Vectrum
Sign In
170 lines · 5.3 KB · Go
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)
		}
	}
}