package ccsettings import ( "os" "path/filepath" "reflect" "strings" "testing" ) func shownOf(files []Instruction) []string { var out []string for _, f := range files { out = append(out, f.Shown) } return out } func TestLoadInstructionsOrder(t *testing.T) { home, _ := dirs(t) repo := filepath.Join(home, "src", "repo") root := filepath.Join(repo, "services", "api") os.MkdirAll(filepath.Join(repo, ".git"), 0o755) os.MkdirAll(root, 0o755) write(t, home, UserInstructions, "user rules") // Above the git root: not part of this project. write(t, home, "src/CLAUDE.md", "outside the repository") write(t, repo, "CLAUDE.md", "repo rules") write(t, repo, ".claude/CLAUDE.md", "repo dot-claude rules") write(t, filepath.Join(repo, "services"), "CLAUDE.md", "services rules") write(t, root, "CLAUDE.md", "api rules") write(t, root, "CLAUDE.local.md", "my api notes") write(t, root, "AGENTS.md", "not Claude's file") files, errs := LoadInstructions(home, root) if len(errs) != 0 { t.Fatalf("errors: %v", errs) } got := shownOf(files) want := []string{ "~/.claude/CLAUDE.md", "~/src/repo/CLAUDE.md", "~/src/repo/.claude/CLAUDE.md", "~/src/repo/services/CLAUDE.md", "CLAUDE.md", "CLAUDE.local.md", } if !reflect.DeepEqual(got, want) { t.Errorf("got %q\nwant %q", got, want) } if files[0].Role != roleUser || files[5].Role != roleLocal || files[4].Role != roleProject { t.Errorf("roles: %q %q %q", files[0].Role, files[4].Role, files[5].Role) } } // With no repository on the way up, the walk stops at the home directory // rather than reading whatever lies above it. func TestLoadInstructionsNoRepository(t *testing.T) { home, root := dirs(t) write(t, home, "CLAUDE.md", "home-level") write(t, filepath.Dir(home), "CLAUDE.md", "above home") write(t, root, "CLAUDE.md", "here") files, _ := LoadInstructions(home, root) got := shownOf(files) want := []string{"~/CLAUDE.md", "CLAUDE.md"} if !reflect.DeepEqual(got, want) { t.Errorf("got %q, want %q", got, want) } // Outside home altogether, only the root itself. elsewhere := filepath.Join(filepath.Dir(home), "tmp", "x") os.MkdirAll(elsewhere, 0o755) write(t, elsewhere, "CLAUDE.md", "x") files, _ = LoadInstructions(home, elsewhere) if len(files) != 1 || files[0].Body != "x" { t.Errorf("got %+v", files) } } func TestInstructionImports(t *testing.T) { home, root := dirs(t) os.MkdirAll(filepath.Join(root, ".git"), 0o755) write(t, root, "CLAUDE.md", strings.Join([]string{ "See @AGENTS.md for the rules, and @docs/style.md.", "Mail me at someone@example.com, which is not an import.", "`@inline` is code, and so is this:", "```", "@decorator", "```", "@~/.ssh/id_rsa is outside the project.", "@../escape.md is too.", "@missing.md is not there.", }, "\n")) write(t, root, "AGENTS.md", "agents body, see @CLAUDE.md") write(t, root, "docs/style.md", "style body @deeper/one.md") write(t, root, "docs/deeper/one.md", "one @two.md") write(t, root, "docs/deeper/two.md", "two @three.md") write(t, root, "docs/deeper/three.md", "three @four.md") write(t, root, "docs/deeper/four.md", "four @five.md") write(t, root, "docs/deeper/five.md", "five — too deep") write(t, root, "inline", "should not load") write(t, root, "decorator", "should not load") write(t, home, ".ssh/id_rsa", "PRIVATE KEY") write(t, home, "src/escape.md", "escaped") files, errs := LoadInstructions(home, root) got := shownOf(files) want := []string{ "CLAUDE.md", "AGENTS.md", "docs/style.md", "docs/deeper/one.md", "docs/deeper/two.md", "docs/deeper/three.md", "docs/deeper/four.md", } if !reflect.DeepEqual(got, want) { t.Errorf("got %q\nwant %q", got, want) } for _, f := range files { if strings.Contains(f.Body, "PRIVATE KEY") || f.Body == "escaped" { t.Errorf("a project file imported %s from outside the project", f.Path) } } if files[1].Role != "imported by CLAUDE.md" { t.Errorf("role %q", files[1].Role) } if len(errs) != 2 { t.Errorf("want the two escapes reported, got %v", errs) } } // The user's own file may import from the home directory: it is theirs. func TestUserInstructionsImportFromHome(t *testing.T) { home, root := dirs(t) write(t, home, UserInstructions, "@~/notes/prefs.md") write(t, home, "notes/prefs.md", "tabs, not spaces") files, _ := LoadInstructions(home, root) if len(files) != 2 || files[1].Body != "tabs, not spaces" { t.Errorf("got %+v", files) } } // A symlink committed to a repository can point anywhere, so the project's // own files are checked where they lead. func TestProjectInstructionSymlinkOut(t *testing.T) { home, root := dirs(t) os.MkdirAll(filepath.Join(root, ".git"), 0o755) secret := write(t, home, ".aws/credentials", "KEY") if err := os.Symlink(secret, filepath.Join(root, "CLAUDE.md")); err != nil { t.Skip(err) } files, _ := LoadInstructions(home, root) if len(files) != 0 { t.Errorf("followed a symlink out of the project: %+v", files) } } func TestFormatInstructions(t *testing.T) { if FormatInstructions(nil) != "" { t.Error("no files should add nothing to the prompt") } got := FormatInstructions([]Instruction{{Shown: "CLAUDE.md", Role: roleProject, Body: "Run make test."}}) for _, want := range []string{"# Instructions from CLAUDE.md", "## CLAUDE.md (this project's instructions, checked in)", "Run make test."} { if !strings.Contains(got, want) { t.Errorf("missing %q in:\n%s", want, got) } } }