From a984b68069a982c77096deabb58f0679fe367d02 Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Sun, 18 Oct 2026 09:02:05 -0500 Subject: [PATCH] feat: read repo and root from a TOML config file Repo and root can be set in $XDG_CONFIG_HOME/agentsync/config.toml (default ~/.config/agentsync/config.toml). Precedence is flag, then env var, then the file. A file that cannot be read, does not parse, has an unknown key or an empty value refuses with exit 2 naming the file. Runs report where Repo and Root came from. Adds github.com/BurntSushi/toml, the first dependency. --- README.md | 13 +- cmd/agentsync/config_test.go | 311 +++++++++++++++++++++++++++++++++ cmd/agentsync/doctor.go | 5 +- cmd/agentsync/home_test.go | 14 +- cmd/agentsync/import.go | 5 +- cmd/agentsync/inventory.go | 5 +- cmd/agentsync/link.go | 5 +- cmd/agentsync/main.go | 78 +++++++-- cmd/agentsync/reconcile.go | 5 +- cmd/agentsync/root.go | 45 +++-- cmd/agentsync/root_test.go | 10 ++ coverage-floors.txt | 3 +- docs/configuration.md | 79 +++++++-- docs/development.md | 6 + docs/scan.md | 2 +- go.mod | 2 + go.sum | 2 + internal/config/config.go | 106 +++++++++++ internal/config/config_test.go | 149 ++++++++++++++++ 19 files changed, 778 insertions(+), 67 deletions(-) create mode 100644 cmd/agentsync/config_test.go create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/README.md b/README.md index 100b201..5d98f08 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,17 @@ just install ## Quick start -```bash -export AGENTSYNC_REPO="$HOME/src/my-agents" # your canonical agents repo -export AGENTSYNC_ROOT="$HOME/src" # the tree holding your projects +Tell agentsync where your trees are in `~/.config/agentsync/config.toml`: + +```toml +repo = "~/src/my-agents" # your canonical agents repo +root = "~/src" # the tree holding your projects +``` +`$AGENTSYNC_REPO` and `$AGENTSYNC_ROOT`, or `--repo` and `--root`, work too and +take precedence; see [configuration](docs/configuration.md). + +```bash cd ~/src/api agentsync scan --at-risk # files protected nowhere else agentsync import --apply # back them up into the repo diff --git a/cmd/agentsync/config_test.go b/cmd/agentsync/config_test.go new file mode 100644 index 0000000..88c4c61 --- /dev/null +++ b/cmd/agentsync/config_test.go @@ -0,0 +1,311 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestMain points HOME and XDG_CONFIG_HOME at empty temp dirs, so no test can +// read the developer's real config file and pass or fail because of it. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "agentsync-test-") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(exitFailure) + } + home := filepath.Join(dir, "home") + for _, err := range []error{ + os.Mkdir(home, 0o755), + os.Setenv("HOME", home), + os.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg")), + } { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(exitFailure) + } + } + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +// useConfig makes content this test's config file and clears both env vars, +// leaving the file as the only source. Empty content leaves no file at all. +func useConfig(t *testing.T, content string) string { + t.Helper() + t.Setenv(envRepo, "") + t.Setenv(envRoot, "") + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + path := filepath.Join(xdg, "agentsync", "config.toml") + if content != "" { + mustWriteT(t, path, content) + } + return path +} + +func TestOpenRepo_Precedence(t *testing.T) { + flagDir, envDir, fileDir := t.TempDir(), t.TempDir(), t.TempDir() + tests := []struct { + name, flag, env, file, want, wantFrom string + }{ + {"file alone", "", "", fileDir, fileDir, "config file"}, + {"env alone", "", envDir, "", envDir, "$" + envRepo}, + {"flag alone", flagDir, "", "", flagDir, "--repo"}, + {"env beats file", "", envDir, fileDir, envDir, "$" + envRepo}, + {"flag beats env and file", flagDir, envDir, fileDir, flagDir, "--repo"}, + {"blank flag counts as unset", " ", envDir, "", envDir, "$" + envRepo}, + {"blank env counts as unset", "", " ", fileDir, fileDir, "config file"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + content := "" + if tc.file != "" { + content = fmt.Sprintf("repo = %q\n", tc.file) + } + path := useConfig(t, content) + t.Setenv(envRepo, tc.env) + + repo, from, err := openRepo(tc.flag) + if err != nil { + t.Fatalf("openRepo: %v", err) + } + if want := mustResolveDir(t, tc.want); repo.Root != want { + t.Errorf("repo = %s, want %s", repo.Root, want) + } + wantFrom := tc.wantFrom + if wantFrom == "config file" { + wantFrom += " " + path + } + if from != wantFrom { + t.Errorf("from = %q, want %q", from, wantFrom) + } + }) + } +} + +// configFixture is projectScopeFixture with the default tree's resolved path +// returned and both env vars left for each test to set. +func configFixture(t *testing.T) (repoRoot, wide string) { + t.Helper() + home, repoRoot := projectScopeFixture(t) + return repoRoot, filepath.Join(mustResolveDir(t, home), "Web") +} + +func TestRun_ConfigFileSuppliesRepoAndRoot(t *testing.T) { + repoRoot, wide := configFixture(t) + + t.Run("both from the file", func(t *testing.T) { + path := useConfig(t, fmt.Sprintf("repo = %q\nroot = %q\n", repoRoot, wide)) + code, stdout, stderr := runCLI(t, "scan", "--all") + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) + } + for _, w := range []string{ + "Repo: ~/Web/agents-repo (from config file " + path + ").", + "Root: ~/Web (from --all: the whole default tree, config file " + path + ").", + } { + if !strings.Contains(stdout, w) { + t.Errorf("report is missing %q:\n%s", w, stdout) + } + } + }) + + t.Run("env repo, file root", func(t *testing.T) { + path := useConfig(t, fmt.Sprintf("root = %q\n", wide)) + t.Setenv(envRepo, repoRoot) + code, stdout, stderr := runCLI(t, "scan", "--all") + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) + } + for _, w := range []string{"(from $" + envRepo + ")", "config file " + path} { + if !strings.Contains(stdout, w) { + t.Errorf("report is missing %q:\n%s", w, stdout) + } + } + }) + + t.Run("file repo, env root", func(t *testing.T) { + path := useConfig(t, fmt.Sprintf("repo = %q\n", repoRoot)) + t.Setenv(envRoot, wide) + code, stdout, stderr := runCLI(t, "scan", "--all") + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) + } + for _, w := range []string{"(from config file " + path + ")", "the whole default tree, $" + envRoot} { + if !strings.Contains(stdout, w) { + t.Errorf("report is missing %q:\n%s", w, stdout) + } + } + }) + + t.Run("--json keeps both lines off stdout", func(t *testing.T) { + useConfig(t, fmt.Sprintf("repo = %q\nroot = %q\n", repoRoot, wide)) + code, stdout, stderr := runCLI(t, "scan", "--all", "--json") + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) + } + var doc any + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Errorf("stdout is not JSON: %v\n%s", err, stdout) + } + for _, w := range []string{"Repo: ", "Root: "} { + if !strings.Contains(stderr, w) { + t.Errorf("stderr is missing %q:\n%s", w, stderr) + } + } + }) +} + +// TestRunInventory_ConfigRootIsExplicit: a config root is the default tree, +// like $AGENTSYNC_ROOT, so inventory still ignores the project underfoot. +func TestRunInventory_ConfigRootIsExplicit(t *testing.T) { + repoRoot, wide := configFixture(t) + path := useConfig(t, fmt.Sprintf("repo = %q\nroot = %q\n", repoRoot, wide)) + t.Chdir(filepath.Join(wide, "alpha")) + + code, stdout, stderr := runCLI(t, "inventory", "--stdout") + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) + } + if w := "Root: ~/Web (the default tree, config file " + path; !strings.Contains(stderr, w) { + t.Errorf("stderr is missing %q:\n%s", w, stderr) + } + if !strings.Contains(stdout, "Web/beta") { + t.Errorf("inventory narrowed to the project underfoot; Web/beta is missing:\n%s", stdout) + } +} + +func TestRun_BrokenConfigRefuses(t *testing.T) { + repoRoot, wide := configFixture(t) + tests := []struct { + name, content string + setup func(t *testing.T, path string) + want string + }{ + {"malformed", "repo = \"/a\"\nroot = \n", nil, "line 2"}, + {"unknown key", "rpeo = \"/a\"\n", nil, `"rpeo"`}, + {"empty value", "repo = \"\"\n", nil, "is empty"}, + {"relative root", "root = \"Web\"\n", nil, "must be an absolute path"}, + {"is a directory", "", mustMkdirT, "is a directory"}, + {"dangling symlink", "", func(t *testing.T, path string) { + mustMkdirT(t, filepath.Dir(path)) + if err := os.Symlink(path+".gone", path); err != nil { + t.Fatal(err) + } + }, "dangling symlink"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := useConfig(t, tc.content) + if tc.setup != nil { + tc.setup(t, path) + } + for _, args := range [][]string{ + {"scan", "--root", wide}, // repo from the file + {"inventory", "--repo", repoRoot}, // root from the file + } { + code, stdout, stderr := runCLI(t, args...) + if code != exitFailure { + t.Errorf("%v: exit = %d, want %d", args, code, exitFailure) + } + // The want is matched with path removed, since t.TempDir puts the + // subtest name in it. + if !strings.Contains(stderr, path) || !strings.Contains(strings.ReplaceAll(stderr, path, ""), tc.want) { + t.Errorf("%v: refusal does not name %s and say %q:\n%s", args, path, tc.want, stderr) + } + if stdout != "" { + t.Errorf("%v: a refused run wrote a report:\n%s", args, stdout) + } + } + }) + } +} + +// TestRun_BrokenConfigUnreadWhenSettled: the file is read only for a value +// still unset, so it cannot block the run a user makes to work around it. +func TestRun_BrokenConfigUnreadWhenSettled(t *testing.T) { + repoRoot, wide := configFixture(t) + for _, tc := range []struct { + name string + env bool + args []string + }{ + {"both flags", false, []string{"scan", "--repo", repoRoot, "--root", wide}}, + {"inventory, both flags", false, []string{"inventory", "--stdout", "--repo", repoRoot, "--root", wide}}, + {"both env vars", true, []string{"scan", "--all"}}, + } { + t.Run(tc.name, func(t *testing.T) { + useConfig(t, "this is = = not toml\n") + if tc.env { + t.Setenv(envRepo, repoRoot) + t.Setenv(envRoot, wide) + } + if code, _, stderr := runCLI(t, tc.args...); code != exitOK { + t.Errorf("exit = %d, want %d: the broken file was read\n%s", code, exitOK, stderr) + } + }) + } +} + +func TestRun_NothingConfiguredRefuses(t *testing.T) { + repoRoot, wide := configFixture(t) + path := useConfig(t, "") + + tests := []struct { + args []string + want string + }{ + {[]string{"scan", "--root", wide}, + "agentsync: no canonical agent repo configured.\n" + + " Pass it each run: --repo PATH (or set $AGENTSYNC_REPO)\n" + + " Or set repo in " + path + "\n"}, + {[]string{"inventory", "--repo", repoRoot}, + "agentsync: no default tree of projects configured.\n" + + " Pass it each run: --root PATH (or set $AGENTSYNC_ROOT)\n" + + " Or set root in " + path + "\n"}, + {[]string{"scan", "--all", "--repo", repoRoot}, + "agentsync: --all: no default tree of projects configured.\n" + + " Pass it each run: --root PATH (or set $AGENTSYNC_ROOT)\n" + + " Or set root in " + path + "\n"}, + } + for _, tc := range tests { + code, stdout, stderr := runCLI(t, tc.args...) + if code != exitFailure { + t.Errorf("%v: exit = %d, want %d", tc.args, code, exitFailure) + } + if stderr != tc.want { + t.Errorf("%v: stderr =\n%s\nwant\n%s", tc.args, stderr, tc.want) + } + if stdout != "" { + t.Errorf("%v: a refused run wrote a report:\n%s", tc.args, stdout) + } + } +} + +// TestOpenRepo_BadPathNamesItsSource: a repo that fails to open says which +// source supplied it, so a stale config entry is not mistaken for the env var. +func TestOpenRepo_BadPathNamesItsSource(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nope") + path := useConfig(t, fmt.Sprintf("repo = %q\n", missing)) + _, _, err := openRepo("") + if want := "repo from config file " + path + ": "; err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("err = %v, want it to start %q", err, want) + } +} + +// TestRun_UnlocatableConfigRefuses: with no usable $HOME or $XDG_CONFIG_HOME +// the file cannot be ruled out, so a run that needs it refuses. +func TestRun_UnlocatableConfigRefuses(t *testing.T) { + useConfig(t, "") + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", "") + code, _, stderr := runCLI(t, "scan", "--root", t.TempDir()) + if code != exitFailure || !strings.Contains(stderr, "cannot locate the config file") { + t.Errorf("exit = %d, stderr = %q; want %d naming the config file", code, stderr, exitFailure) + } +} diff --git a/cmd/agentsync/doctor.go b/cmd/agentsync/doctor.go index b5713ab..68d34a8 100644 --- a/cmd/agentsync/doctor.go +++ b/cmd/agentsync/doctor.go @@ -29,7 +29,7 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { fs.Usage = func() { fmt.Fprint(stderr, usage) } rootF := registerRoot(fs) - repoPath := fs.String("repo", defaultRepo(), "canonical agent repo") + repoPath := fs.String("repo", "", "canonical agent repo") asJSON := fs.Bool("json", false, "emit JSON") allowOutsideHome := fs.Bool(scan.AllowOutsideHomeFlag, false, "projects legitimately live outside $HOME; suppress the home warning") @@ -43,7 +43,7 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { return exitFailure } - repo, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo(*repoPath) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure @@ -84,6 +84,7 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "agentsync: warning: %s\n", warning) } + emitRepoChoice(reportStream(*asJSON, stdout, stderr), repo, repoFrom) emitRootChoice(reportStream(*asJSON, stdout, stderr), repo.Home, root) if *asJSON { if err := run.emitJSON(stdout); err != nil { diff --git a/cmd/agentsync/home_test.go b/cmd/agentsync/home_test.go index 4fb982a..7d12b92 100644 --- a/cmd/agentsync/home_test.go +++ b/cmd/agentsync/home_test.go @@ -222,14 +222,14 @@ func TestRunScan_OutsideHomeOptOutIsSilent(t *testing.T) { } } -// withoutRootLine drops the root-selection line, which every run prints and -// which --json sends to stderr to keep stdout parseable. It is not a -// diagnostic, so a test asserting that a correct run raises no diagnostic has -// to look past it — and only past it: everything else on stderr still fails. -func withoutRootLine(s string) string { +// withoutChoiceLines drops the Repo and Root lines, which every run prints and +// which --json sends to stderr to keep stdout parseable. They are not +// diagnostics, so a test asserting that a correct run raises none has to look +// past them — and only past them: everything else on stderr still fails. +func withoutChoiceLines(s string) string { var kept []string for _, line := range strings.Split(s, "\n") { - if line == "" || strings.HasPrefix(line, "Root: ") { + if line == "" || strings.HasPrefix(line, "Root: ") || strings.HasPrefix(line, "Repo: ") { continue } kept = append(kept, line) @@ -245,7 +245,7 @@ func TestRunScan_CorrectHomeIsSilent(t *testing.T) { for _, extra := range [][]string{nil, {"--json"}, {"--at-risk"}, {"--fail-on-risk"}} { _, _, stderr := runCLI(t, scanArgs(repoRoot, root, extra...)...) - if got := withoutRootLine(stderr); got != "" { + if got := withoutChoiceLines(stderr); got != "" { t.Errorf("correct run with %v wrote to stderr:\n%s", extra, got) } } diff --git a/cmd/agentsync/import.go b/cmd/agentsync/import.go index 77a238e..647fda3 100644 --- a/cmd/agentsync/import.go +++ b/cmd/agentsync/import.go @@ -29,7 +29,7 @@ func runImport(args []string, stdout, stderr io.Writer) int { fs.Usage = func() { fmt.Fprint(stderr, usage) } rootF := registerRoot(fs) - repoPath := fs.String("repo", defaultRepo(), "canonical agent repo") + repoPath := fs.String("repo", "", "canonical agent repo") apply := fs.Bool("apply", false, "write the captures; without it this is a dry run") var update updateFlag fs.Var(&update, "update", @@ -68,7 +68,7 @@ func runImport(args []string, stdout, stderr io.Writer) int { return exitFailure } - repo, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo(*repoPath) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure @@ -112,6 +112,7 @@ func runImport(args []string, stdout, stderr io.Writer) int { run.written, run.errs = run.plan.Apply() } + emitRepoChoice(reportStream(*asJSON, stdout, stderr), repo, repoFrom) emitRootChoice(reportStream(*asJSON, stdout, stderr), repo.Home, root) if *asJSON { if err := run.emitJSON(stdout); err != nil { diff --git a/cmd/agentsync/inventory.go b/cmd/agentsync/inventory.go index bf1bae1..75fc134 100644 --- a/cmd/agentsync/inventory.go +++ b/cmd/agentsync/inventory.go @@ -43,7 +43,7 @@ func runInventory(args []string, stdout, stderr io.Writer) int { fs.Usage = func() { fmt.Fprint(stderr, usage) } rootF := registerWideRoot(fs) - repoPath := fs.String("repo", defaultRepo(), "canonical agent repo") + repoPath := fs.String("repo", "", "canonical agent repo") out := fs.String("out", "", "write here instead of /"+inventoryFileName) toStdout := fs.Bool("stdout", false, "write to stdout and leave the file alone") check := fs.Bool("check", false, @@ -71,7 +71,7 @@ func runInventory(args []string, stdout, stderr io.Writer) int { return exitFailure } - repo, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo(*repoPath) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure @@ -116,6 +116,7 @@ func runInventory(args []string, stdout, stderr io.Writer) int { } // --stdout is this command's machine-readable form, as --json is elsewhere. + emitRepoChoice(reportStream(*toStdout, stdout, stderr), repo, repoFrom) emitRootChoice(reportStream(*toStdout, stdout, stderr), repo.Home, root) if *toStdout { diff --git a/cmd/agentsync/link.go b/cmd/agentsync/link.go index c4c03ba..4902743 100644 --- a/cmd/agentsync/link.go +++ b/cmd/agentsync/link.go @@ -136,7 +136,7 @@ func runLink(args []string, stdout, stderr io.Writer) int { fs.Usage = func() { fmt.Fprint(stderr, usage) } rootF := registerRoot(fs) - repoPath := fs.String("repo", defaultRepo(), "canonical agent repo") + repoPath := fs.String("repo", "", "canonical agent repo") apply := fs.Bool("apply", false, "create the links; without it this is a dry run") excludeLocal := fs.Bool(linker.ExcludeLocalFlag, false, "add the agents directory to the project's .git/info/exclude before linking") @@ -175,7 +175,7 @@ func runLink(args []string, stdout, stderr io.Writer) int { return exitFailure } - repo, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo(*repoPath) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure @@ -261,6 +261,7 @@ func runLink(args []string, stdout, stderr io.Writer) int { run.done, run.errs = plan.Apply(repo, st) } + emitRepoChoice(reportStream(*asJSON, stdout, stderr), repo, repoFrom) emitRootChoice(reportStream(*asJSON, stdout, stderr), repo.Home, root) if *asJSON { if err := run.emitJSON(stdout); err != nil { diff --git a/cmd/agentsync/main.go b/cmd/agentsync/main.go index f9dbce5..220c488 100644 --- a/cmd/agentsync/main.go +++ b/cmd/agentsync/main.go @@ -45,6 +45,7 @@ import ( "strings" "unicode/utf8" + "github.com/agentic-tooling/agentsync/internal/config" "github.com/agentic-tooling/agentsync/internal/scan" ) @@ -68,16 +69,17 @@ Common flags: named it. Nothing found before $HOME is refused rather than widened: pass --all or --root to scan from there. Every run prints the root it chose and how it decided. - --all scan $AGENTSYNC_ROOT, the whole default tree, rather than the project - the working directory is in. Refused together with --root: - the two say different things and neither should quietly win. + --all scan the whole default tree ($AGENTSYNC_ROOT, else root in the + config file) rather than the project the working directory + is in. Refused together with --root: the two say different + things and neither should quietly win. inventory is the one command that does NOT derive a root: it describes the whole -machine by definition, so --root still names a tree but the default is -$AGENTSYNC_ROOT and never the project you are standing in. --all is accepted there and means what -the default already means. - --repo PATH canonical agent repo (default $AGENTSYNC_REPO; required, one or - the other) +machine by definition, so --root still names a tree but the default is the whole +default tree and never the project you are standing in. --all is accepted there +and means what the default already means. + --repo PATH canonical agent repo (default $AGENTSYNC_REPO, else repo in the + config file; required from one of the three) --json emit machine-readable JSON --allow-outside-home your projects legitimately live outside $HOME; do not warn @@ -85,6 +87,10 @@ the default already means. link, reconcile --apply) that every project key falls under _outside-home/ +Config file: $XDG_CONFIG_HOME/agentsync/config.toml, else +~/.config/agentsync/config.toml. It may set repo and root; a flag beats the +environment, which beats the file. A file that exists but is broken refuses. + scan flags: --at-risk list only entries whose content is protected nowhere --fail-on-risk exit 1 if any at-risk entry is found (for CI) @@ -273,15 +279,52 @@ const ( envRoot = "AGENTSYNC_ROOT" ) -func defaultRepo() string { return os.Getenv(envRepo) } +// loadConfig reads the config file. Callers reach it only once flag and env +// have left a value unset, so a broken file never blocks a run that named both. +func loadConfig() (config.Config, string, error) { + path, err := config.Path() + if err != nil { + return config.Config{}, "", err + } + c, err := config.Load(path) + return c, path, err +} -// openRepo refuses an unnamed repo rather than guessing one: every link this -// tool writes points into it, so a wrong guess is written to disk. -func openRepo(path string) (*scan.Repo, error) { - if strings.TrimSpace(path) == "" { - return nil, fmt.Errorf("no canonical agent repo: pass --repo PATH or set $%s", envRepo) +// openRepo resolves the repo by flag, then $AGENTSYNC_REPO, then the config +// file, and refuses rather than guessing: every link this tool writes points +// into it. The string returned names where the path came from. +func openRepo(flagValue string) (*scan.Repo, string, error) { + path, from := strings.TrimSpace(flagValue), "--repo" + if path == "" { + path, from = strings.TrimSpace(os.Getenv(envRepo)), "$"+envRepo + } + if path == "" { + c, cfgPath, err := loadConfig() + if err != nil { + return nil, "", err + } + if c.Repo == "" { + return nil, "", notConfigured("canonical agent repo", "repo", envRepo, cfgPath) + } + path, from = c.Repo, "config file "+cfgPath } - return scan.NewRepo(path) + repo, err := scan.NewRepo(path) + if err != nil { + return nil, "", fmt.Errorf("repo from %s: %w", from, err) + } + return repo, from, nil +} + +// notConfigured is the refusal for a setting no source supplied. key is both +// the flag name and the config key. +func notConfigured(what, key, env, cfgPath string) error { + return fmt.Errorf("no %s configured.\n Pass it each run: --%s PATH (or set $%s)\n Or set %s in %s", what, key, env, key, cfgPath) +} + +// emitRepoChoice prints which repo this run uses and where that came from, +// beside the Root line and under the same stdout/stderr rule. +func emitRepoChoice(w io.Writer, repo *scan.Repo, from string) { + fmt.Fprintf(w, "Repo: %s (from %s).\n", displayPath(repo.Home, repo.Root), from) } func runScan(args []string, stdout, stderr io.Writer) int { @@ -290,7 +333,7 @@ func runScan(args []string, stdout, stderr io.Writer) int { fs.Usage = func() { fmt.Fprint(stderr, usage) } rootF := registerRoot(fs) - repoPath := fs.String("repo", defaultRepo(), "canonical agent repo") + repoPath := fs.String("repo", "", "canonical agent repo") asJSON := fs.Bool("json", false, "emit JSON") atRiskOnly := fs.Bool("at-risk", false, "list only at-risk entries") failOnRisk := fs.Bool("fail-on-risk", false, "exit 1 if any at-risk entry is found") @@ -306,7 +349,7 @@ func runScan(args []string, stdout, stderr io.Writer) int { return exitFailure } - repo, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo(*repoPath) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure @@ -329,6 +372,7 @@ func runScan(args []string, stdout, stderr io.Writer) int { // reads the caveat before the numbers it applies to. warnHome(stderr, repo, root.Path, res.Dirs, *allowOutsideHome) + emitRepoChoice(reportStream(*asJSON, stdout, stderr), repo, repoFrom) emitRootChoice(reportStream(*asJSON, stdout, stderr), repo.Home, root) if *asJSON { if err := emitJSON(stdout, res, *atRiskOnly); err != nil { diff --git a/cmd/agentsync/reconcile.go b/cmd/agentsync/reconcile.go index 7ab53ec..97bd6fe 100644 --- a/cmd/agentsync/reconcile.go +++ b/cmd/agentsync/reconcile.go @@ -109,7 +109,7 @@ func runReconcile(args []string, stdout, stderr io.Writer) int { fs.Usage = func() { fmt.Fprint(stderr, usage) } rootF := registerRoot(fs) - repoPath := fs.String("repo", defaultRepo(), "canonical agent repo") + repoPath := fs.String("repo", "", "canonical agent repo") apply := fs.Bool("apply", false, "make the changes; without it this is a dry run") asJSON := fs.Bool("json", false, "emit JSON") allowOutsideHome := fs.Bool(scan.AllowOutsideHomeFlag, false, @@ -165,7 +165,7 @@ func runReconcile(args []string, stdout, stderr io.Writer) int { return exitFailure } - repo, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo(*repoPath) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure @@ -241,6 +241,7 @@ func runReconcile(args []string, stdout, stderr io.Writer) int { run.done, run.errs = plan.Apply(repo, st) } + emitRepoChoice(reportStream(*asJSON, stdout, stderr), repo, repoFrom) emitRootChoice(reportStream(*asJSON, stdout, stderr), repo.Home, root) if *asJSON { if err := run.emitJSON(stdout); err != nil { diff --git a/cmd/agentsync/root.go b/cmd/agentsync/root.go index 5a6b05b..7b1cfe9 100644 --- a/cmd/agentsync/root.go +++ b/cmd/agentsync/root.go @@ -13,7 +13,7 @@ import ( "github.com/agentic-tooling/agentsync/internal/scan" ) -// allFlag widens a run back to the whole default tree ($AGENTSYNC_ROOT), so the machine-wide view +// allFlag widens a run back to the whole default tree, so the machine-wide view // is discoverable from --help rather than folklore about --root. const allFlag = "all" @@ -51,6 +51,9 @@ type rootChoice struct { // marker is the entry that identified the project, named in the report. marker string + + // from names where rootFromAll and rootFromWide found the default tree. + from string } // rootFlags are the two ways a run may name its root, registered together @@ -64,7 +67,7 @@ func registerRoot(fs *flag.FlagSet) rootFlags { return rootFlags{ root: fs.String("root", "", "directory tree to scan (default: the project the working directory is in)"), - all: fs.Bool(allFlag, false, "scan the whole default tree ($"+envRoot+") instead of the project you are in"), + all: fs.Bool(allFlag, false, "scan the whole default tree ($"+envRoot+" or root in the config file) instead of the project you are in"), } } @@ -75,22 +78,30 @@ func registerRoot(fs *flag.FlagSet) rootFlags { // is not a safety boundary. func registerWideRoot(fs *flag.FlagSet) rootFlags { return rootFlags{ - root: fs.String("root", "", "directory tree to scan (default: the whole tree, $"+envRoot+")"), - all: fs.Bool(allFlag, false, "scan the whole default tree ($"+envRoot+"), which is already the default here"), + root: fs.String("root", "", "directory tree to scan (default: the whole tree, $"+envRoot+" or root in the config file)"), + all: fs.Bool(allFlag, false, "scan the whole default tree ($"+envRoot+" or root in the config file), which is already the default here"), } } -// wideRoot is the whole default tree. There is no machine-agnostic guess for it, -// so an unset $AGENTSYNC_ROOT refuses rather than scanning somewhere unnamed. -func wideRoot() (string, error) { +// wideRoot is the whole default tree, from $AGENTSYNC_ROOT or else the config +// file, with where it came from. There is no machine-agnostic guess for it, so +// neither being set refuses rather than scanning somewhere unnamed. +func wideRoot() (path, from string, err error) { if p := strings.TrimSpace(os.Getenv(envRoot)); p != "" { // A relative value would name a different tree from every directory. if !filepath.IsAbs(p) { - return "", fmt.Errorf("$%s must be an absolute path, got %q", envRoot, p) + return "", "", fmt.Errorf("$%s must be an absolute path, got %q", envRoot, p) } - return p, nil + return p, "$" + envRoot, nil + } + c, cfgPath, err := loadConfig() + if err != nil { + return "", "", err + } + if c.Root == "" { + return "", "", notConfigured("default tree of projects", "root", envRoot, cfgPath) } - return "", fmt.Errorf("no default tree: pass --root PATH or set $%s to the directory holding your projects", envRoot) + return c.Root, "config file " + cfgPath, nil } // settled returns the choice the flags decide with no derivation of any kind. @@ -111,11 +122,11 @@ func (f rootFlags) settled(home string) (rootChoice, error) { return rootChoice{}, fmt.Errorf("no home directory, so project keys and the walk up from the working directory cannot be worked out; pass --root PATH") } if *f.all { - p, err := wideRoot() + p, from, err := wideRoot() if err != nil { return rootChoice{}, fmt.Errorf("--%s: %w", allFlag, err) } - return rootChoice{Path: p, Origin: rootFromAll}, nil + return rootChoice{Path: p, Origin: rootFromAll, from: from}, nil } return rootChoice{}, nil } @@ -133,11 +144,11 @@ func (f rootFlags) resolveWide(home string) (rootChoice, error) { if err != nil || c.Origin != rootOriginInvalid { return c, err } - p, err := wideRoot() + p, from, err := wideRoot() if err != nil { return rootChoice{}, err } - return rootChoice{Path: p, Origin: rootFromWide}, nil + return rootChoice{Path: p, Origin: rootFromWide, from: from}, nil } // resolve decides the root, given the resolved home directory project keys are @@ -159,7 +170,7 @@ func (f rootFlags) resolve(home string) (rootChoice, error) { // A new project has no marker yet, and widening to the whole tree from there // would scan every project on the machine; --all is the explicit way to ask. if dir == "" { - return rootChoice{}, fmt.Errorf("no project found above the working directory, so nothing was scanned; run inside a project or create its .claude/agents/, or pass --%s to scan the whole tree ($%s) or --root PATH", allFlag, envRoot) + return rootChoice{}, fmt.Errorf("no project found above the working directory, so nothing was scanned; run inside a project or create its .claude/agents/, or pass --%s to scan the whole tree ($%s or root in the config file) or --root PATH", allFlag, envRoot) } return rootChoice{ Path: dir, @@ -277,9 +288,9 @@ func emitRootChoice(w io.Writer, home string, c rootChoice) { fmt.Fprintf(w, "Root: %s (from the current directory, which is in a project: %s) — pass --root PATH or --%s to widen.\n", shown, c.marker, allFlag) case rootFromAll: - fmt.Fprintf(w, "Root: %s (from --%s: the whole default tree, $%s).\n", shown, allFlag, envRoot) + fmt.Fprintf(w, "Root: %s (from --%s: the whole default tree, %s).\n", shown, allFlag, c.from) case rootFromWide: - fmt.Fprintf(w, "Root: %s (the default tree, $%s: this command always describes the whole machine and never narrows to the project you are in).\n", shown, envRoot) + fmt.Fprintf(w, "Root: %s (the default tree, %s: this command always describes the whole machine and never narrows to the project you are in).\n", shown, c.from) } } diff --git a/cmd/agentsync/root_test.go b/cmd/agentsync/root_test.go index 8452d3c..3aa316b 100644 --- a/cmd/agentsync/root_test.go +++ b/cmd/agentsync/root_test.go @@ -392,6 +392,16 @@ func TestEmitRootChoice(t *testing.T) { choice: rootChoice{Path: "/home/u/Web", Origin: rootFromAll}, want: []string{"~/Web", "--all"}, }, + { + name: "all names the config file it came from", + choice: rootChoice{Path: "/home/u/Web", Origin: rootFromAll, from: "config file /c.toml"}, + want: []string{"~/Web", "--all", "config file /c.toml"}, + }, + { + name: "wide default names the config file it came from", + choice: rootChoice{Path: "/home/u/Web", Origin: rootFromWide, from: "config file /c.toml"}, + want: []string{"~/Web", "config file /c.toml", "never narrows"}, + }, { name: "wide default says it never narrows", choice: rootChoice{Path: "/home/u/Web", Origin: rootFromWide}, diff --git a/coverage-floors.txt b/coverage-floors.txt index 29d531f..d68d51a 100644 --- a/coverage-floors.txt +++ b/coverage-floors.txt @@ -1,6 +1,7 @@ # Minimum per-package statement coverage, checked by `just check`. # Raise a floor after a coverage pass; never lower one. -cmd/agentsync 89.7 +cmd/agentsync 89.9 +internal/config 100.0 internal/doctor 88.1 internal/importer 88.8 internal/inventory 91.2 diff --git a/docs/configuration.md b/docs/configuration.md index b3c3233..1938ff5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,10 +3,29 @@ agentsync does not guess where your trees live. A run that needs a setting that is unset refuses with exit 2. -| Setting | Flag | Environment | Used for | -|---|---|---|---| -| Canonical agents repo | `--repo PATH` | `$AGENTSYNC_REPO` | every command | -| Default tree of projects | `--root PATH` | `$AGENTSYNC_ROOT` | `--all` and `inventory` | +## The config file + +The usual place to set them is `config.toml`: + +```toml +repo = "~/src/my-agents" +root = "~/src" +``` + +It lives at `$XDG_CONFIG_HOME/agentsync/config.toml`, or at +`~/.config/agentsync/config.toml` when `$XDG_CONFIG_HOME` is unset or not an +absolute path. That holds on macOS too. + +`repo` and `root` are the only keys. Each must be an absolute path or start +with `~/`, which expands against `$HOME`; `~` alone means `$HOME`. A missing file +is not an error, but a symlink there whose target is missing is a broken file. + +## Flags and environment + +| Setting | Flag | Environment | Config key | Used for | +|---|---|---|---|---| +| Canonical agents repo | `--repo PATH` | `$AGENTSYNC_REPO` | `repo` | every command | +| Default tree of projects | `--root PATH` | `$AGENTSYNC_ROOT` | `root` | `--all` and `inventory` | ```bash export AGENTSYNC_REPO="$HOME/src/my-agents" @@ -15,6 +34,43 @@ export AGENTSYNC_ROOT="$HOME/src" `$AGENTSYNC_ROOT` must be an absolute path. +## Precedence + +Each setting is resolved on its own, so the repo can come from the environment +and the root from the file. An empty or blank flag or variable counts as unset. + +| Order | Source | +|---|---| +| 1 | flag | +| 2 | environment variable | +| 3 | config file | +| 4 | refuse with exit 2 | + +The file is read only when a setting is still unset after the flag and the +environment. A broken file therefore never blocks a run that passes both, which +is how you work around it while fixing it. + +Every run that reports prints a `Repo:` line and a `Root:` line naming where +each value came from, including the config file's path. + +## A broken config file refuses + +When the file has to be read, each of these refuses with exit 2 and names the +file: + +- it exists but cannot be read, is a directory, or is a dangling symlink; +- it does not parse as TOML (the message gives the line), or a value has the + wrong type, such as `repo = 5`; +- it holds a key other than `repo` or `root` (the message names the key); +- a key is present but empty; +- a value is not an absolute path after `~` expansion. + +If neither `$XDG_CONFIG_HOME` nor `$HOME` is a usable absolute path, the file +cannot be located, and a run that needs it refuses with exit 2 saying it cannot +locate the config file. + +## The canonical repo + The canonical repo holds `agents/.md` (the agents you link) and `project-agents//.md` (captures of agents that exist in one project only). A project key is the project's path relative to `$HOME`, for @@ -39,8 +95,9 @@ resolved, or an ancestor that cannot be read, also refuses with exit 2 rather than widening to the whole tree. - `--root PATH` names a tree explicitly and switches the derivation off. -- `--all` scans `$AGENTSYNC_ROOT`, the machine-wide view. It is refused - together with `--root`, not resolved by precedence. +- `--all` scans the default tree (`$AGENTSYNC_ROOT` or `root` in the config + file), the machine-wide view. It is refused together with `--root`, not + resolved by precedence. A derived root also selects that project for `link`, as `--project ` would. That is what makes `agentsync link --agent NAME --apply` a complete @@ -48,15 +105,15 @@ command inside a project. It is a selection, not an exemption: `--agent` still requires a project and is refused when the root is the default tree and none was named. -Every run that reports prints the root it chose and how it decided. With -`--json` (and `inventory --stdout`) that line goes to stderr so stdout stays -machine-readable. A run refused before it reports prints nothing to stdout. +Every run that reports prints the repo and root it chose and how it decided. +With `--json` (and `inventory --stdout`) those lines go to stderr so stdout +stays machine-readable. A run refused before it reports prints nothing to stdout. ## inventory is the exception `inventory` never derives a root from the working directory. It describes every agent on the machine, so a project-scoped run would overwrite the machine-wide -document under the same name. It uses `--root` or `$AGENTSYNC_ROOT`; `--all` is +document under the same name. It uses `--root`, else the default tree; `--all` is accepted and means what the default already means. See [inventory](inventory.md). ## The home guard @@ -80,6 +137,6 @@ If your projects genuinely live outside `$HOME`, pass `--allow-outside-home`. |---|---| | `--repo PATH` | canonical agents repo | | `--root PATH` | tree to scan | -| `--all` | scan `$AGENTSYNC_ROOT` | +| `--all` | scan the default tree | | `--json` | machine-readable output | | `--allow-outside-home` | see above | diff --git a/docs/development.md b/docs/development.md index 9afc079..6cb0bbd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,6 +19,12 @@ just uninstall `INSTALL_DIR=/usr/local/bin just install`. Other recipes: `build`, `test`, `lint`, `coverage`, `run`, `clean`. `just run scan` runs from source. +## Dependencies + +The only third-party module is `github.com/BurntSushi/toml`, which parses the +config file in `internal/config`. Its strict-decode check is what rejects +unknown keys. Keep new dependencies rare and pinned in `go.mod`. + ## The gate `just check` runs, in order: `go vet` and `gofmt` verification (`lint` fails on diff --git a/docs/scan.md b/docs/scan.md index e8b4c2c..ac70d4c 100644 --- a/docs/scan.md +++ b/docs/scan.md @@ -5,7 +5,7 @@ repo. It is read-only. ```bash agentsync scan # the project you are in -agentsync scan --all # every project under $AGENTSYNC_ROOT +agentsync scan --all # every project under the default tree agentsync scan --at-risk # only files protected nowhere else agentsync scan --all --fail-on-risk # exit 1 if any at-risk file exists (CI) ``` diff --git a/go.mod b/go.mod index 3dbb0ab..dc65942 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/agentic-tooling/agentsync go 1.25 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a19e974 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,106 @@ +// Package config reads agentsync's TOML config file, which may name the +// canonical repo and the default tree of projects. +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +// Config holds the settings the file may carry. An empty field means the key +// was absent: Load refuses a key that is present but empty. +type Config struct { + Repo string `toml:"repo"` + Root string `toml:"root"` +} + +// Path is where the config file lives: under $XDG_CONFIG_HOME when that is +// absolute, else under ~/.config, on macOS too. +func Path() (string, error) { + // The XDG spec says a relative value is invalid and must be ignored. + if x := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(x) { + return filepath.Join(x, "agentsync", "config.toml"), nil + } + home, err := home() + if err != nil { + return "", fmt.Errorf("cannot locate the config file: %w", err) + } + return filepath.Join(home, ".config", "agentsync", "config.toml"), nil +} + +// Load reads the file at path. A missing file is an empty Config, not an +// error; every other problem with it is an error naming the file. +func Load(path string) (Config, error) { + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + // A link whose target is gone is a broken config, not an absent one. + if _, lerr := os.Lstat(path); lerr == nil { + return Config{}, fmt.Errorf("config file %s is a dangling symlink; fix or remove the link", path) + } + return Config{}, nil + } + if err != nil { + return Config{}, fmt.Errorf("config file %s: %w", path, err) + } + + var c Config + md, err := toml.Decode(string(data), &c) + if err != nil { + return Config{}, fmt.Errorf("config file %s: %w", path, err) + } + if undecoded := md.Undecoded(); len(undecoded) > 0 { + return Config{}, fmt.Errorf("config file %s: unknown key %q (known keys: repo, root)", path, undecoded[0].String()) + } + + for _, f := range []struct { + key string + val *string + }{{"repo", &c.Repo}, {"root", &c.Root}} { + if !md.IsDefined(f.key) { + continue + } + v, err := expand(strings.TrimSpace(*f.val)) + if err != nil { + return Config{}, fmt.Errorf("config file %s: %s: %w", path, f.key, err) + } + // Unlike a flag or env var, a file has no working directory a relative + // path could sensibly mean. + if !filepath.IsAbs(v) { + return Config{}, fmt.Errorf("config file %s: %s must be an absolute path or start with ~/, got %q", path, f.key, *f.val) + } + *f.val = v + } + return c, nil +} + +func expand(v string) (string, error) { + if v == "" { + return "", errors.New("is empty; remove the key or give it a path") + } + if v == "~" { + v = "~/" + } + rest, ok := strings.CutPrefix(v, "~/") + if !ok { + return v, nil + } + h, err := home() + if err != nil { + return "", fmt.Errorf("cannot expand ~: %w", err) + } + return filepath.Join(h, rest), nil +} + +func home() (string, error) { + h := os.Getenv("HOME") + if !filepath.IsAbs(h) { + return "", fmt.Errorf("$HOME must be an absolute path, got %q", h) + } + return h, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b224a25 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,149 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPath(t *testing.T) { + home := t.TempDir() + xdg := t.TempDir() + tests := []struct { + name, xdg, home, want string + }{ + {"XDG_CONFIG_HOME set", xdg, home, filepath.Join(xdg, "agentsync", "config.toml")}, + {"XDG_CONFIG_HOME unset", "", home, filepath.Join(home, ".config", "agentsync", "config.toml")}, + {"relative XDG_CONFIG_HOME is ignored", "rel/xdg", home, filepath.Join(home, ".config", "agentsync", "config.toml")}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", tc.xdg) + t.Setenv("HOME", tc.home) + got, err := Path() + if err != nil || got != tc.want { + t.Errorf("Path() = %q, %v; want %q", got, err, tc.want) + } + }) + } +} + +func TestPath_NoUsableHomeRefuses(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + for _, h := range []string{"", "relative"} { + t.Setenv("HOME", h) + if got, err := Path(); err == nil { + t.Errorf("HOME=%q: Path() = %q, want an error", h, got) + } + } +} + +func writeConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoad_Valid(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + tests := []struct { + name, content string + want Config + }{ + {"tilde expands against HOME", "repo = \"~/agents\"\nroot = \"~/Web\"\n", + Config{Repo: filepath.Join(home, "agents"), Root: filepath.Join(home, "Web")}}, + {"bare tilde and tilde slash both mean HOME", "repo = \"~\"\nroot = \"~/\"\n", + Config{Repo: home, Root: home}}, + {"absolute paths pass through", "repo = \"/opt/agents\"\nroot = \"/srv\"\n", + Config{Repo: "/opt/agents", Root: "/srv"}}, + {"one key leaves the other unset", "repo = \"/opt/agents\"\n", Config{Repo: "/opt/agents"}}, + {"an empty file sets nothing", "", Config{}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Load(writeConfig(t, tc.content)) + if err != nil || got != tc.want { + t.Errorf("Load = %+v, %v; want %+v", got, err, tc.want) + } + }) + } +} + +func TestLoad_MissingFileIsNotAnError(t *testing.T) { + got, err := Load(filepath.Join(t.TempDir(), "absent.toml")) + if err != nil || got != (Config{}) { + t.Errorf("Load = %+v, %v; want an empty Config and no error", got, err) + } +} + +func TestLoad_RefusesBrokenFiles(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + tests := []struct { + name, content string + want []string + }{ + {"parse error names the line", "repo = \"/a\"\nroot = \n", []string{"line 2"}}, + {"unknown key is named", "rpeo = \"/a\"\n", []string{`"rpeo"`, "unknown key"}}, + {"unknown table is named", "[extra]\nx = 1\n", []string{`"extra"`}}, + {"wrong type", "repo = 5\n", []string{`"repo"`, "incompatible types"}}, + {"empty value", "repo = \"\"\n", []string{"repo: is empty"}}, + {"blank value", "root = \" \"\n", []string{"root: is empty"}}, + {"relative root", "root = \"Web\"\n", []string{"root must be an absolute path"}}, + {"relative repo", "repo = \"./agents\"\n", []string{"repo must be an absolute path"}}, + {"tilde user is not expanded", "root = \"~bob/Web\"\n", []string{"root must be an absolute path"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := writeConfig(t, tc.content) + got, err := Load(path) + if err == nil { + t.Fatalf("Load = %+v, want an error", got) + } + assertNames(t, err, path, tc.want...) + }) + } +} + +// assertNames checks err names path and each of want, matching want against +// the message with path removed: t.TempDir puts the subtest name in path. +func assertNames(t *testing.T, err error, path string, want ...string) { + t.Helper() + if err == nil || !strings.Contains(err.Error(), path) { + t.Fatalf("error = %v, want one naming %s", err, path) + } + msg := strings.ReplaceAll(err.Error(), path, "") + for _, w := range want { + if !strings.Contains(msg, w) { + t.Errorf("error does not say %q:\n%s", w, msg) + } + } +} + +func TestLoad_DirectoryRefuses(t *testing.T) { + dir := t.TempDir() + _, err := Load(dir) + assertNames(t, err, dir, "is a directory") +} + +func TestLoad_DanglingSymlinkRefuses(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.Symlink(filepath.Join(dir, "gone.toml"), path); err != nil { + t.Fatal(err) + } + _, err := Load(path) + assertNames(t, err, path, "dangling symlink") +} + +func TestLoad_TildeWithoutHomeRefuses(t *testing.T) { + path := writeConfig(t, "repo = \"~/agents\"\n") + t.Setenv("HOME", "") + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "$HOME") { + t.Errorf("err = %v, want a refusal naming $HOME", err) + } +}