Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions internal/devconfig/configfile/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"slices"
"strings"

Expand All @@ -16,6 +18,12 @@ import (
"go.jetify.com/devbox/internal/ux"
)

var versionPattern = regexp.MustCompile(
`\d+\.\d+\.\d+` + // MAJOR.MINOR.PATCH
`(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?` + // optional pre-release
`(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`, // optional build metadata
)

type PackagesMutator struct {
// collection contains the set of package definitions
collection []Package
Expand Down Expand Up @@ -345,6 +353,9 @@ func (p *Package) UnmarshalJSON(data []byte) error {
*p = Package(*alias)
}

// If the version is a file path, read and clean the version from it.
p.Version = resolveVersionFromFile(p.Version)

if p.Patch == "" {
if p.PatchGlibc {
// Force patching if the user has an old config with the deprecated
Expand Down Expand Up @@ -381,3 +392,30 @@ func packagesFromLegacyList(packages []string) []Package {
}
return packagesList
}

// resolveVersionFromFile checks if version is a path to an existing file.
// If so, it reads the file and extracts a version string (e.g. "1.2.3").
// Otherwise it returns the version unchanged.
func resolveVersionFromFile(version string) string {
if version == "" {
return version
}

info, err := os.Stat(version)
if err != nil || info.IsDir() {
return version
}

data, err := os.ReadFile(version)
if err != nil {
return version
}

cleaned := strings.TrimSpace(string(data))
match := versionPattern.FindString(cleaned)
if match == "" {
return version
}

return match
}
124 changes: 123 additions & 1 deletion internal/devconfig/configfile/packages_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package configfile

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/google/go-cmp/cmp"
Expand All @@ -10,6 +13,12 @@ import (

// TestJsonifyConfigPackages tests the jsonMarshal and jsonUnmarshal of the Config.Packages field
func TestJsonifyConfigPackages(t *testing.T) {
fileVersionContent := "1.20\n"
tmpFileVersion := filepath.Join(t.TempDir(), "version")
if err := os.WriteFile(tmpFileVersion, []byte(fileVersionContent), 0o644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}

testCases := []struct {
name string
jsonConfig string
Expand Down Expand Up @@ -46,7 +55,16 @@ func TestJsonifyConfigPackages(t *testing.T) {
},
},
},

{
name: "map-with-file-value",
jsonConfig: fmt.Sprintf(`{"packages":{"python":"latest","go":"%s"}}`, tmpFileVersion),
expected: PackagesMutator{
collection: []Package{
NewVersionOnlyPackage("python", "latest"),
NewVersionOnlyPackage("go", "1.20"),
},
},
},
{
name: "map-with-struct-value",
jsonConfig: `{"packages":{"python":{"version":"latest"}}}`,
Expand Down Expand Up @@ -233,6 +251,110 @@ func diffPackages(t *testing.T, got, want PackagesMutator) string {
return cmp.Diff(want, got, cmpopts.IgnoreUnexported(PackagesMutator{}, Package{}))
}

func TestVersionFromFile(t *testing.T) {
testCases := []struct {
name string
fileContent string
expectedVersion string
}{
{
name: "semver",
fileContent: "1.2.3",
expectedVersion: "1.2.3",
},
{
name: "semver-with-newline",
fileContent: "1.2.3\n",
expectedVersion: "1.2.3",
},
{
name: "semver-with-whitespace",
fileContent: " 1.2.3 \n",
expectedVersion: "1.2.3",
},
{
name: "v-prefix",
fileContent: "v1.2.3\n",
expectedVersion: "1.2.3",
},
{
name: "semver-pre-release",
fileContent: "1.0.0-alpha\n",
expectedVersion: "1.0.0-alpha",
},
{
name: "semver-pre-release-dotted",
fileContent: "1.0.0-alpha.1\n",
expectedVersion: "1.0.0-alpha.1",
},
{
name: "semver-pre-release-numeric",
fileContent: "1.0.0-0.3.7\n",
expectedVersion: "1.0.0-0.3.7",
},
{
name: "semver-build-metadata",
fileContent: "1.0.0+build.123\n",
expectedVersion: "1.0.0+build.123",
},
{
name: "semver-pre-release-and-build",
fileContent: "1.0.0-beta.1+build.456\n",
expectedVersion: "1.0.0-beta.1+build.456",
},
}

for _, testCase := range testCases {
t.Run("string-value-"+testCase.name, func(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "version")
if err := os.WriteFile(tmpFile, []byte(testCase.fileContent), 0o644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}

version := resolveVersionFromFile(tmpFile)

if version != testCase.expectedVersion {
t.Errorf("version: expected %q, got %q", testCase.expectedVersion, version)
}
})
}
}

func TestInvalidVersionFromFile(t *testing.T) {
t.Run("no-file", func(t *testing.T) {
tmpFile := "nonexistent"
version := resolveVersionFromFile(tmpFile)

if tmpFile != version {
t.Errorf("version: expected %q, got %q", tmpFile, version)
}
})

t.Run("no-version-in-file", func(t *testing.T) {
fileContent := "hello world\n"
tmpFile := filepath.Join(t.TempDir(), "version")
if err := os.WriteFile(tmpFile, []byte(fileContent), 0o644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}

version := resolveVersionFromFile(tmpFile)

if tmpFile != version {
t.Errorf("version: expected %q, got %q", tmpFile, version)
}
})

t.Run("directory-not-treated-as-file", func(t *testing.T) {
dir := t.TempDir()

version := resolveVersionFromFile(dir)

if dir != version {
t.Errorf("version: expected %q, got %q", dir, version)
}
})
}

func TestParseVersionedName(t *testing.T) {
testCases := []struct {
name string
Expand Down