package ctxasm import ( "os" "os/exec" "path/filepath" "strings" "testing" "nub/internal/llm" ) func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestAssemble_WalkUp_OrdersRepoRootBeforeCwd(t *testing.T) { repoRoot := t.TempDir() sub := filepath.Join(repoRoot, "sub", "deeper") if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } writeFile(t, filepath.Join(repoRoot, "AGENTS.md"), "root instructions") writeFile(t, filepath.Join(sub, "AGENTS.md"), "deeper instructions") blocks, meta, err := Assemble(Options{ RepoRoot: repoRoot, Cwd: sub, Files: []string{"AGENTS.md"}, WalkUp: true, MaxTokens: 20000, }) if err != nil { t.Fatal(err) } if len(meta.Files) != 2 { t.Fatalf("loaded %d files, want 2", len(meta.Files)) } if meta.Files[0].Path != "AGENTS.md" { t.Errorf("first file = %q, want repo-root AGENTS.md first", meta.Files[0].Path) } if meta.Files[1].Path != filepath.Join("sub", "deeper", "AGENTS.md") { t.Errorf("second file = %q, want the deeper one last", meta.Files[1].Path) } joined := joinBlockTexts(blocks) rootIdx := strings.Index(joined, "root instructions") deeperIdx := strings.Index(joined, "deeper instructions") if rootIdx == -1 || deeperIdx == -1 || rootIdx > deeperIdx { t.Errorf("expected root instructions before deeper instructions in assembled text") } } func TestAssemble_LastBlockHasCacheMark(t *testing.T) { repoRoot := t.TempDir() blocks, _, err := Assemble(Options{RepoRoot: repoRoot, Cwd: repoRoot, MaxTokens: 20000}) if err != nil { t.Fatal(err) } for i, b := range blocks { want := i == len(blocks)-1 if b.CacheMark != want { t.Errorf("block %d CacheMark = %v, want %v", i, b.CacheMark, want) } } } func TestAssemble_BudgetExceeded_TruncatesAndWarns(t *testing.T) { repoRoot := t.TempDir() big := strings.Repeat("word ", 2000) writeFile(t, filepath.Join(repoRoot, "AGENTS.md"), big) _, meta, err := Assemble(Options{ RepoRoot: repoRoot, Cwd: repoRoot, Files: []string{"AGENTS.md"}, WalkUp: false, MaxTokens: 100, }) if err != nil { t.Fatal(err) } if len(meta.Warnings) == 0 { t.Fatal("expected a budget warning") } if len(meta.Files) != 1 || !meta.Files[0].Truncated { t.Errorf("expected AGENTS.md to be marked truncated, got: %+v", meta.Files) } } func TestAssemble_MissingFilesAreSkippedSilently(t *testing.T) { repoRoot := t.TempDir() _, meta, err := Assemble(Options{ RepoRoot: repoRoot, Cwd: repoRoot, Files: []string{"DOES_NOT_EXIST.md"}, WalkUp: false, MaxTokens: 20000, }) if err != nil { t.Fatal(err) } if len(meta.Files) != 0 { t.Errorf("expected no files loaded, got %+v", meta.Files) } } func TestAssemble_WarnsOnStaleRepoMap(t *testing.T) { repoRoot := t.TempDir() runGit(t, repoRoot, "init") writeFile(t, filepath.Join(repoRoot, "f.txt"), "x") runGit(t, repoRoot, "add", "-A") runGit(t, repoRoot, "-c", "user.email=t@t.com", "-c", "user.name=t", "commit", "-m", "init") content, err := GenerateRepoMap(repoRoot) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(repoRoot, "REPOMAP.md"), content) // Neuer Commit nach Repomap-Generierung -> Repomap ist jetzt stale. writeFile(t, filepath.Join(repoRoot, "f.txt"), "y") runGit(t, repoRoot, "add", "-A") runGit(t, repoRoot, "-c", "user.email=t@t.com", "-c", "user.name=t", "commit", "-m", "change") _, meta, err := Assemble(Options{ RepoRoot: repoRoot, Cwd: repoRoot, Files: []string{"REPOMAP.md"}, WalkUp: false, MaxTokens: 20000, }) if err != nil { t.Fatal(err) } found := false for _, w := range meta.Warnings { if strings.Contains(w, "REPOMAP.md") { found = true } } if !found { t.Errorf("expected a staleness warning, got: %v", meta.Warnings) } } func runGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } func joinBlockTexts(blocks []llm.Block) string { var b strings.Builder for _, blk := range blocks { b.WriteString(blk.Text) b.WriteString("\n") } return b.String() }