package mirror import ( "os" "path/filepath" "strings" "testing" ) // state is what one directory looks like after a walk: what kind of thing each // name is, and what the real file holds. It is the assertion for every case // below, because every case is a statement about exactly this. type state struct { agentsIsFile bool claudeIsLink bool linkTarget string body string } func read(t *testing.T, dir string) state { t.Helper() var s state if fi, err := os.Lstat(filepath.Join(dir, Agents)); err == nil { s.agentsIsFile = fi.Mode().IsRegular() } if fi, err := os.Lstat(filepath.Join(dir, Claude)); err == nil { if fi.Mode()&os.ModeSymlink != 0 { s.claudeIsLink = true s.linkTarget, _ = os.Readlink(filepath.Join(dir, Claude)) } } if b, err := os.ReadFile(filepath.Join(dir, Agents)); err == nil { s.body = string(b) } return s } // canonical is the one arrangement this package exists to produce. func canonical(body string) state { return state{agentsIsFile: true, claudeIsLink: true, linkTarget: Agents, body: body} } func writeFile(t *testing.T, path, body string) { t.Helper() if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } func symlink(t *testing.T, target, path string) { t.Helper() if err := os.Symlink(target, path); err != nil { t.Fatal(err) } } // The seven branches, each named by the state it starts in. func TestEveryBranchIsLosslessOrARefusal(t *testing.T) { cases := []struct { name string // setup builds one directory in the state under test. setup func(t *testing.T, dir string) // want is the state afterwards; a conflict case wants no change at all. want state // fixes and conflicts are how many of each the walk reports. fixes, conflicts int // says is a fragment the one report has to contain, so a message that // stops naming what happened fails here rather than in somebody's // terminal a year from now. says string }{ { name: "AGENTS.md alone gets a link beside it", setup: func(t *testing.T, dir string) { writeFile(t, filepath.Join(dir, Agents), "doc\n") }, want: canonical("doc\n"), fixes: 1, says: "created symlink", }, { name: "CLAUDE.md alone is renamed and linked back", setup: func(t *testing.T, dir string) { writeFile(t, filepath.Join(dir, Claude), "doc\n") }, want: canonical("doc\n"), fixes: 1, says: "renamed to " + Agents, }, { name: "already canonical is left completely alone", setup: func(t *testing.T, dir string) { writeFile(t, filepath.Join(dir, Agents), "doc\n") symlink(t, Agents, filepath.Join(dir, Claude)) }, want: canonical("doc\n"), }, { name: "a link pointing elsewhere is re-pointed", setup: func(t *testing.T, dir string) { writeFile(t, filepath.Join(dir, Agents), "doc\n") writeFile(t, filepath.Join(dir, "OTHER.md"), "other\n") symlink(t, "OTHER.md", filepath.Join(dir, Claude)) }, want: canonical("doc\n"), fixes: 1, says: "re-pointed symlink", }, { name: "the reversed layout is swapped round", setup: func(t *testing.T, dir string) { writeFile(t, filepath.Join(dir, Claude), "doc\n") symlink(t, Claude, filepath.Join(dir, Agents)) }, want: canonical("doc\n"), fixes: 1, says: "swapped", }, { name: "two real files with one content lose the copy", setup: func(t *testing.T, dir string) { writeFile(t, filepath.Join(dir, Agents), "doc\n") writeFile(t, filepath.Join(dir, Claude), "doc\n") }, want: canonical("doc\n"), fixes: 1, says: "replaced with symlink", }, { // The one case that must never be resolved: one of the two is // somebody's writing, and no rule here knows which. name: "two real files with different content are refused", setup: func(t *testing.T, dir string) { writeFile(t, filepath.Join(dir, Agents), "mine\n") writeFile(t, filepath.Join(dir, Claude), "theirs\n") }, want: state{agentsIsFile: true, body: "mine\n"}, conflicts: 1, says: "merge manually", }, { name: "a broken link with nothing beside it is reported, not replaced", setup: func(t *testing.T, dir string) { symlink(t, "GONE.md", filepath.Join(dir, Agents)) }, want: state{}, conflicts: 1, says: "broken symlink", }, { name: "a CLAUDE.md link to nothing is reported", setup: func(t *testing.T, dir string) { symlink(t, "GONE.md", filepath.Join(dir, Claude)) }, want: state{claudeIsLink: true, linkTarget: "GONE.md"}, conflicts: 1, says: "missing target", }, { name: "a directory holding neither is not touched", setup: func(t *testing.T, dir string) {}, want: state{}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() tc.setup(t, dir) res := Sync(dir) if len(res.Fixes) != tc.fixes { t.Errorf("fixes = %d (%v), want %d", len(res.Fixes), res.Fixes, tc.fixes) } if len(res.Conflicts) != tc.conflicts { t.Errorf("conflicts = %d (%v), want %d", len(res.Conflicts), res.Conflicts, tc.conflicts) } if tc.says != "" { all := strings.Join(append(res.Fixes, res.Conflicts...), "\n") if !strings.Contains(all, tc.says) { t.Errorf("no report mentions %q; got:\n%s", tc.says, all) } } if got := read(t, dir); got != tc.want { t.Errorf("after Sync:\n got %+v\nwant %+v", got, tc.want) } // A second run must find nothing left to do. A repair that reported // itself fixed and did not converge would loop forever inside a // PreToolUse hook, once per Bash call. if again := Sync(dir); len(again.Fixes) != 0 { t.Errorf("not idempotent — a second run still fixes %v", again.Fixes) } }) } } // Check is the same walk with the writes turned off, and the promise it makes is // that the run after it does exactly what it said. func TestCheckReportsWithoutWriting(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, Agents), "doc\n") before := read(t, dir) res := Check(dir) if len(res.Fixes) != 1 { t.Fatalf("Check found %d fixes, want 1", len(res.Fixes)) } if after := read(t, dir); after != before { t.Errorf("Check wrote to the tree: %+v -> %+v", before, after) } sync := Sync(dir) if len(sync.Fixes) != len(res.Fixes) || sync.Fixes[0] != res.Fixes[0] { t.Errorf("Sync did not do what Check said:\n check %v\n sync %v", res.Fixes, sync.Fixes) } if !Check(dir).Clean() { t.Error("the tree is still not canonical after Sync") } } // Somebody else's tree is somebody else's business. A vendored dependency's // AGENTS.md rewritten here is a diff nobody asked for, and `.git` is not a place // to be creating symlinks. func TestSkippedDirectoriesAreNotTouched(t *testing.T) { dir := t.TempDir() for _, skip := range []string{"vendor", "node_modules", ".git", ".claude"} { sub := filepath.Join(dir, skip, "pkg") if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } writeFile(t, filepath.Join(sub, Agents), "theirs\n") } if res := Sync(dir); !res.Clean() { t.Errorf("walked into a skipped directory: %+v", res) } for _, skip := range []string{"vendor", "node_modules", ".git", ".claude"} { p := filepath.Join(dir, skip, "pkg", Claude) if _, err := os.Lstat(p); err == nil { t.Errorf("%s was created inside %s", Claude, skip) } } } // The walk is a walk: a document six directories down is as canonical as one at // the top, and the report says where it was. func TestTheWholeTreeIsWalked(t *testing.T) { dir := t.TempDir() deep := filepath.Join(dir, "cli", "internal", "issue") if err := os.MkdirAll(deep, 0o755); err != nil { t.Fatal(err) } writeFile(t, filepath.Join(dir, Agents), "root\n") writeFile(t, filepath.Join(deep, Agents), "issue\n") res := Sync(dir) if len(res.Fixes) != 2 { t.Fatalf("fixes = %v, want one per directory", res.Fixes) } joined := strings.Join(res.Fixes, "\n") if !strings.Contains(joined, filepath.Join("cli", "internal", "issue", Claude)) { t.Errorf("the report does not name the nested directory:\n%s", joined) } if got := read(t, deep); got != canonical("issue\n") { t.Errorf("nested directory not canonical: %+v", got) } }