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
5 changes: 2 additions & 3 deletions internal/devconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,7 @@ func Find(path string) (*Config, error) {
// searchDir looks for a config file in dir. It does not search parent
// directories.
func searchDir(dir string) (*Config, error) {
try := []string{configfile.DefaultName}
for _, name := range try {
for _, name := range configfile.ValidNames {
path := filepath.Join(dir, name)
slog.Debug("trying config file", "path", path)

Expand All @@ -156,7 +155,7 @@ func searchDir(dir string) (*Config, error) {
if errors.Is(err, os.ErrNotExist) {
continue
}
// Ignore directories named devbox.json.
// Ignore directories that happen to share a config filename.
if errors.Is(err, errIsDirectory) {
continue
}
Expand Down
80 changes: 80 additions & 0 deletions internal/devconfig/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,86 @@ func TestFindError(t *testing.T) {
})
}

func TestJSONCConfig(t *testing.T) {
const jsonc = "{\n // devbox lets you comment your config\n \"packages\": []\n}\n"

t.Run("OpenDiscoversJSONC", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, configfile.AltName)
if err := os.WriteFile(path, []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Open(dir)
if err != nil {
t.Fatalf("Open(%q) error: %v", dir, err)
}
if cfg.Root.AbsRootPath != path {
t.Errorf("cfg.Root.AbsRootPath = %q, want %q", cfg.Root.AbsRootPath, path)
}
})

t.Run("FindDiscoversJSONCInParent", func(t *testing.T) {
root, child, _ := mkNestedDirs(t)
path := filepath.Join(root, configfile.AltName)
if err := os.WriteFile(path, []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Find(child)
if err != nil {
t.Fatalf("Find(%q) error: %v", child, err)
}
if cfg.Root.AbsRootPath != path {
t.Errorf("cfg.Root.AbsRootPath = %q, want %q", cfg.Root.AbsRootPath, path)
}
})

t.Run("DefaultNameWinsWhenBothExist", func(t *testing.T) {
dir := t.TempDir()
jsonPath := filepath.Join(dir, configfile.DefaultName)
if err := os.WriteFile(jsonPath, []byte(`{"packages": []}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, configfile.AltName), []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Open(dir)
if err != nil {
t.Fatalf("Open(%q) error: %v", dir, err)
}
if cfg.Root.AbsRootPath != jsonPath {
t.Errorf("cfg.Root.AbsRootPath = %q, want %q", cfg.Root.AbsRootPath, jsonPath)
}
})

t.Run("SaveWritesBackToJSONC", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, configfile.AltName)
if err := os.WriteFile(path, []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Open(dir)
if err != nil {
t.Fatalf("Open(%q) error: %v", dir, err)
}
if err := cfg.Root.SaveTo(dir); err != nil {
t.Fatalf("SaveTo(%q) error: %v", dir, err)
}

// Saving must write back to devbox.jsonc, not create a devbox.json.
if _, err := os.Stat(filepath.Join(dir, configfile.DefaultName)); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("SaveTo created a %s; want it to update %s in place",
configfile.DefaultName, configfile.AltName)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("os.Stat(%q) after save: %v", path, err)
}
})
}

// mkNestedDirs sets up a nested directory structure for Find and Open tests.
func mkNestedDirs(t *testing.T) (root, child, nested string) {
t.Helper()
Expand Down
29 changes: 26 additions & 3 deletions internal/devconfig/configfile/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,19 @@ import (

const (
DefaultName = "devbox.json"
// AltName is an alternate config filename that devbox also recognizes.
// devbox.json already permits comments (it is parsed as JSONC), but
// editors and GitHub diffs flag comments in a .json file as errors. Naming
// the file devbox.jsonc lets those tools highlight it correctly without any
// extra configuration. See https://github.com/jetify-com/devbox/issues/2602
AltName = "devbox.jsonc"
)

// ValidNames are the config filenames devbox recognizes, in the order they are
// searched for within a directory. devbox.json is listed first so it wins when
// a directory happens to contain both files.
var ValidNames = []string{DefaultName, AltName}

// ConfigFile defines a devbox environment as JSON.
type ConfigFile struct {
// AbsRootPath is the absolute path to the devbox.json or plugin.json file
Expand Down Expand Up @@ -114,9 +125,21 @@ func (c *ConfigFile) InitHook() *shellcmd.Commands {
return c.Shell.InitHook
}

// SaveTo writes the config to a file.
func (c *ConfigFile) SaveTo(path string) error {
return os.WriteFile(filepath.Join(path, DefaultName), c.Bytes(), 0o644)
// FileName returns the base name of the config file (e.g. "devbox.json" or
// "devbox.jsonc"). It preserves whatever name the config was loaded from so
// that saving writes back to the same file. It falls back to [DefaultName] when
// the config has no on-disk path (for example, a config loaded from a URL).
func (c *ConfigFile) FileName() string {
if c.AbsRootPath != "" {
return filepath.Base(c.AbsRootPath)
}
return DefaultName
}

// SaveTo writes the config into the directory dir, using the config's original
// filename (see [ConfigFile.FileName]).
func (c *ConfigFile) SaveTo(dir string) error {
return os.WriteFile(filepath.Join(dir, c.FileName()), c.Bytes(), 0o644)
}

// TODO: Can we remove SaveTo and just use Save()?
Expand Down
51 changes: 51 additions & 0 deletions internal/devconfig/configfile/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package configfile
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -782,3 +784,52 @@ func TestNixpkgsValidation(t *testing.T) {
})
}
}

func TestFileName(t *testing.T) {
testCases := map[string]struct {
absRootPath string
want string
}{
"empty_falls_back_to_default": {"", DefaultName},
"json": {"/home/user/project/devbox.json", DefaultName},
"jsonc": {"/home/user/project/devbox.jsonc", AltName},
}

for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
c := &ConfigFile{AbsRootPath: testCase.absRootPath}
if got := c.FileName(); got != testCase.want {
t.Errorf("FileName() = %q, want %q", got, testCase.want)
}
})
}
}

func TestSaveToPreservesFileName(t *testing.T) {
testCases := map[string]struct {
absRootPath string
wantName string
}{
"json": {"/anywhere/devbox.json", DefaultName},
"jsonc": {"/anywhere/devbox.jsonc", AltName},
"no_path_uses_default": {"", DefaultName},
}

for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
in, err := LoadBytes([]byte(`{"packages": []}`))
if err != nil {
t.Fatalf("LoadBytes error: %v", err)
}
in.AbsRootPath = testCase.absRootPath

dir := t.TempDir()
if err := in.SaveTo(dir); err != nil {
t.Fatalf("SaveTo(%q) error: %v", dir, err)
}
if _, err := os.Stat(filepath.Join(dir, testCase.wantName)); err != nil {
t.Errorf("expected %q to be written: %v", testCase.wantName, err)
}
})
}
}
Loading