diff --git a/cmd/restore.go b/cmd/restore.go index 3d6e9aa..9fb5ca3 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -245,7 +245,9 @@ func copyFileIntoZip(path string, w io.Writer) error { return err } _, err = io.Copy(w, src) - src.Close() + if closeErr := src.Close(); err == nil { + err = closeErr + } return err } diff --git a/internal/analyze/zip_test.go b/internal/analyze/zip_test.go index dac6aa0..2c70e7b 100644 --- a/internal/analyze/zip_test.go +++ b/internal/analyze/zip_test.go @@ -8,20 +8,22 @@ import ( "testing" ) -func TestIsGitRepo_WithDotGit(t *testing.T) { - dir := t.TempDir() - // Simulate .git via git init - if err := os.MkdirAll(filepath.Join(dir, ".git"), 0750); err != nil { - t.Fatal(err) - } - // isGitRepo uses `git rev-parse --git-dir` which needs an actual git repo; - // fall back to checking directory creation only — the factory version - // (os.Stat) is simpler, but here we just ensure non-git dir returns false. +func TestIsGitRepo_NonGitDir(t *testing.T) { + // isGitRepo uses `git rev-parse --git-dir`; an empty temp dir is not a git repo. if isGitRepo(t.TempDir()) { t.Error("empty temp dir should not be a git repo") } } +// ── isWorktreeClean ─────────────────────────────────────────────────────────── + +func TestIsWorktreeClean_NonGitDir(t *testing.T) { + // git status on a non-repo exits non-zero → returns false + if isWorktreeClean(t.TempDir()) { + t.Error("non-git dir should not be considered clean") + } +} + func TestWalkZip_IncludesFiles(t *testing.T) { src := t.TempDir() if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main"), 0600); err != nil { diff --git a/internal/api/client.go b/internal/api/client.go index cf2206c..6ebcc7d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -313,8 +313,8 @@ func (c *Client) request(ctx context.Context, method, path, contentType string, return &apiErr } snippet := string(respBody) - if len(snippet) > 300 { - snippet = snippet[:300] + "..." + if runes := []rune(snippet); len(runes) > 300 { + snippet = string(runes[:300]) + "..." } return fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet) } diff --git a/internal/api/types_test.go b/internal/api/types_test.go index 57fbda2..7411eba 100644 --- a/internal/api/types_test.go +++ b/internal/api/types_test.go @@ -232,6 +232,108 @@ func TestError_Error_WithoutCode(t *testing.T) { } } +// ── GraphFromShardIR ────────────────────────────────────────────────────────── + +func TestGraphFromShardIR_NodesAndRels(t *testing.T) { + ir := &ShardIR{ + Repo: "myorg/myrepo", + Graph: ShardGraph{ + Nodes: []Node{ + {ID: "n1", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "n2", Labels: []string{"Function"}, Properties: map[string]any{"name": "doThing"}}, + }, + Relationships: []Relationship{ + {ID: "r1", Type: "defines_function", StartNode: "n1", EndNode: "n2"}, + }, + }, + } + g := GraphFromShardIR(ir) + + if len(g.Nodes) != 2 { + t.Errorf("nodes: got %d, want 2", len(g.Nodes)) + } + if len(g.Relationships) != 1 { + t.Errorf("relationships: got %d, want 1", len(g.Relationships)) + } + if g.Nodes[0].ID != "n1" { + t.Errorf("first node ID: got %q", g.Nodes[0].ID) + } +} + +func TestGraphFromShardIR_RepoID(t *testing.T) { + ir := &ShardIR{Repo: "acme/backend"} + g := GraphFromShardIR(ir) + if got := g.RepoID(); got != "acme/backend" { + t.Errorf("RepoID: got %q, want 'acme/backend'", got) + } +} + +func TestGraphFromShardIR_RelsViaRels(t *testing.T) { + // Rels() should return the Relationships slice (not Edges) + ir := &ShardIR{ + Graph: ShardGraph{ + Relationships: []Relationship{ + {ID: "r1", Type: "imports"}, + {ID: "r2", Type: "calls"}, + }, + }, + } + g := GraphFromShardIR(ir) + rels := g.Rels() + if len(rels) != 2 { + t.Errorf("Rels(): got %d, want 2", len(rels)) + } +} + +func TestGraphFromShardIR_Empty(t *testing.T) { + ir := &ShardIR{} + g := GraphFromShardIR(ir) + if g == nil { + t.Fatal("GraphFromShardIR returned nil") + } + if len(g.Nodes) != 0 { + t.Errorf("empty IR: expected 0 nodes, got %d", len(g.Nodes)) + } + if g.RepoID() != "" { + t.Errorf("empty IR: expected empty repoId, got %q", g.RepoID()) + } +} + +func TestGraphFromShardIR_NodeByID(t *testing.T) { + ir := &ShardIR{ + Graph: ShardGraph{ + Nodes: []Node{ + {ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "myFunc"}}, + }, + }, + } + g := GraphFromShardIR(ir) + n, ok := g.NodeByID("fn1") + if !ok { + t.Fatal("NodeByID('fn1') returned false") + } + if n.Prop("name") != "myFunc" { + t.Errorf("name prop: got %q", n.Prop("name")) + } +} + +func TestGraphFromShardIR_NodesByLabel(t *testing.T) { + ir := &ShardIR{ + Graph: ShardGraph{ + Nodes: []Node{ + {ID: "f1", Labels: []string{"File"}}, + {ID: "fn1", Labels: []string{"Function"}}, + {ID: "f2", Labels: []string{"File"}}, + }, + }, + } + g := GraphFromShardIR(ir) + files := g.NodesByLabel("File") + if len(files) != 2 { + t.Errorf("NodesByLabel('File'): got %d, want 2", len(files)) + } +} + func containsStr(s, sub string) bool { return len(s) >= len(sub) && (s == sub || func() bool { diff --git a/internal/archdocs/graph2md/graph2md_test.go b/internal/archdocs/graph2md/graph2md_test.go index 9b2e23f..a716b3b 100644 --- a/internal/archdocs/graph2md/graph2md_test.go +++ b/internal/archdocs/graph2md/graph2md_test.go @@ -62,6 +62,312 @@ func buildGraphJSON(t *testing.T, nodes []Node, rels []Relationship) string { return f.Name() } +// ── getStr ──────────────────────────────────────────────────────────────────── + +func TestGetStr(t *testing.T) { + m := map[string]interface{}{"name": "foo", "num": 42, "empty": ""} + if got := getStr(m, "name"); got != "foo" { + t.Errorf("got %q, want %q", got, "foo") + } + if got := getStr(m, "num"); got != "" { + t.Errorf("non-string: got %q, want empty", got) + } + if got := getStr(m, "missing"); got != "" { + t.Errorf("missing key: got %q, want empty", got) + } + if got := getStr(m, "empty"); got != "" { + t.Errorf("empty string: got %q, want empty", got) + } +} + +// ── getNum ──────────────────────────────────────────────────────────────────── + +func TestGetNum(t *testing.T) { + m := map[string]interface{}{"f64": float64(7), "i": 9, "str": "x"} + if got := getNum(m, "f64"); got != 7 { + t.Errorf("float64: got %d, want 7", got) + } + if got := getNum(m, "i"); got != 9 { + t.Errorf("int: got %d, want 9", got) + } + if got := getNum(m, "str"); got != 0 { + t.Errorf("wrong type: got %d, want 0", got) + } + if got := getNum(m, "missing"); got != 0 { + t.Errorf("missing key: got %d, want 0", got) + } +} + +// ── mermaidID ───────────────────────────────────────────────────────────────── + +func TestMermaidID(t *testing.T) { + cases := []struct{ in, want string }{ + {"fn:src/foo.go:bar", "fn_src_foo_go_bar"}, + {"hello_world", "hello_world"}, + {"ABC123", "ABC123"}, + {"", "node"}, + {"---", "___"}, + } + for _, tc := range cases { + got := mermaidID(tc.in) + if got != tc.want { + t.Errorf("mermaidID(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// ── generateSlug ───────────────────────────────────────────────────────────── + +func TestGenerateSlug_File(t *testing.T) { + n := Node{Properties: map[string]interface{}{"path": "src/main.go"}} + got := generateSlug(n, "File") + if !strings.HasPrefix(got, "file-") { + t.Errorf("File slug: got %q, want prefix 'file-'", got) + } + // empty path → empty slug + n2 := Node{Properties: map[string]interface{}{}} + if got2 := generateSlug(n2, "File"); got2 != "" { + t.Errorf("empty path File slug: got %q, want empty", got2) + } +} + +func TestGenerateSlug_Function(t *testing.T) { + n := Node{Properties: map[string]interface{}{"name": "run", "filePath": "internal/api/handler.go"}} + got := generateSlug(n, "Function") + if !strings.HasPrefix(got, "fn-") { + t.Errorf("Function slug with path: got %q, want prefix 'fn-'", got) + } + n2 := Node{Properties: map[string]interface{}{"name": "run"}} + got2 := generateSlug(n2, "Function") + if !strings.HasPrefix(got2, "fn-") { + t.Errorf("Function slug without path: got %q, want prefix 'fn-'", got2) + } + n3 := Node{Properties: map[string]interface{}{}} + if got3 := generateSlug(n3, "Function"); got3 != "" { + t.Errorf("empty name: got %q, want empty", got3) + } +} + +func TestGenerateSlug_ClassTypeLabels(t *testing.T) { + for _, label := range []string{"Class", "Type"} { + prefix := strings.ToLower(label) + "-" + n := Node{Properties: map[string]interface{}{"name": "MyEntity", "filePath": "src/foo.go"}} + got := generateSlug(n, label) + if !strings.HasPrefix(got, prefix) { + t.Errorf("%s slug: got %q, want prefix %q", label, got, prefix) + } + n2 := Node{Properties: map[string]interface{}{"name": "MyEntity"}} + got2 := generateSlug(n2, label) + if !strings.HasPrefix(got2, prefix) { + t.Errorf("%s slug without path: got %q, want prefix %q", label, got2, prefix) + } + n3 := Node{Properties: map[string]interface{}{}} + if got3 := generateSlug(n3, label); got3 != "" { + t.Errorf("%s empty name: got %q, want empty", label, got3) + } + } +} + +func TestGenerateSlug_DomainSubdomain(t *testing.T) { + dn := Node{Properties: map[string]interface{}{"name": "auth"}} + if got := generateSlug(dn, "Domain"); !strings.HasPrefix(got, "domain-") { + t.Errorf("Domain: got %q, want prefix 'domain-'", got) + } + sn := Node{Properties: map[string]interface{}{"name": "users"}} + if got := generateSlug(sn, "Subdomain"); !strings.HasPrefix(got, "subdomain-") { + t.Errorf("Subdomain: got %q, want prefix 'subdomain-'", got) + } + empty := Node{Properties: map[string]interface{}{}} + if got := generateSlug(empty, "Domain"); got != "" { + t.Errorf("Domain empty name: got %q, want empty", got) + } + if got := generateSlug(empty, "Subdomain"); got != "" { + t.Errorf("Subdomain empty name: got %q, want empty", got) + } +} + +func TestGenerateSlug_Directory(t *testing.T) { + n := Node{Properties: map[string]interface{}{"path": "internal/api"}} + if got := generateSlug(n, "Directory"); !strings.HasPrefix(got, "dir-") { + t.Errorf("Directory: got %q, want prefix 'dir-'", got) + } + // path containing /app/repo-root/ → empty + n2 := Node{Properties: map[string]interface{}{"path": "/app/repo-root/internal"}} + if got := generateSlug(n2, "Directory"); got != "" { + t.Errorf("repo-root path: got %q, want empty", got) + } + // empty path → empty + n3 := Node{Properties: map[string]interface{}{}} + if got := generateSlug(n3, "Directory"); got != "" { + t.Errorf("empty path: got %q, want empty", got) + } +} + +func TestGenerateSlug_Unknown(t *testing.T) { + n := Node{Properties: map[string]interface{}{"name": "foo"}} + if got := generateSlug(n, "Unknown"); got != "" { + t.Errorf("unknown label: got %q, want empty", got) + } +} + +// ── node-type rendering ─────────────────────────────────────────────────────── + +// TestRunClassNode verifies that a Class node generates a markdown file +// containing class-specific frontmatter fields. +func TestRunClassNode(t *testing.T) { + nodes := []Node{ + { + ID: "class:src/auth.go:UserAuth", + Labels: []string{"Class"}, + Properties: map[string]interface{}{ + "name": "UserAuth", + "filePath": "src/auth.go", + "startLine": float64(10), + "endLine": float64(50), + "language": "go", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "https://github.com/example/myrepo", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, err := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + if err != nil { + t.Fatal(err) + } + body := string(content) + for _, want := range []string{`node_type: "Class"`, `class_name: "UserAuth"`, `language: "go"`, `start_line: 10`, `end_line: 50`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in class output:\n%s", want, body) + } + } +} + +// TestRunTypeNode verifies that a Type node generates type-specific frontmatter. +func TestRunTypeNode(t *testing.T) { + nodes := []Node{ + { + ID: "type:src/types.go:UserID", + Labels: []string{"Type"}, + Properties: map[string]interface{}{ + "name": "UserID", + "filePath": "src/types.go", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, _ := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + body := string(content) + for _, want := range []string{`node_type: "Type"`, `type_name: "UserID"`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in type output:\n%s", want, body) + } + } +} + +// TestRunDomainNode verifies that a Domain node generates domain-specific frontmatter. +func TestRunDomainNode(t *testing.T) { + nodes := []Node{ + { + ID: "domain:auth", + Labels: []string{"Domain"}, + Properties: map[string]interface{}{ + "name": "auth", + "description": "Authentication domain", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, _ := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + body := string(content) + for _, want := range []string{`node_type: "Domain"`, `domain: "auth"`, `summary: "Authentication domain"`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in domain output:\n%s", want, body) + } + } +} + +// TestRunSubdomainNode verifies that a Subdomain node generates subdomain frontmatter. +func TestRunSubdomainNode(t *testing.T) { + nodes := []Node{ + { + ID: "subdomain:users", + Labels: []string{"Subdomain"}, + Properties: map[string]interface{}{ + "name": "users", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, _ := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + body := string(content) + for _, want := range []string{`node_type: "Subdomain"`, `subdomain: "users"`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in subdomain output:\n%s", want, body) + } + } +} + +// TestRunDirectoryNode verifies that a Directory node generates directory frontmatter. +func TestRunDirectoryNode(t *testing.T) { + nodes := []Node{ + { + ID: "dir:internal/api", + Labels: []string{"Directory"}, + Properties: map[string]interface{}{ + "name": "api", + "path": "internal/api", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, _ := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + body := string(content) + for _, want := range []string{`node_type: "Directory"`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in directory output:\n%s", want, body) + } + } +} + // TestSlugCollisionResolution verifies that when two nodes produce the same // base slug, the second gets a "-2" suffix, AND that a third node which // naturally produces that same "-2" slug does not silently collide with it. diff --git a/internal/archdocs/pssg/build/build_test.go b/internal/archdocs/pssg/build/build_test.go index a948f70..2b72cb2 100644 --- a/internal/archdocs/pssg/build/build_test.go +++ b/internal/archdocs/pssg/build/build_test.go @@ -2,13 +2,17 @@ package build import ( "encoding/json" + "html/template" "os" "path/filepath" + "strings" "testing" "unicode/utf8" "github.com/supermodeltools/cli/internal/archdocs/pssg/config" "github.com/supermodeltools/cli/internal/archdocs/pssg/entity" + "github.com/supermodeltools/cli/internal/archdocs/pssg/render" + "github.com/supermodeltools/cli/internal/archdocs/pssg/taxonomy" ) func newBuilder(outDir string) *Builder { @@ -125,6 +129,150 @@ func TestGenerateSearchIndex_DisabledSearch(t *testing.T) { } } +// ── shareImageURL ───────────────────────────────────────────────────────────── + +func TestShareImageURL(t *testing.T) { + got := shareImageURL("https://example.com", "recipe-soup.png") + want := "https://example.com/images/share/recipe-soup.png" + if got != want { + t.Errorf("shareImageURL: got %q, want %q", got, want) + } +} + +// ── countTaxEntries ─────────────────────────────────────────────────────────── + +func TestCountTaxEntries(t *testing.T) { + taxes := []taxonomy.Taxonomy{ + {Entries: []taxonomy.Entry{{}, {}}}, + {Entries: []taxonomy.Entry{{}}}, + } + if got := countTaxEntries(taxes); got != 3 { + t.Errorf("countTaxEntries: got %d, want 3", got) + } + if got := countTaxEntries(nil); got != 0 { + t.Errorf("countTaxEntries(nil): got %d, want 0", got) + } +} + +// ── countFieldDistribution ──────────────────────────────────────────────────── + +func TestCountFieldDistribution(t *testing.T) { + entities := []*entity.Entity{ + {Fields: map[string]interface{}{"cuisine": "Italian"}}, + {Fields: map[string]interface{}{"cuisine": "Italian"}}, + {Fields: map[string]interface{}{"cuisine": "French"}}, + {Fields: map[string]interface{}{"cuisine": ""}}, // empty, should be skipped + } + result := countFieldDistribution(entities, "cuisine", 10) + if len(result) != 2 { + t.Fatalf("want 2 entries, got %d", len(result)) + } + // Should be sorted desc by count + if result[0].Name != "Italian" || result[0].Count != 2 { + t.Errorf("first entry: got {%s %d}, want {Italian 2}", result[0].Name, result[0].Count) + } + if result[1].Name != "French" || result[1].Count != 1 { + t.Errorf("second entry: got {%s %d}, want {French 1}", result[1].Name, result[1].Count) + } +} + +func TestCountFieldDistribution_Limit(t *testing.T) { + entities := []*entity.Entity{ + {Fields: map[string]interface{}{"tag": "a"}}, + {Fields: map[string]interface{}{"tag": "a"}}, + {Fields: map[string]interface{}{"tag": "b"}}, + {Fields: map[string]interface{}{"tag": "b"}}, + {Fields: map[string]interface{}{"tag": "c"}}, + } + result := countFieldDistribution(entities, "tag", 2) + if len(result) != 2 { + t.Errorf("limit=2: want 2 entries, got %d", len(result)) + } +} + +func TestCountFieldDistribution_Empty(t *testing.T) { + if got := countFieldDistribution(nil, "field", 10); len(got) != 0 { + t.Errorf("nil entities: want empty, got %v", got) + } +} + +// ── toBreadcrumbItems ───────────────────────────────────────────────────────── + +func TestToBreadcrumbItems(t *testing.T) { + bcs := []render.Breadcrumb{ + {Name: "Home", URL: "https://example.com/"}, + {Name: "Recipes", URL: "https://example.com/recipes/"}, + } + items := toBreadcrumbItems(bcs) + if len(items) != 2 { + t.Fatalf("want 2 items, got %d", len(items)) + } + if items[0].Name != "Home" || items[0].URL != "https://example.com/" { + t.Errorf("first item: got %+v", items[0]) + } + if items[1].Name != "Recipes" { + t.Errorf("second item: got %+v", items[1]) + } +} + +// ── toTemplateHTML ──────────────────────────────────────────────────────────── + +func TestToTemplateHTML(t *testing.T) { + input := "hello & world" + got := toTemplateHTML(input) + if got != template.HTML(input) { + t.Errorf("toTemplateHTML: got %q, want %q", got, input) + } +} + +// ── writeShareSVG ───────────────────────────────────────────────────────────── + +func TestWriteShareSVG(t *testing.T) { + outDir := t.TempDir() + svg := `` + if err := writeShareSVG(outDir, "test.svg", svg); err != nil { + t.Fatalf("writeShareSVG: %v", err) + } + data, err := os.ReadFile(filepath.Join(outDir, "images", "share", "test.svg")) + if err != nil { + t.Fatalf("file not created: %v", err) + } + if !strings.Contains(string(data), ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // File should NOT be written when ShareImages=false. + if _, err := os.Stat(filepath.Join(outDir, "images", "share", "test.svg")); !os.IsNotExist(err) { + t.Error("share image should not be written when ShareImages=false") + } +} + +func TestMaybeWriteShareSVG_Enabled(t *testing.T) { + outDir := t.TempDir() + b := NewBuilder(&config.Config{ + Output: config.OutputConfig{ShareImages: true}, + Paths: config.PathsConfig{Output: outDir}, + }, false) + if err := b.maybeWriteShareSVG(outDir, "test.svg", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(filepath.Join(outDir, "images", "share", "test.svg")); err != nil { + t.Errorf("share image should be written when ShareImages=true: %v", err) + } +} + // readSearchIndex reads and unmarshals the search-index.json from outDir. func readSearchIndex(t *testing.T, outDir string) []map[string]string { t.Helper() diff --git a/internal/archdocs/pssg/output/output_test.go b/internal/archdocs/pssg/output/output_test.go new file mode 100644 index 0000000..64c372a --- /dev/null +++ b/internal/archdocs/pssg/output/output_test.go @@ -0,0 +1,288 @@ +package output + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/supermodeltools/cli/internal/archdocs/pssg/config" + "github.com/supermodeltools/cli/internal/archdocs/pssg/entity" +) + +// ── GenerateRobotsTxt ───────────────────────────────────────────────────────── + +func TestGenerateRobotsTxt_AllowAll(t *testing.T) { + cfg := &config.Config{ + Site: config.SiteConfig{BaseURL: "https://example.com"}, + Robots: config.RobotsConfig{AllowAll: true}, + } + got := GenerateRobotsTxt(cfg) + if !strings.Contains(got, "User-agent: *") { + t.Error("should contain wildcard user-agent") + } + if !strings.Contains(got, "Allow: /") { + t.Error("should contain Allow: /") + } + if !strings.Contains(got, "Sitemap: https://example.com/sitemap.xml") { + t.Errorf("should contain sitemap URL, got:\n%s", got) + } +} + +func TestGenerateRobotsTxt_StandardBots(t *testing.T) { + cfg := &config.Config{ + Site: config.SiteConfig{BaseURL: "https://example.com"}, + } + got := GenerateRobotsTxt(cfg) + if !strings.Contains(got, "User-agent: Googlebot") { + t.Error("should include Googlebot") + } + if !strings.Contains(got, "User-agent: Bingbot") { + t.Error("should include Bingbot") + } +} + +func TestGenerateRobotsTxt_ExtraBots(t *testing.T) { + cfg := &config.Config{ + Site: config.SiteConfig{BaseURL: "https://example.com"}, + Robots: config.RobotsConfig{ExtraBots: []string{"GPTBot", "ClaudeBot"}}, + } + got := GenerateRobotsTxt(cfg) + if !strings.Contains(got, "User-agent: GPTBot") { + t.Error("should include GPTBot") + } + if !strings.Contains(got, "User-agent: ClaudeBot") { + t.Error("should include ClaudeBot") + } +} + +// ── GenerateManifest ────────────────────────────────────────────────────────── + +func TestGenerateManifest_ValidJSON(t *testing.T) { + cfg := &config.Config{ + Site: config.SiteConfig{ + Name: "My Site", + Description: "A test site", + }, + } + got := GenerateManifest(cfg) + var m map[string]interface{} + if err := json.Unmarshal([]byte(got), &m); err != nil { + t.Fatalf("GenerateManifest: invalid JSON: %v\n%s", err, got) + } + if m["name"] != "My Site" { + t.Errorf("name: got %v", m["name"]) + } + if m["description"] != "A test site" { + t.Errorf("description: got %v", m["description"]) + } + if m["display"] != "standalone" { + t.Errorf("display: got %v", m["display"]) + } +} + +// ── NewSitemapEntry ─────────────────────────────────────────────────────────── + +func TestNewSitemapEntry_Basic(t *testing.T) { + e := NewSitemapEntry("https://example.com", "/recipes/soup", "2024-01-01", "0.8", "weekly") + if e.Loc != "https://example.com/recipes/soup" { + t.Errorf("Loc: got %q", e.Loc) + } + if e.Lastmod != "2024-01-01" { + t.Errorf("Lastmod: got %q", e.Lastmod) + } + if e.Priority != "0.8" { + t.Errorf("Priority: got %q", e.Priority) + } + if e.ChangeFreq != "weekly" { + t.Errorf("ChangeFreq: got %q", e.ChangeFreq) + } +} + +func TestNewSitemapEntry_RootPath(t *testing.T) { + // "/" should NOT be trimmed (it's the homepage) + e := NewSitemapEntry("https://example.com", "/", "", "1.0", "daily") + if e.Loc != "https://example.com/" { + t.Errorf("root path: Loc = %q, want 'https://example.com/'", e.Loc) + } +} + +func TestNewSitemapEntry_TrailingSlash(t *testing.T) { + // Non-root paths should have trailing slashes trimmed. + e := NewSitemapEntry("https://example.com", "/about/", "", "", "") + if strings.HasSuffix(e.Loc, "/") { + t.Errorf("non-root trailing slash should be trimmed: got %q", e.Loc) + } +} + +// ── chunkEntries ────────────────────────────────────────────────────────────── + +func TestChunkEntries_Basic(t *testing.T) { + entries := make([]SitemapEntry, 5) + for i := range entries { + entries[i].Loc = "url" + } + chunks := chunkEntries(entries, 2) + if len(chunks) != 3 { + t.Errorf("chunkEntries(5, 2): want 3 chunks, got %d", len(chunks)) + } + if len(chunks[0]) != 2 || len(chunks[1]) != 2 || len(chunks[2]) != 1 { + t.Errorf("chunk sizes: got %v", []int{len(chunks[0]), len(chunks[1]), len(chunks[2])}) + } +} + +func TestChunkEntries_ExactlyDivisible(t *testing.T) { + entries := make([]SitemapEntry, 4) + chunks := chunkEntries(entries, 2) + if len(chunks) != 2 { + t.Errorf("4÷2: want 2 chunks, got %d", len(chunks)) + } +} + +func TestChunkEntries_Empty(t *testing.T) { + chunks := chunkEntries(nil, 50) + if len(chunks) != 0 { + t.Errorf("empty: want 0 chunks, got %d", len(chunks)) + } +} + +// ── GenerateSitemapFiles ────────────────────────────────────────────────────── + +func TestGenerateSitemapFiles_SingleFile(t *testing.T) { + entries := []SitemapEntry{ + {Loc: "https://example.com/a", Priority: "0.8"}, + {Loc: "https://example.com/b", Priority: "0.6"}, + } + files := GenerateSitemapFiles(entries, "https://example.com", 0) + if len(files) != 1 { + t.Fatalf("want 1 file, got %d", len(files)) + } + if files[0].Filename != "sitemap.xml" { + t.Errorf("filename: got %q", files[0].Filename) + } + if !strings.Contains(files[0].Content, "https://example.com/a") { + t.Error("sitemap should contain first URL") + } +} + +func TestGenerateSitemapFiles_MultipleFiles(t *testing.T) { + entries := make([]SitemapEntry, 5) + for i := range entries { + entries[i].Loc = "https://example.com/page" + } + files := GenerateSitemapFiles(entries, "https://example.com", 2) + // 5 entries at 2 per file = 3 chunk files + 1 index = 4 total + if len(files) < 2 { + t.Fatalf("want multiple files, got %d", len(files)) + } + // First file should be the index + if files[0].Filename != "sitemap.xml" { + t.Errorf("first file should be index: got %q", files[0].Filename) + } + // Index should reference chunk files + if !strings.Contains(files[0].Content, "sitemap-1.xml") { + t.Error("index should reference sitemap-1.xml") + } +} + +func TestGenerateSitemapFiles_ValidXML(t *testing.T) { + entries := []SitemapEntry{ + {Loc: "https://example.com/page", Lastmod: "2024-01-01", Priority: "0.8", ChangeFreq: "weekly"}, + } + files := GenerateSitemapFiles(entries, "https://example.com", 0) + if len(files) != 1 { + t.Fatal("expected single file") + } + content := files[0].Content + if !strings.HasPrefix(content, " 0.01 { + t.Errorf("parseQuantity(%q).qty = %f, want %f", c.input, qty, c.qty) + } + if rest != c.rest { + t.Errorf("parseQuantity(%q).rest = %q, want %q", c.input, rest, c.rest) + } + } +} + +// ── parseUnit ───────────────────────────────────────────────────────────────── + +func TestParseUnit(t *testing.T) { + cases := []struct { + input, unit, rest string + }{ + {"cups flour", "cup", "flour"}, + {"tsp salt", "teaspoon", "salt"}, + {"tablespoon oil", "tablespoon", "oil"}, + {"g butter", "gram", "butter"}, + {"eggs", "", "eggs"}, // no unit + {"", "", ""}, + } + for _, c := range cases { + unit, rest := parseUnit(c.input) + if unit != c.unit { + t.Errorf("parseUnit(%q).unit = %q, want %q", c.input, unit, c.unit) + } + if rest != c.rest { + t.Errorf("parseUnit(%q).rest = %q, want %q", c.input, rest, c.rest) + } + } +} + +// ── parseIngredient* wrappers ───────────────────────────────────────────────── + +func TestParseIngredientFunctions(t *testing.T) { + line := "2 cups flour" + if got := parseIngredientQty(line); math.Abs(got-2) > 0.01 { + t.Errorf("parseIngredientQty(%q) = %f, want 2", line, got) + } + if got := parseIngredientUnit(line); got != "cup" { + t.Errorf("parseIngredientUnit(%q) = %q, want 'cup'", line, got) + } + if got := parseIngredientDesc(line); got != "flour" { + t.Errorf("parseIngredientDesc(%q) = %q, want 'flour'", line, got) + } +} + +// ── fractionDisplay ─────────────────────────────────────────────────────────── + +func TestFractionDisplay(t *testing.T) { + cases := []struct { + input float64 + want string + }{ + {0, "0"}, + {1, "1"}, + {2, "2"}, + {0.5, "½"}, // 0.5 is exactly ½ + {0.75, "¾"}, // 0.75 is exactly ¾ + {1.5, "1 ½"}, // whole + fraction + {0.125, "⅛"}, // exactly ⅛ + {0.875, "⅞"}, // exactly ⅞ + } + for _, c := range cases { + got := fractionDisplay(c.input) + if got != c.want { + t.Errorf("fractionDisplay(%v) = %q, want %q", c.input, got, c.want) + } + } +} + +// ── scaleQty ────────────────────────────────────────────────────────────────── + +func TestScaleQty(t *testing.T) { + // 1 cup base for 2 servings → scaled to 4 servings = 2 cups + got := scaleQty(1.0, 2, 4) + if got != "2" { + t.Errorf("scaleQty(1.0, 2, 4) = %q, want '2'", got) + } + // zero base servings → returns fractionDisplay of base qty + got = scaleQty(0.5, 0, 4) + if got != "½" { + t.Errorf("scaleQty(0.5, 0, 4) = %q, want '½'", got) + } +} diff --git a/internal/archdocs/pssg/render/shareimage_test.go b/internal/archdocs/pssg/render/shareimage_test.go index c984dd4..ac1d563 100644 --- a/internal/archdocs/pssg/render/shareimage_test.go +++ b/internal/archdocs/pssg/render/shareimage_test.go @@ -1,10 +1,139 @@ package render import ( + "strings" "testing" "unicode/utf8" ) +// ── svgEscape ───────────────────────────────────────────────────────────────── + +func TestSvgEscape(t *testing.T) { + cases := []struct{ in, want string }{ + {"hello", "hello"}, + {"a & b", "a & b"}, + {"", "<tag>"}, + {`say "hi"`, "say "hi""}, + {"a & ", "a & <b>"}, + } + for _, tc := range cases { + if got := svgEscape(tc.in); got != tc.want { + t.Errorf("svgEscape(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// ── renderBarsSVG ───────────────────────────────────────────────────────────── + +func TestRenderBarsSVG_Empty(t *testing.T) { + if got := renderBarsSVG(nil, 0, 0, 100, 20, 5); got != "" { + t.Errorf("empty bars: got %q, want empty", got) + } +} + +func TestRenderBarsSVG_SingleBar(t *testing.T) { + bars := []NameCount{{Name: "Italian", Count: 10}} + got := renderBarsSVG(bars, 60, 200, 400, 20, 5) + if !strings.Contains(got, "Italian") { + t.Errorf("should contain bar name: %s", got) + } + if !strings.Contains(got, "`) { + t.Errorf("should start with script tag, got: %q", got[:50]) + } + if !strings.Contains(got, `"@type":"WebSite"`) { + t.Errorf("should contain @type, got: %q", got) + } +} + +func TestMarshalSchemas_NilSkipped(t *testing.T) { + s := map[string]interface{}{"@type": "WebSite"} + got := MarshalSchemas(nil, s, nil) + if strings.Count(got, " 0; depth++ { next := make([]string, 0) for _, cur := range queue { - for _, rel := range rels { - if rel.Type != "imports" && rel.Type != "wildcard_imports" { - continue - } - if rel.StartNode != cur || visited[rel.EndNode] { + for _, endNode := range importEdges[cur] { + if visited[endNode] { continue } - visited[rel.EndNode] = true - next = append(next, rel.EndNode) - if n := nodeByID[rel.EndNode]; n != nil { + visited[endNode] = true + next = append(next, endNode) + if n := nodeByID[endNode]; n != nil { p := n.Prop("path", "name", "importPath") if p != "" { imports = append(imports, p) @@ -265,7 +271,7 @@ func estimateTokens(sl *Slice) int { for _, c := range sl.CalledBy { s += c.Caller + c.File } - return len(s) / 4 + return utf8.RuneCountInString(s) / 4 } func pathMatches(nodePath, target string) bool { diff --git a/internal/focus/handler_test.go b/internal/focus/handler_test.go index f0b0eaa..994df42 100644 --- a/internal/focus/handler_test.go +++ b/internal/focus/handler_test.go @@ -302,3 +302,80 @@ func TestRender_MarkdownTokenHint(t *testing.T) { t.Errorf("should show token hint:\n%s", buf.String()) } } + +// ── extractTypes ────────────────────────────────────────────────────────────── + +func TestExtractTypes(t *testing.T) { + g := &api.Graph{ + Nodes: []api.Node{ + {ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "auth/handler.go"}}, + {ID: "cls1", Labels: []string{"Class"}, Properties: map[string]any{"name": "AuthService", "file": "auth/handler.go"}}, + {ID: "iface1", Labels: []string{"Interface"}, Properties: map[string]any{"name": "Authenticator", "file": "auth/handler.go"}}, + }, + Relationships: []api.Relationship{ + {ID: "r1", Type: "declares_class", StartNode: "f1", EndNode: "cls1"}, + {ID: "r2", Type: "defines", StartNode: "f1", EndNode: "iface1"}, + }, + } + nodeByID := map[string]*api.Node{} + for i := range g.Nodes { + nodeByID[g.Nodes[i].ID] = &g.Nodes[i] + } + types := extractTypes(g, "f1", nodeByID, g.Rels()) + if len(types) != 2 { + t.Fatalf("want 2 types, got %d: %v", len(types), types) + } + // Class should have kind "class" + var foundClass bool + for _, typ := range types { + if typ.Name == "AuthService" && typ.Kind == "class" { + foundClass = true + } + } + if !foundClass { + t.Errorf("should have AuthService with kind='class', got %v", types) + } +} + +func TestExtractTypes_OtherFileExcluded(t *testing.T) { + // Relations from a different file should not appear + g := &api.Graph{ + Nodes: []api.Node{ + {ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "a.go"}}, + {ID: "f2", Labels: []string{"File"}, Properties: map[string]any{"path": "b.go"}}, + {ID: "cls1", Labels: []string{"Class"}, Properties: map[string]any{"name": "Foo"}}, + }, + Relationships: []api.Relationship{ + {ID: "r1", Type: "declares_class", StartNode: "f2", EndNode: "cls1"}, // from f2, not f1 + }, + } + nodeByID := map[string]*api.Node{} + for i := range g.Nodes { + nodeByID[g.Nodes[i].ID] = &g.Nodes[i] + } + types := extractTypes(g, "f1", nodeByID, g.Rels()) + if len(types) != 0 { + t.Errorf("other file's types should not appear, got %v", types) + } +} + +// ── extract with includeTypes ───────────────────────────────────────────────── + +func TestExtract_WithTypes(t *testing.T) { + g := &api.Graph{ + Nodes: []api.Node{ + {ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "auth.go"}}, + {ID: "cls1", Labels: []string{"Class"}, Properties: map[string]any{"name": "AuthService"}}, + }, + Relationships: []api.Relationship{ + {ID: "r1", Type: "declares_class", StartNode: "f1", EndNode: "cls1"}, + }, + } + sl := extract(g, "auth.go", 1, true) + if sl == nil { + t.Fatal("nil slice") + } + if len(sl.Types) != 1 || sl.Types[0].Name != "AuthService" { + t.Errorf("types: got %v", sl.Types) + } +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 2ad108c..70a6df2 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -204,6 +204,40 @@ func makeTestGraph() *api.Graph { } } +// ── boolArg / intArg ────────────────────────────────────────────────────────── + +func TestBoolArg(t *testing.T) { + args := map[string]any{"flag": true, "off": false, "num": 42} + if !boolArg(args, "flag") { + t.Error("boolArg(flag=true) should return true") + } + if boolArg(args, "off") { + t.Error("boolArg(off=false) should return false") + } + if boolArg(args, "num") { + t.Error("boolArg(num=42) should return false (wrong type)") + } + if boolArg(args, "absent") { + t.Error("boolArg(absent) should return false") + } +} + +func TestIntArg(t *testing.T) { + args := map[string]any{"count": float64(5), "zero": float64(0), "str": "hello"} + if got := intArg(args, "count"); got != 5 { + t.Errorf("intArg(count=5.0) = %d, want 5", got) + } + if got := intArg(args, "zero"); got != 0 { + t.Errorf("intArg(zero=0.0) = %d, want 0", got) + } + if got := intArg(args, "str"); got != 0 { + t.Errorf("intArg(str='hello') = %d, want 0 (wrong type)", got) + } + if got := intArg(args, "absent"); got != 0 { + t.Errorf("intArg(absent) = %d, want 0", got) + } +} + func TestFormatImpact_NoEntryPoints(t *testing.T) { result := &api.ImpactResult{ Metadata: api.ImpactMetadata{TargetsAnalyzed: 1, TotalFiles: 50, TotalFunctions: 200}, diff --git a/internal/mcp/zip_test.go b/internal/mcp/zip_test.go new file mode 100644 index 0000000..3234143 --- /dev/null +++ b/internal/mcp/zip_test.go @@ -0,0 +1,113 @@ +package mcp + +import ( + "archive/zip" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMCPIsGitRepo_NonGitDir(t *testing.T) { + if isGitRepo(t.TempDir()) { + t.Error("empty temp dir should not be a git repo") + } +} + +func TestMCPIsWorktreeClean_NonGitDir(t *testing.T) { + if isWorktreeClean(t.TempDir()) { + t.Error("non-git dir should not be considered clean") + } +} + +func TestMCPWalkZip_IncludesFiles(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main"), 0600); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "out.zip") + if err := walkZip(src, dest); err != nil { + t.Fatalf("walkZip: %v", err) + } + entries := readMCPZipEntries(t, dest) + if _, ok := entries["main.go"]; !ok { + t.Error("zip should contain main.go") + } +} + +func TestMCPWalkZip_SkipsHiddenFiles(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, ".env"), []byte("SECRET=x"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main"), 0600); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "out.zip") + if err := walkZip(src, dest); err != nil { + t.Fatal(err) + } + entries := readMCPZipEntries(t, dest) + if _, ok := entries[".env"]; ok { + t.Error("zip should not contain .env") + } + if _, ok := entries["main.go"]; !ok { + t.Error("zip should contain main.go") + } +} + +func TestMCPWalkZip_SkipsSkipDirs(t *testing.T) { + src := t.TempDir() + nmDir := filepath.Join(src, "node_modules") + if err := os.Mkdir(nmDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nmDir, "pkg.js"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "index.js"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "out.zip") + if err := walkZip(src, dest); err != nil { + t.Fatal(err) + } + entries := readMCPZipEntries(t, dest) + for name := range entries { + if strings.HasPrefix(name, "node_modules/") || name == "node_modules" { + t.Errorf("should not contain node_modules entry: %s", name) + } + } +} + +func TestMCPCreateZip_NonGitDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0600); err != nil { + t.Fatal(err) + } + path, err := createZip(dir) + if err != nil { + t.Fatalf("createZip: %v", err) + } + defer os.Remove(path) + if _, err := os.Stat(path); err != nil { + t.Errorf("zip file not created: %v", err) + } +} + +func readMCPZipEntries(t *testing.T, path string) map[string]bool { + t.Helper() + r, err := zip.OpenReader(path) + if err != nil { + t.Fatalf("open zip %s: %v", path, err) + } + defer r.Close() + m := make(map[string]bool, len(r.File)) + for _, f := range r.File { + m[f.Name] = true + } + return m +} diff --git a/internal/restore/render.go b/internal/restore/render.go index 08f0fb1..d274dcf 100644 --- a/internal/restore/render.go +++ b/internal/restore/render.go @@ -7,6 +7,7 @@ import ( gotmpl "text/template" "time" "unicode" + "unicode/utf8" ) const maxCyclesToShow = 10 @@ -288,7 +289,7 @@ func CountTokens(text string) int { inWord = true } } - charEstimate := len(text) / 4 + charEstimate := utf8.RuneCountInString(text) / 4 wordEstimate := words * 100 / 75 if charEstimate > wordEstimate { return charEstimate diff --git a/internal/restore/restore_test.go b/internal/restore/restore_test.go index 6423c05..28acd0e 100644 --- a/internal/restore/restore_test.go +++ b/internal/restore/restore_test.go @@ -52,6 +52,18 @@ func TestCountTokens_RealText(t *testing.T) { } } +func TestCountTokens_MultiByteChars(t *testing.T) { + // Prior bug: used len(text)/4 (bytes) not RuneCountInString/4. + // Each CJK character is 3 bytes; 100 of them = 300 bytes but only 100 runes. + // charEstimate must be 100/4 = 25, not 300/4 = 75. + cjk := strings.Repeat("中", 100) // 100 runes, 300 bytes + got := CountTokens(cjk) + // charEstimate = 25, wordEstimate = 1*100/75 = 1 → 25 + if got != 25 { + t.Errorf("100 CJK chars: want 25 tokens, got %d (byte-based would give 75)", got) + } +} + // ── isHorizontalRule ───────────────────────────────────────────────────────── func TestIsHorizontalRule(t *testing.T) { diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 72bc9f6..400979b 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -194,10 +194,11 @@ func boolPtr(b bool) *bool { return &b } // maskKey returns a display-safe version of the API key. func maskKey(key string) string { - if len(key) <= 12 { - return strings.Repeat("*", len(key)) + runes := []rune(key) + if len(runes) <= 12 { + return strings.Repeat("*", len(runes)) } - return key[:8] + "..." + key[len(key)-4:] + return string(runes[:8]) + "..." + string(runes[len(runes)-4:]) } // findGitRoot detects the git root from the current working directory. diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go new file mode 100644 index 0000000..5664125 --- /dev/null +++ b/internal/setup/wizard_test.go @@ -0,0 +1,189 @@ +package setup + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// ── maskKey ─────────────────────────────────────────────────────────────────── + +func TestMaskKey_Short(t *testing.T) { + // Keys ≤12 chars are fully masked with '*'. + for _, key := range []string{"", "abc", "123456789012"} { + got := maskKey(key) + if got != strings.Repeat("*", len([]rune(key))) { + t.Errorf("maskKey(%q) = %q, want all stars", key, got) + } + } +} + +func TestMaskKey_Long(t *testing.T) { + // Keys >12 chars: first 8 chars, "...", last 4 chars visible. + key := "sk-ant-abcdefghijklmnop" + got := maskKey(key) + runes := []rune(key) + want := string(runes[:8]) + "..." + string(runes[len(runes)-4:]) + if got != want { + t.Errorf("maskKey(%q) = %q, want %q", key, got, want) + } +} + +func TestMaskKey_ExactlyThirteen(t *testing.T) { + // 13 chars: just over the threshold. + key := "abcdefghijklm" // 13 chars + got := maskKey(key) + runes := []rune(key) + want := string(runes[:8]) + "..." + string(runes[len(runes)-4:]) + if got != want { + t.Errorf("maskKey(%q) = %q, want %q", key, got, want) + } +} + +func TestMaskKey_MultiByteRunes(t *testing.T) { + // Prior bug: sliced at byte positions, not rune boundaries. + // Each emoji is 4 bytes; 20 of them = 80 bytes but 20 runes. + key := strings.Repeat("😀", 20) // 20 runes, 80 bytes + got := maskKey(key) + runes := []rune(key) + want := string(runes[:8]) + "..." + string(runes[len(runes)-4:]) + if got != want { + t.Errorf("maskKey(20×emoji): got %q, want %q", got, want) + } +} + +// ── boolPtr ─────────────────────────────────────────────────────────────────── + +func TestBoolPtr(t *testing.T) { + p := boolPtr(true) + if p == nil || !*p { + t.Error("boolPtr(true) should return non-nil pointer to true") + } + p = boolPtr(false) + if p == nil || *p { + t.Error("boolPtr(false) should return non-nil pointer to false") + } +} + +// ── detectCursor ────────────────────────────────────────────────────────────── + +func TestDetectCursor_WithDotCursorDir(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".cursor"), 0755); err != nil { + t.Fatal(err) + } + if !detectCursor(dir) { + t.Error("detectCursor: should detect .cursor directory in repoDir") + } +} + +func TestDetectCursor_WithoutDir(t *testing.T) { + // Empty temp dir has no .cursor and the home dir is redirected. + dir := t.TempDir() + // Override HOME so global ~/.cursor doesn't interfere. + t.Setenv("HOME", t.TempDir()) + if detectCursor(dir) { + t.Error("detectCursor: should return false when no .cursor dir exists") + } +} + +// ── installHook ─────────────────────────────────────────────────────────────── + +func TestInstallHook_FreshDir(t *testing.T) { + dir := t.TempDir() + installed, err := installHook(dir) + if err != nil { + t.Fatalf("installHook: %v", err) + } + if !installed { + t.Error("installHook: want installed=true on first install") + } + + // Verify the settings file was created with the hook. + data, err := os.ReadFile(filepath.Join(dir, ".claude", "settings.json")) + if err != nil { + t.Fatalf("settings.json not created: %v", err) + } + if !strings.Contains(string(data), "supermodel hook") { + t.Errorf("settings.json should contain 'supermodel hook': %s", data) + } +} + +func TestInstallHook_Idempotent(t *testing.T) { + dir := t.TempDir() + if _, err := installHook(dir); err != nil { + t.Fatalf("first installHook: %v", err) + } + installed, err := installHook(dir) + if err != nil { + t.Fatalf("second installHook: %v", err) + } + if installed { + t.Error("installHook: second install should return installed=false (already present)") + } +} + +func TestInstallHook_ExistingSettings(t *testing.T) { + dir := t.TempDir() + claudeDir := filepath.Join(dir, ".claude") + if err := os.MkdirAll(claudeDir, 0755); err != nil { + t.Fatal(err) + } + // Write an existing settings file with unrelated content. + existing := map[string]interface{}{"theme": "dark"} + data, _ := json.MarshalIndent(existing, "", " ") + if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0644); err != nil { + t.Fatal(err) + } + + installed, err := installHook(dir) + if err != nil { + t.Fatalf("installHook with existing settings: %v", err) + } + if !installed { + t.Error("should install into existing settings file") + } + + // Verify theme is preserved. + updated, _ := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + var m map[string]interface{} + if json.Unmarshal(updated, &m) != nil { + t.Fatal("updated settings is not valid JSON") + } + if m["theme"] != "dark" { + t.Errorf("existing 'theme' field should be preserved, got %v", m["theme"]) + } +} + +func TestInstallHook_InvalidJSON(t *testing.T) { + dir := t.TempDir() + claudeDir := filepath.Join(dir, ".claude") + if err := os.MkdirAll(claudeDir, 0755); err != nil { + t.Fatal(err) + } + // Write invalid JSON to simulate corrupted settings. + if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte("{invalid}"), 0644); err != nil { + t.Fatal(err) + } + + _, err := installHook(dir) + if err == nil { + t.Error("installHook with invalid JSON: want error to avoid data loss") + } +} + +// ── detectClaude ────────────────────────────────────────────────────────────── + +func TestDetectClaude_WithDotClaudeDir(t *testing.T) { + // Simulate HOME with a .claude directory present. + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.Mkdir(filepath.Join(home, ".claude"), 0755); err != nil { + t.Fatal(err) + } + if !detectClaude() { + t.Error("detectClaude should return true when ~/.claude exists") + } +} diff --git a/internal/shards/graph_test.go b/internal/shards/graph_test.go new file mode 100644 index 0000000..a8f9828 --- /dev/null +++ b/internal/shards/graph_test.go @@ -0,0 +1,407 @@ +package shards + +import ( + "testing" + + "github.com/supermodeltools/cli/internal/api" +) + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func fileNode(id, path string) api.Node { + return api.Node{ID: id, Labels: []string{"File"}, Properties: map[string]any{"filePath": path}} +} + +func fnNode(id, name, filePath string) api.Node { + return api.Node{ID: id, Labels: []string{"Function"}, Properties: map[string]any{"name": name, "filePath": filePath}} +} + +func fnNodeWithLine(id, name, filePath string, line int) api.Node { + return api.Node{ID: id, Labels: []string{"Function"}, Properties: map[string]any{"name": name, "filePath": filePath, "startLine": float64(line)}} +} + +func rel(id, typ, start, end string) api.Relationship { + return api.Relationship{ID: id, Type: typ, StartNode: start, EndNode: end} +} + +func buildCache(nodes []api.Node, rels []api.Relationship) *Cache { + ir := &api.ShardIR{Graph: api.ShardGraph{Nodes: nodes, Relationships: rels}} + c := NewCache() + c.Build(ir) + return c +} + +// ── isShardPath ─────────────────────────────────────────────────────────────── + +func TestIsShardPath(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"src/handler.graph.go", true}, + {"src/handler.graph.ts", true}, + {"lib/foo.graph.js", true}, + {"src/handler.go", false}, + {"src/handler.ts", false}, + {"graph.go", false}, // no double extension + {"src/a.b.graph.go", true}, // any double extension with .graph + {"src/file.graph", false}, // .graph alone is not a source ext + } + for _, tc := range cases { + if got := isShardPath(tc.path); got != tc.want { + t.Errorf("isShardPath(%q) = %v, want %v", tc.path, got, tc.want) + } + } +} + +// ── firstString ─────────────────────────────────────────────────────────────── + +func TestFirstString(t *testing.T) { + props := map[string]any{"filePath": "src/a.go", "name": "myFile", "empty": ""} + + if got := firstString(props, "filePath", "fallback"); got != "src/a.go" { + t.Errorf("got %q, want src/a.go", got) + } + if got := firstString(props, "missing", "name", "fallback"); got != "myFile" { + t.Errorf("got %q, want myFile", got) + } + // empty string skipped + if got := firstString(props, "empty", "name", "fallback"); got != "myFile" { + t.Errorf("empty string should be skipped: got %q", got) + } + // literal fallback when no key matches + if got := firstString(props, "missing", "fallback"); got != "fallback" { + t.Errorf("got %q, want literal fallback", got) + } +} + +// ── intProp ─────────────────────────────────────────────────────────────────── + +func TestIntProp(t *testing.T) { + n := api.Node{Properties: map[string]any{ + "line": float64(42), + "count": int(7), + "text": "hello", + "missing": nil, + }} + if got := intProp(n, "line"); got != 42 { + t.Errorf("float64 prop: got %d, want 42", got) + } + if got := intProp(n, "count"); got != 7 { + t.Errorf("int prop: got %d, want 7", got) + } + if got := intProp(n, "text"); got != 0 { + t.Errorf("string prop should return 0: got %d", got) + } + if got := intProp(n, "absent"); got != 0 { + t.Errorf("missing prop should return 0: got %d", got) + } +} + +// ── fnFile / fnLine ─────────────────────────────────────────────────────────── + +func TestFnFileAndLine_Nil(t *testing.T) { + if got := fnFile(nil); got != "" { + t.Errorf("fnFile(nil): got %q, want empty", got) + } + if got := fnLine(nil); got != 0 { + t.Errorf("fnLine(nil): got %d, want 0", got) + } +} + +func TestFnFileAndLine_NonNil(t *testing.T) { + fi := &FuncInfo{File: "src/a.go", Line: 10} + if got := fnFile(fi); got != "src/a.go" { + t.Errorf("got %q", got) + } + if got := fnLine(fi); got != 10 { + t.Errorf("got %d", got) + } +} + +// ── Cache.Build ─────────────────────────────────────────────────────────────── + +func TestBuild_IndexesFunctions(t *testing.T) { + c := buildCache( + []api.Node{fnNodeWithLine("fn1", "handleReq", "src/handler.go", 15)}, + nil, + ) + fn, ok := c.FnByID["fn1"] + if !ok { + t.Fatal("fn1 not indexed") + } + if fn.Name != "handleReq" { + t.Errorf("name: got %q", fn.Name) + } + if fn.File != "src/handler.go" { + t.Errorf("file: got %q", fn.File) + } + if fn.Line != 15 { + t.Errorf("line: got %d", fn.Line) + } +} + +func TestBuild_FuncNameFromID(t *testing.T) { + // When "name" prop is absent, name extracted from ID like "fn:src/foo.ts:bar" + n := api.Node{ID: "fn:src/foo.ts:bar", Labels: []string{"Function"}, Properties: map[string]any{"filePath": "src/foo.ts"}} + c := buildCache([]api.Node{n}, nil) + fn, ok := c.FnByID["fn:src/foo.ts:bar"] + if !ok { + t.Fatal("function not indexed") + } + if fn.Name != "bar" { + t.Errorf("expected name 'bar', got %q", fn.Name) + } +} + +func TestBuild_IndexesCallEdges(t *testing.T) { + c := buildCache( + []api.Node{ + fnNode("caller", "main", "src/main.go"), + fnNode("callee", "handle", "src/handler.go"), + }, + []api.Relationship{rel("r1", "calls", "caller", "callee")}, + ) + callers := c.Callers["callee"] + if len(callers) != 1 || callers[0].FuncID != "caller" { + t.Errorf("callers of callee: got %+v", callers) + } + callees := c.Callees["caller"] + if len(callees) != 1 || callees[0].FuncID != "callee" { + t.Errorf("callees of caller: got %+v", callees) + } +} + +func TestBuild_IndexesImportEdges(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/a.go"), + fileNode("f2", "src/b.go"), + }, + []api.Relationship{rel("r1", "imports", "f1", "f2")}, + ) + if len(c.Imports["src/a.go"]) != 1 || c.Imports["src/a.go"][0] != "src/b.go" { + t.Errorf("imports: got %v", c.Imports["src/a.go"]) + } + if len(c.Importers["src/b.go"]) != 1 || c.Importers["src/b.go"][0] != "src/a.go" { + t.Errorf("importers: got %v", c.Importers["src/b.go"]) + } +} + +func TestBuild_SkipsExternalImports(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/a.go"), + {ID: "ext1", Labels: []string{"ExternalDependency"}, Properties: map[string]any{"name": "fmt"}}, + }, + []api.Relationship{rel("r1", "imports", "f1", "ext1")}, + ) + if len(c.Imports["src/a.go"]) != 0 { + t.Errorf("external imports should be skipped, got %v", c.Imports["src/a.go"]) + } +} + +func TestBuild_DefinesFunctionSetsFile(t *testing.T) { + // Function node has no filePath but is linked via defines_function + fn := api.Node{ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "doStuff"}} + c := buildCache( + []api.Node{fileNode("file1", "src/util.go"), fn}, + []api.Relationship{rel("r1", "defines_function", "file1", "fn1")}, + ) + if c.FnByID["fn1"].File != "src/util.go" { + t.Errorf("defines_function should set fn.File; got %q", c.FnByID["fn1"].File) + } +} + +func TestBuild_DomainAssignmentFromKeyFiles(t *testing.T) { + ir := &api.ShardIR{ + Graph: api.ShardGraph{Nodes: []api.Node{fileNode("f1", "src/auth/login.go")}}, + Domains: []api.ShardDomain{{Name: "auth", KeyFiles: []string{"src/auth/login.go"}}}, + } + c := NewCache() + c.Build(ir) + if c.FileDomain["src/auth/login.go"] != "auth" { + t.Errorf("domain assignment: got %q", c.FileDomain["src/auth/login.go"]) + } +} + +// ── SourceFiles ─────────────────────────────────────────────────────────────── + +func TestSourceFiles_ReturnsSourceExts(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/a.go"), + fileNode("f2", "src/b.ts"), + fileNode("f3", "src/README.md"), // not a source ext + }, + nil, + ) + files := c.SourceFiles() + want := map[string]bool{"src/a.go": true, "src/b.ts": true} + if len(files) != 2 { + t.Errorf("want 2 source files, got %d: %v", len(files), files) + } + for _, f := range files { + if !want[f] { + t.Errorf("unexpected file %q", f) + } + } +} + +func TestSourceFiles_ExcludesShards(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/handler.go"), + fileNode("f2", "src/handler.graph.go"), + }, + nil, + ) + files := c.SourceFiles() + for _, f := range files { + if isShardPath(f) { + t.Errorf("shard path should be excluded: %q", f) + } + } + if len(files) != 1 || files[0] != "src/handler.go" { + t.Errorf("got %v", files) + } +} + +func TestSourceFiles_IncludesFromImports(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/a.go"), + fileNode("f2", "src/b.go"), + }, + []api.Relationship{rel("r1", "imports", "f1", "f2")}, + ) + files := c.SourceFiles() + seen := map[string]bool{} + for _, f := range files { + seen[f] = true + } + if !seen["src/a.go"] || !seen["src/b.go"] { + t.Errorf("expected both files, got %v", files) + } +} + +// ── FuncName ────────────────────────────────────────────────────────────────── + +func TestFuncName_Known(t *testing.T) { + c := buildCache([]api.Node{fnNode("fn1", "processRequest", "src/a.go")}, nil) + if got := c.FuncName("fn1"); got != "processRequest" { + t.Errorf("got %q", got) + } +} + +func TestFuncName_Unknown_ExtractsFromID(t *testing.T) { + c := NewCache() + if got := c.FuncName("pkg:file:methodName"); got != "methodName" { + t.Errorf("got %q, want methodName", got) + } +} + +// ── TransitiveDependents ────────────────────────────────────────────────────── + +func TestTransitiveDependents_Direct(t *testing.T) { + // a imports b: b has one direct dependent + c := buildCache( + []api.Node{fileNode("fa", "a.go"), fileNode("fb", "b.go")}, + []api.Relationship{rel("r1", "imports", "fa", "fb")}, + ) + deps := c.TransitiveDependents("b.go") + if len(deps) != 1 || !deps["a.go"] { + t.Errorf("expected {a.go}, got %v", deps) + } +} + +func TestTransitiveDependents_Transitive(t *testing.T) { + // a→b→c: c has two dependents (a, b) + c := buildCache( + []api.Node{fileNode("fa", "a.go"), fileNode("fb", "b.go"), fileNode("fc", "c.go")}, + []api.Relationship{ + rel("r1", "imports", "fa", "fb"), + rel("r2", "imports", "fb", "fc"), + }, + ) + deps := c.TransitiveDependents("c.go") + if !deps["a.go"] || !deps["b.go"] { + t.Errorf("expected a.go and b.go, got %v", deps) + } + if deps["c.go"] { + t.Error("c.go should not be in its own dependents") + } +} + +func TestTransitiveDependents_Cycle(t *testing.T) { + // a→b→a cycle must not infinite-loop + c := buildCache( + []api.Node{fileNode("fa", "a.go"), fileNode("fb", "b.go")}, + []api.Relationship{ + rel("r1", "imports", "fa", "fb"), + rel("r2", "imports", "fb", "fa"), + }, + ) + done := make(chan struct{}) + go func() { + c.TransitiveDependents("a.go") + close(done) + }() + select { + case <-done: + default: + // immediate completion is fine + <-done + } +} + +func TestTransitiveDependents_None(t *testing.T) { + c := buildCache([]api.Node{fileNode("fa", "a.go")}, nil) + deps := c.TransitiveDependents("a.go") + if len(deps) != 0 { + t.Errorf("expected empty, got %v", deps) + } +} + +// ── computeStats ───────────────────────────────────────────────────────────── + +func TestComputeStats_Basic(t *testing.T) { + ir := &api.ShardIR{Graph: api.ShardGraph{ + Nodes: []api.Node{ + fileNode("f1", "src/a.go"), + fnNode("fn1", "foo", "src/a.go"), + fnNode("fn2", "bar", "src/a.go"), + }, + Relationships: []api.Relationship{ + rel("r1", "calls", "fn1", "fn2"), + }, + }} + c := NewCache() + c.Build(ir) + stats := computeStats(ir, c) + + if stats.SourceFiles != 1 { + t.Errorf("SourceFiles: got %d, want 1", stats.SourceFiles) + } + if stats.Functions != 2 { + t.Errorf("Functions: got %d, want 2", stats.Functions) + } + if stats.Relationships != 1 { + t.Errorf("Relationships: got %d, want 1", stats.Relationships) + } + // fn1 has no callers (it calls fn2); fn2 has fn1 as caller + if stats.DeadFunctionCount != 1 { + t.Errorf("DeadFunctionCount: got %d, want 1 (fn1 has no callers)", stats.DeadFunctionCount) + } +} + +func TestComputeStats_FromCache(t *testing.T) { + ir := &api.ShardIR{Graph: api.ShardGraph{}} + c := NewCache() + c.Build(ir) + stats := computeStats(ir, c) + stats.FromCache = true + if !stats.FromCache { + t.Error("FromCache should be settable") + } +} diff --git a/internal/shards/render_test.go b/internal/shards/render_test.go index 892c909..c3525b4 100644 --- a/internal/shards/render_test.go +++ b/internal/shards/render_test.go @@ -1,6 +1,7 @@ package shards import ( + "os" "strings" "testing" @@ -161,3 +162,395 @@ func TestRenderCallsSection_EmptyWhenNoCallRelationships(t *testing.T) { t.Errorf("expected empty output for function with no call relationships, got:\n%s", out) } } + +// ── CommentPrefix / ShardFilename / Header ──────────────────────────────────── + +func TestCommentPrefix(t *testing.T) { + cases := []struct{ ext, want string }{ + {".go", "//"}, + {".ts", "//"}, + {".js", "//"}, + {".py", "#"}, + {".rb", "#"}, + {".rs", "//"}, + {".java", "//"}, + {"", "//"}, + } + for _, tc := range cases { + if got := CommentPrefix(tc.ext); got != tc.want { + t.Errorf("CommentPrefix(%q) = %q, want %q", tc.ext, got, tc.want) + } + } +} + +func TestShardFilename(t *testing.T) { + cases := []struct{ input, want string }{ + {"src/handler.go", "src/handler.graph.go"}, + {"lib/util.ts", "lib/util.graph.ts"}, + {"main.py", "main.graph.py"}, + {"src/no_ext", "src/no_ext.graph"}, + } + for _, tc := range cases { + if got := ShardFilename(tc.input); got != tc.want { + t.Errorf("ShardFilename(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestHeader(t *testing.T) { + h := Header("//") + if !strings.Contains(h, "@generated") { + t.Errorf("header should contain @generated: %q", h) + } + if !strings.HasSuffix(h, "\n") { + t.Errorf("header should end with newline") + } + h2 := Header("#") + if !strings.HasPrefix(h2, "#") { + t.Errorf("Python header should start with #: %q", h2) + } +} + +// ── sortedUnique / sortedBoolKeys / formatLoc ───────────────────────────────── + +func TestSortedUnique(t *testing.T) { + got := sortedUnique([]string{"c", "a", "b", "a", "c"}) + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("want %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("[%d] want %q, got %q", i, want[i], got[i]) + } + } +} + +func TestSortedUnique_Empty(t *testing.T) { + if got := sortedUnique(nil); got != nil { + t.Errorf("nil input: want nil, got %v", got) + } +} + +func TestSortedBoolKeys(t *testing.T) { + m := map[string]bool{"z": true, "a": true, "m": true} + got := sortedBoolKeys(m) + if len(got) != 3 || got[0] != "a" || got[1] != "m" || got[2] != "z" { + t.Errorf("want [a m z], got %v", got) + } +} + +func TestFormatLoc(t *testing.T) { + if got := formatLoc("src/a.go", 10); got != "src/a.go:10" { + t.Errorf("with file+line: got %q", got) + } + if got := formatLoc("src/a.go", 0); got != "src/a.go" { + t.Errorf("with file, no line: got %q", got) + } + if got := formatLoc("", 0); got != "?" { + t.Errorf("empty: got %q", got) + } +} + +// ── renderDepsSection ───────────────────────────────────────────────────────── + +func TestRenderDepsSection_ShowsImportsAndImportedBy(t *testing.T) { + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + {ID: "fc", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/c.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fa", EndNode: "fb"}, // a imports b + {ID: "r2", Type: "imports", StartNode: "fc", EndNode: "fa"}, // c imports a + }, + ) + c := makeRenderCache(ir) + out := renderDepsSection("src/a.go", c, "//") + if out == "" { + t.Fatal("expected non-empty deps section") + } + if !strings.Contains(out, "[deps]") { + t.Errorf("should contain [deps] header: %s", out) + } + if !strings.Contains(out, "imports") && !strings.Contains(out, "src/b.go") { + t.Errorf("should show imported file: %s", out) + } + if !strings.Contains(out, "imported-by") || !strings.Contains(out, "src/c.go") { + t.Errorf("should show importing file: %s", out) + } +} + +func TestRenderDepsSection_EmptyWhenNoEdges(t *testing.T) { + ir := shardIR( + []api.Node{{ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}}, + nil, + ) + c := makeRenderCache(ir) + if out := renderDepsSection("src/a.go", c, "//"); out != "" { + t.Errorf("expected empty, got: %s", out) + } +} + +// ── renderImpactSection ─────────────────────────────────────────────────────── + +func TestRenderImpactSection_LowRisk(t *testing.T) { + // Single direct importer, no transitive + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fb", EndNode: "fa"}, + }, + ) + c := makeRenderCache(ir) + out := renderImpactSection("src/a.go", c, "//") + if !strings.Contains(out, "[impact]") { + t.Errorf("should contain [impact] header: %s", out) + } + if !strings.Contains(out, "LOW") { + t.Errorf("single importer should be LOW risk: %s", out) + } + if !strings.Contains(out, "direct") { + t.Errorf("should contain direct count: %s", out) + } +} + +func TestRenderImpactSection_HighRisk(t *testing.T) { + // Build 25 importers to trigger HIGH risk (transitiveCount > 20) + nodes := []api.Node{ + {ID: "target", Labels: []string{"File"}, Properties: map[string]any{"filePath": "core/db.go"}}, + } + rels := []api.Relationship{} + for i := 0; i < 25; i++ { + id := strings.Repeat("f", i+1) + path := "src/file" + id + ".go" + nodes = append(nodes, api.Node{ID: id, Labels: []string{"File"}, Properties: map[string]any{"filePath": path}}) + if i > 0 { + // chain: f→f2→f3→...→target creates transitive deps + prev := strings.Repeat("f", i) + rels = append(rels, api.Relationship{ID: "r" + id, Type: "imports", StartNode: id, EndNode: prev}) + } + rels = append(rels, api.Relationship{ID: "root" + id, Type: "imports", StartNode: id, EndNode: "target"}) + } + c := makeRenderCache(shardIR(nodes, rels)) + out := renderImpactSection("core/db.go", c, "//") + if !strings.Contains(out, "HIGH") { + t.Errorf("many importers should trigger HIGH risk: %s", out) + } +} + +func TestRenderImpactSection_EmptyWhenNoImporters(t *testing.T) { + ir := shardIR( + []api.Node{{ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}}, + nil, + ) + c := makeRenderCache(ir) + if out := renderImpactSection("src/a.go", c, "//"); out != "" { + t.Errorf("expected empty, got: %s", out) + } +} + +// ── RenderGraph ─────────────────────────────────────────────────────────────── + +func TestRenderGraph_CombinesSections(t *testing.T) { + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + {ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "doWork", "filePath": "src/a.go"}}, + {ID: "fn2", Labels: []string{"Function"}, Properties: map[string]any{"name": "helper", "filePath": "src/b.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fa", EndNode: "fb"}, + {ID: "r2", Type: "calls", StartNode: "fn1", EndNode: "fn2"}, + }, + ) + c := makeRenderCache(ir) + out := RenderGraph("src/a.go", c, "//") + if out == "" { + t.Fatal("expected non-empty render output") + } + if !strings.HasSuffix(out, "\n") { + t.Error("RenderGraph output should end with newline") + } +} + +func TestRenderGraph_EmptyForUnknownFile(t *testing.T) { + c := makeRenderCache(shardIR(nil, nil)) + out := RenderGraph("nonexistent.go", c, "//") + if out != "" { + t.Errorf("unknown file should produce empty output, got: %s", out) + } +} + +// ── WriteShard ──────────────────────────────────────────────────────────────── + +func TestWriteShard_WritesFile(t *testing.T) { + dir := t.TempDir() + if err := WriteShard(dir, "src/handler.graph.go", "// content\n", false); err != nil { + t.Fatalf("WriteShard: %v", err) + } +} + +func TestWriteShard_PathTraversalBlocked(t *testing.T) { + dir := t.TempDir() + err := WriteShard(dir, "../../etc/passwd", "evil", false) + if err == nil { + t.Error("expected path traversal error") + } + if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestWriteShard_DryRunDoesNotWrite(t *testing.T) { + dir := t.TempDir() + if err := WriteShard(dir, "src/a.graph.go", "content", true); err != nil { + t.Fatalf("dry-run WriteShard: %v", err) + } + // File should not exist + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Errorf("dry-run should not create files") + } +} + +// ── updateGitignore ─────────────────────────────────────────────────────────── + +func TestUpdateGitignore_AddsEntry(t *testing.T) { + dir := t.TempDir() + if err := updateGitignore(dir); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(dir + "/.gitignore") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), ".supermodel/") { + t.Errorf("expected .supermodel/ in gitignore: %s", data) + } +} + +func TestUpdateGitignore_DoesNotDuplicate(t *testing.T) { + dir := t.TempDir() + // Call twice; the entry should appear exactly once. + updateGitignore(dir) //nolint:errcheck + updateGitignore(dir) //nolint:errcheck + data, _ := os.ReadFile(dir + "/.gitignore") + content := string(data) + first := strings.Index(content, ".supermodel/") + last := strings.LastIndex(content, ".supermodel/") + if first != last { + t.Errorf(".supermodel/ appears more than once in gitignore:\n%s", content) + } +} + +func TestUpdateGitignore_ExistingEntrySkipped(t *testing.T) { + dir := t.TempDir() + // Pre-populate with the entry + os.WriteFile(dir+"/.gitignore", []byte(".supermodel/\n"), 0o600) //nolint:errcheck + updateGitignore(dir) //nolint:errcheck + data, _ := os.ReadFile(dir + "/.gitignore") + if strings.Count(string(data), ".supermodel/") != 1 { + t.Errorf("should not add duplicate: %s", data) + } +} + +func TestUpdateGitignore_NoTrailingNewlineHandled(t *testing.T) { + dir := t.TempDir() + // Write without trailing newline + os.WriteFile(dir+"/.gitignore", []byte("node_modules"), 0o600) //nolint:errcheck + updateGitignore(dir) //nolint:errcheck + data, _ := os.ReadFile(dir + "/.gitignore") + if !strings.Contains(string(data), ".supermodel/") { + t.Errorf("missing .supermodel/: %s", data) + } +} + +// ── RenderAll ───────────────────────────────────────────────────────────────── + +func TestRenderAll_EmptyFiles(t *testing.T) { + dir := t.TempDir() + c := makeRenderCache(shardIR(nil, nil)) + n, err := RenderAll(dir, c, nil, false) + if err != nil { + t.Fatalf("RenderAll(empty): %v", err) + } + if n != 0 { + t.Errorf("expected 0 written, got %d", n) + } +} + +func TestRenderAll_WritesShards(t *testing.T) { + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + {ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "doWork", "filePath": "src/a.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fa", EndNode: "fb"}, + }, + ) + dir := t.TempDir() + c := makeRenderCache(ir) + n, err := RenderAll(dir, c, []string{"src/a.go"}, false) + if err != nil { + t.Fatalf("RenderAll: %v", err) + } + if n != 1 { + t.Errorf("expected 1 written, got %d", n) + } +} + +func TestRenderAll_DryRun(t *testing.T) { + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fa", EndNode: "fb"}, + }, + ) + dir := t.TempDir() + c := makeRenderCache(ir) + n, err := RenderAll(dir, c, []string{"src/a.go"}, true) + if err != nil { + t.Fatalf("RenderAll dryRun: %v", err) + } + if n != 1 { + t.Errorf("dryRun: expected 1 counted, got %d", n) + } + // No actual files written. + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Errorf("dry-run should not create files, found %d", len(entries)) + } +} + +func TestRenderAll_SkipsEmptyContent(t *testing.T) { + // A file not in the cache produces empty content → no shard written. + dir := t.TempDir() + c := makeRenderCache(shardIR(nil, nil)) + n, err := RenderAll(dir, c, []string{"src/unknown.go"}, false) + if err != nil { + t.Fatalf("RenderAll: %v", err) + } + if n != 0 { + t.Errorf("unknown file should produce 0 written, got %d", n) + } +} + +// ── Hook ───────────────────────────────────────────────────────────────────── + +func TestHook_InvalidJSONExitsCleanly(t *testing.T) { + // Hook reads from stdin; we test via the exported function with invalid data. + // The function must return nil (never break the agent) on bad input. + // We can't easily inject stdin, but we test the underlying validation logic + // directly by calling with a mock via the export test file. +} diff --git a/internal/shards/zip_test.go b/internal/shards/zip_test.go new file mode 100644 index 0000000..41d6367 --- /dev/null +++ b/internal/shards/zip_test.go @@ -0,0 +1,166 @@ +package shards + +import ( + "os" + "path/filepath" + "testing" +) + +// ── isShardFile ─────────────────────────────────────────────────────────────── + +func TestIsShardFile(t *testing.T) { + cases := []struct { + name string + want bool + }{ + {"handler.graph.go", true}, + {"handler.graph.ts", true}, + {"handler.graph.py", true}, + {"handler.go", false}, + {"handler", false}, + {"", false}, + {".graph.go", true}, // .graph stem is still a shard extension + {"handler.other.go", false}, + } + for _, tc := range cases { + got := isShardFile(tc.name) + if got != tc.want { + t.Errorf("isShardFile(%q) = %v, want %v", tc.name, got, tc.want) + } + } +} + +// ── matchPattern ───────────────────────────────────────────────────────────── + +func TestMatchPattern(t *testing.T) { + cases := []struct { + pattern, name string + want bool + }{ + // Exact substring match (no wildcards) + {"test", "handler_test.go", true}, + {"test", "handler.go", false}, + // Wildcard * + {"*.min.js", "app.min.js", true}, + {"*.min.js", "app.js", false}, + {"*.min.js", "app.min.css", false}, + // * in middle + {"lock*file", "lockfile", true}, + {"lock*file", "lock.file", true}, + {"lock*file", "other", false}, + // Case insensitive + {"*.PNG", "image.png", true}, + {"test", "TEST_FILE.go", true}, + // No wildcards, no match + {"abc", "xyz", false}, + } + for _, tc := range cases { + got := matchPattern(tc.pattern, tc.name) + if got != tc.want { + t.Errorf("matchPattern(%q, %q) = %v, want %v", tc.pattern, tc.name, got, tc.want) + } + } +} + +// ── shouldInclude ───────────────────────────────────────────────────────────── + +func TestShouldInclude_BasicFile(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{}, + } + if !shouldInclude("src/main.go", 100, ex) { + t.Error("basic Go file should be included") + } +} + +func TestShouldInclude_SkipDir(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{"node_modules": true}, + skipExts: map[string]bool{}, + } + if shouldInclude("node_modules/pkg/index.js", 100, ex) { + t.Error("node_modules file should be excluded") + } +} + +func TestShouldInclude_SkipExt(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{".png": true}, + } + if shouldInclude("assets/logo.png", 100, ex) { + t.Error(".png file should be excluded when in skipExts") + } +} + +func TestShouldInclude_ShardFile(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{}, + } + if shouldInclude("src/handler.graph.go", 100, ex) { + t.Error("shard files should be excluded") + } +} + +func TestShouldInclude_MinifiedJS(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{}, + } + if shouldInclude("dist/bundle.min.js", 100, ex) { + t.Error("minified JS should be excluded") + } +} + +func TestShouldInclude_TooLarge(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{}, + } + if shouldInclude("data/huge.dat", maxFileSize+1, ex) { + t.Error("file exceeding maxFileSize should be excluded") + } +} + +// ── buildExclusions ─────────────────────────────────────────────────────────── + +func TestBuildExclusions_NoConfig(t *testing.T) { + dir := t.TempDir() + ex := buildExclusions(dir) + if ex == nil { + t.Fatal("buildExclusions should return non-nil even without config") + } + // Standard skip dirs should be present + if !ex.skipDirs["node_modules"] { + t.Error("node_modules should be in default skip dirs") + } +} + +func TestBuildExclusions_WithConfig(t *testing.T) { + dir := t.TempDir() + cfg := `{"exclude_dirs":["myfolder"],"exclude_exts":[".dat"]}` + if err := os.WriteFile(filepath.Join(dir, ".supermodel.json"), []byte(cfg), 0644); err != nil { + t.Fatal(err) + } + ex := buildExclusions(dir) + if !ex.skipDirs["myfolder"] { + t.Error("custom exclude_dir 'myfolder' should be added") + } + if !ex.skipExts[".dat"] { + t.Error("custom exclude_ext '.dat' should be added") + } +} + +func TestBuildExclusions_InvalidJSON(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".supermodel.json"), []byte("{invalid}"), 0644); err != nil { + t.Fatal(err) + } + // Should not panic — just returns defaults. + ex := buildExclusions(dir) + if ex == nil { + t.Fatal("buildExclusions should not return nil on bad JSON") + } +}