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)
}
}
}