diff --git a/cmd/root.go b/cmd/root.go index 26145a5..46f2c53 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,6 +27,7 @@ var ( excludePatterns []string includePatterns []string branch string + outputFormat string ) var rootCmd = &cobra.Command{ @@ -62,6 +63,12 @@ You can specify a local path or a repository URL as the source.`, return nil }, Run: func(cmd *cobra.Command, args []string) { + // Validate format flag early + if outputFormat != "text" && outputFormat != "json" { + fmt.Fprintf(os.Stderr, "Error: unsupported format '%s'. Use 'text' or 'json'.\n", outputFormat) + os.Exit(1) + } + source := args[0] opts := digest.IngestionOptions{ @@ -84,31 +91,41 @@ You can specify a local path or a repository URL as the source.`, os.Exit(1) } - ingestResult.FormatOutput(opts) + // Route to the correct formatter — exactly one call + if outputFormat == "json" { + jsonBytes, errJSON := ingestResult.FormatJSON(opts) + if errJSON != nil { + fmt.Fprintf(os.Stderr, "Error formatting JSON output: %v\n", errJSON) + os.Exit(1) + } - if opts.OutputFile != "" && opts.OutputFile != "-" { - outputDir := filepath.Dir(opts.OutputFile) - if outputDir != "." && outputDir != "" { - if err := os.MkdirAll(outputDir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "Error creating output directory %s: %v\n", outputDir, err) + if opts.OutputFile != "" && opts.OutputFile != "-" { + if err := writeOutputFile(opts.OutputFile, jsonBytes); err != nil { + fmt.Fprintf(os.Stderr, "Error writing to output file %s: %v\n", opts.OutputFile, err) os.Exit(1) } + fmt.Fprintf(os.Stderr, "Digest written to: %s\n", opts.OutputFile) + } else { + os.Stdout.Write(jsonBytes) } + } else { + ingestResult.FormatOutput(opts) - fileContentToWrite := ingestResult.TreeStructure + "\n" + ingestResult.FileContents - err = os.WriteFile(opts.OutputFile, []byte(fileContentToWrite), 0644) - if err != nil { - fmt.Fprintf(os.Stderr, "Error writing to output file %s: %v\n", opts.OutputFile, err) - os.Exit(1) + if opts.OutputFile != "" && opts.OutputFile != "-" { + textBytes := []byte(ingestResult.TreeStructure + "\n" + ingestResult.FileContents) + if err := writeOutputFile(opts.OutputFile, textBytes); err != nil { + fmt.Fprintf(os.Stderr, "Error writing to output file %s: %v\n", opts.OutputFile, err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "Digest written to: %s\n", opts.OutputFile) + } else { + fmt.Println(ingestResult.TreeStructure) + fmt.Println(ingestResult.FileContents) } - fmt.Fprintf(os.Stderr, "Digest written to: %s\n", opts.OutputFile) - } else { - fmt.Println(ingestResult.TreeStructure) - fmt.Println(ingestResult.FileContents) - } - fmt.Fprintln(os.Stderr, "\n--- Summary ---") - fmt.Fprint(os.Stderr, ingestResult.Summary) + fmt.Fprintln(os.Stderr, "\n--- Summary ---") + fmt.Fprint(os.Stderr, ingestResult.Summary) + } }, } @@ -126,6 +143,16 @@ var versionCmd = &cobra.Command{ }, } +func writeOutputFile(path string, data []byte) error { + outputDir := filepath.Dir(path) + if outputDir != "." && outputDir != "" { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return err + } + } + return os.WriteFile(path, data, 0644) +} + func Execute() { err := rootCmd.Execute() if err != nil { @@ -142,4 +169,5 @@ func init() { rootCmd.Flags().StringSliceP("exclude-pattern", "e", []string{}, "Comma-separated glob patterns to exclude (adds to defaults)") rootCmd.Flags().StringSliceVarP(&includePatterns, "include-pattern", "i", []string{}, "Comma-separated glob patterns to include (overrides excludes)") rootCmd.Flags().StringVarP(&branch, "branch", "b", "", "Branch to clone and ingest (if source is a Git URL)") + rootCmd.Flags().StringVarP(&outputFormat, "format", "f", "text", "Output format: text or json") } diff --git a/internal/digest/json.go b/internal/digest/json.go new file mode 100644 index 0000000..2b1ec27 --- /dev/null +++ b/internal/digest/json.go @@ -0,0 +1,138 @@ +package digest + +import ( + "encoding/json" + "path/filepath" +) + +type JSONOutput struct { + Summary JSONSummary `json:"summary"` + Tree []*JSONNode `json:"tree"` + Files []JSONFile `json:"files"` + GitInfo *JSONGitInfo `json:"git_info,omitempty"` +} + +type JSONSummary struct { + Source string `json:"source"` + TotalFiles int `json:"total_files"` + TotalSize int64 `json:"total_size"` + TotalSizeHuman string `json:"total_size_human"` + ExcludePatterns []string `json:"exclude_patterns"` + IncludePatterns []string `json:"include_patterns"` + MaxFileSize int64 `json:"max_file_size"` +} + +type JSONNode struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Size int64 `json:"size,omitempty"` + Children []*JSONNode `json:"children,omitempty"` +} + +type JSONFile struct { + Path string `json:"path"` + Size int64 `json:"size"` + Type string `json:"type"` + Content string `json:"content"` +} + +type JSONGitInfo struct { + RepoURL string `json:"repo_url,omitempty"` + Branch string `json:"branch,omitempty"` + Commit string `json:"commit,omitempty"` + User string `json:"user,omitempty"` + RepoName string `json:"repo_name,omitempty"` +} + +func (r *Result) FormatJSON(opts IngestionOptions) ([]byte, error) { + output := JSONOutput{ + Summary: JSONSummary{ + Source: opts.Source, + TotalFiles: r.TotalFiles, + TotalSize: r.TotalSize, + TotalSizeHuman: formatBytes(r.TotalSize), + ExcludePatterns: opts.ExcludePatterns, + IncludePatterns: opts.IncludePatterns, + MaxFileSize: opts.MaxFileSize, + }, + Tree: buildJSONTree(r.RootNode), + Files: gatherJSONFiles(r.RootNode), + } + + if r.GitInfo != nil { + output.GitInfo = &JSONGitInfo{ + RepoURL: r.GitInfo.RepoURL, + Branch: r.GitInfo.Branch, + Commit: r.GitInfo.Commit, + User: r.GitInfo.User, + RepoName: r.GitInfo.RepoName, + } + } + + return json.MarshalIndent(output, "", " ") +} + +func buildJSONTree(node *FileNode) []*JSONNode { + if node == nil { + return nil + } + + if node.Type == NodeTypeDir && node.Children != nil { + result := make([]*JSONNode, 0, len(node.Children)) + for _, child := range node.Children { + result = append(result, fileNodeToJSON(child)) + } + return result + } + + return []*JSONNode{fileNodeToJSON(node)} +} + +func fileNodeToJSON(node *FileNode) *JSONNode { + jn := &JSONNode{ + Name: node.Name, + Path: filepath.ToSlash(node.Path), + Type: string(node.Type), + Size: node.Size, + } + + if node.Type == NodeTypeDir && node.Children != nil { + jn.Children = make([]*JSONNode, 0, len(node.Children)) + for _, child := range node.Children { + jn.Children = append(jn.Children, fileNodeToJSON(child)) + } + } + + return jn +} + +func gatherJSONFiles(node *FileNode) []JSONFile { + var files []JSONFile + gatherJSONFilesRecursive(node, &files) + return files +} + +func gatherJSONFilesRecursive(node *FileNode, files *[]JSONFile) { + if node.Type == NodeTypeFile { + f := JSONFile{ + Path: filepath.ToSlash(node.Path), + Size: node.Size, + Type: string(node.Type), + Content: node.Content, // always include, even if empty + } + *files = append(*files, f) + } else if node.Type == NodeTypeNotText || node.Type == NodeTypeTooLarge { + *files = append(*files, JSONFile{ + Path: filepath.ToSlash(node.Path), + Size: node.Size, + Type: string(node.Type), + }) + } + + if node.Type == NodeTypeDir { + for _, child := range node.Children { + gatherJSONFilesRecursive(child, files) + } + } +} diff --git a/internal/digest/json_test.go b/internal/digest/json_test.go new file mode 100644 index 0000000..8b1f421 --- /dev/null +++ b/internal/digest/json_test.go @@ -0,0 +1,386 @@ +package digest + +import ( + "encoding/json" + "testing" + + "github.com/ga1az/pathdigest/internal/gitutil" +) + +func TestFormatJSON_BasicStructure(t *testing.T) { + root := &FileNode{ + Name: "project", + Path: ".", + Type: NodeTypeDir, + Children: []*FileNode{ + { + Name: "main.go", + Path: "main.go", + Type: NodeTypeFile, + Size: 100, + Content: "package main\n", + }, + { + Name: "internal", + Path: "internal", + Type: NodeTypeDir, + Children: []*FileNode{ + { + Name: "app.go", + Path: "internal/app.go", + Type: NodeTypeFile, + Size: 200, + Content: "package internal\n", + }, + }, + }, + }, + } + + result := &Result{ + RootNode: root, + TotalFiles: 2, + TotalSize: 300, + } + + opts := IngestionOptions{ + Source: "/tmp/project", + ExcludePatterns: []string{".git/"}, + IncludePatterns: []string{}, + MaxFileSize: 10 * 1024 * 1024, + } + + data, err := result.FormatJSON(opts) + if err != nil { + t.Fatalf("FormatJSON returned error: %v", err) + } + + var output JSONOutput + if err := json.Unmarshal(data, &output); err != nil { + t.Fatalf("Failed to unmarshal JSON output: %v\nJSON: %s", err, string(data)) + } + + // Verify summary + if output.Summary.Source != "/tmp/project" { + t.Errorf("Summary.Source = %q, want %q", output.Summary.Source, "/tmp/project") + } + if output.Summary.TotalFiles != 2 { + t.Errorf("Summary.TotalFiles = %d, want 2", output.Summary.TotalFiles) + } + if output.Summary.TotalSize != 300 { + t.Errorf("Summary.TotalSize = %d, want 300", output.Summary.TotalSize) + } + if output.Summary.TotalSizeHuman == "" { + t.Error("Summary.TotalSizeHuman is empty") + } + + // Verify arrays are always present (no omitempty) + if output.Summary.ExcludePatterns == nil { + t.Error("Summary.ExcludePatterns is nil, should be empty array") + } + if output.Summary.IncludePatterns == nil { + t.Error("Summary.IncludePatterns is nil, should be empty array") + } + + // Verify tree + if len(output.Tree) == 0 { + t.Fatal("Tree is empty") + } + + // Verify files + if len(output.Files) != 2 { + t.Fatalf("Files length = %d, want 2", len(output.Files)) + } + + // Verify file content is always present (even if empty string) + for _, f := range output.Files { + if f.Path == "" { + t.Error("File has empty path") + } + if f.Type != "file" { + t.Errorf("File %q has type %q, want 'file'", f.Path, f.Type) + } + // Content field must exist in the JSON — we verify by re-marshaling + } +} + +func TestFormatJSON_ContentAlwaysPresent(t *testing.T) { + root := &FileNode{ + Name: "project", + Path: ".", + Type: NodeTypeDir, + Children: []*FileNode{ + { + Name: "empty.txt", + Path: "empty.txt", + Type: NodeTypeFile, + Size: 0, + Content: "", // empty file + }, + { + Name: "hello.go", + Path: "hello.go", + Type: NodeTypeFile, + Size: 50, + Content: "package main\n", + }, + }, + } + + result := &Result{ + RootNode: root, + TotalFiles: 2, + TotalSize: 50, + } + + opts := IngestionOptions{ + Source: ".", + ExcludePatterns: []string{}, + IncludePatterns: []string{}, + } + + data, err := result.FormatJSON(opts) + if err != nil { + t.Fatalf("FormatJSON returned error: %v", err) + } + + // Verify that "content" key appears for both files in raw JSON + raw := string(data) + + // Count occurrences of "content": even empty files must have it + // We check that both file entries have a "content" field + var output JSONOutput + json.Unmarshal(data, &output) + + for _, f := range output.Files { + if f.Path == "empty.txt" && f.Content != "" { + t.Errorf("empty.txt Content = %q, want empty string", f.Content) + } + if f.Path == "hello.go" && f.Content != "package main\n" { + t.Errorf("hello.go Content = %q, want %q", f.Content, "package main\n") + } + } + + // Verify raw JSON contains "content" key for empty file + if !containsN(raw, `"content"`, 2) { + t.Errorf("JSON does not contain 'content' key for all files.\nJSON: %s", raw) + } +} + +func TestFormatJSON_NonTextAndTooLarge(t *testing.T) { + root := &FileNode{ + Name: "project", + Path: ".", + Type: NodeTypeDir, + Children: []*FileNode{ + { + Name: "image.png", + Path: "image.png", + Type: NodeTypeNotText, + Size: 5000, + }, + { + Name: "big.log", + Path: "big.log", + Type: NodeTypeTooLarge, + Size: 999999999, + }, + }, + } + + result := &Result{ + RootNode: root, + TotalFiles: 2, + TotalSize: 5000 + 999999999, + } + + opts := IngestionOptions{Source: "."} + + data, err := result.FormatJSON(opts) + if err != nil { + t.Fatalf("FormatJSON returned error: %v", err) + } + + var output JSONOutput + json.Unmarshal(data, &output) + + if len(output.Files) != 2 { + t.Fatalf("Files length = %d, want 2", len(output.Files)) + } + + for _, f := range output.Files { + if f.Type != "non-text" && f.Type != "too-large" { + t.Errorf("File %q has unexpected type %q", f.Path, f.Type) + } + // Non-text and too-large files should NOT have content field in JSON + // They are not NodeTypeFile, so they are excluded from content gathering + } +} + +func TestFormatJSON_GitInfo(t *testing.T) { + root := &FileNode{ + Name: "repo", + Path: ".", + Type: NodeTypeDir, + Content: "", + Children: []*FileNode{ + { + Name: "readme.md", + Path: "readme.md", + Type: NodeTypeFile, + Size: 42, + Content: "# Hello", + }, + }, + } + + result := &Result{ + RootNode: root, + TotalFiles: 1, + TotalSize: 42, + GitInfo: &gitutil.GitURLParts{ + RepoURL: "https://github.com/user/repo.git", + Host: "github.com", + User: "user", + RepoName: "repo", + Branch: "main", + }, + } + + opts := IngestionOptions{Source: "user/repo"} + + data, err := result.FormatJSON(opts) + if err != nil { + t.Fatalf("FormatJSON returned error: %v", err) + } + + var output JSONOutput + json.Unmarshal(data, &output) + + if output.GitInfo == nil { + t.Fatal("GitInfo is nil, expected non-nil") + } + if output.GitInfo.RepoURL != "https://github.com/user/repo.git" { + t.Errorf("GitInfo.RepoURL = %q, want %q", output.GitInfo.RepoURL, "https://github.com/user/repo.git") + } + if output.GitInfo.Branch != "main" { + t.Errorf("GitInfo.Branch = %q, want %q", output.GitInfo.Branch, "main") + } + if output.GitInfo.User != "user" { + t.Errorf("GitInfo.User = %q, want %q", output.GitInfo.User, "user") + } + if output.GitInfo.RepoName != "repo" { + t.Errorf("GitInfo.RepoName = %q, want %q", output.GitInfo.RepoName, "repo") + } +} + +func TestFormatJSON_NoGitInfo(t *testing.T) { + root := &FileNode{ + Name: "file.txt", + Path: ".", + Type: NodeTypeFile, + Size: 10, + Content: "hello", + } + + result := &Result{ + RootNode: root, + TotalFiles: 1, + TotalSize: 10, + GitInfo: nil, + } + + opts := IngestionOptions{Source: "."} + + data, err := result.FormatJSON(opts) + if err != nil { + t.Fatalf("FormatJSON returned error: %v", err) + } + + raw := string(data) + if containsN(raw, `"git_info"`, 1) { + t.Errorf("JSON should not contain git_info when GitInfo is nil.\nJSON: %s", raw) + } +} + +func TestFormatJSON_TreeStructure(t *testing.T) { + root := &FileNode{ + Name: "root", + Path: ".", + Type: NodeTypeDir, + Children: []*FileNode{ + { + Name: "a", + Path: "a", + Type: NodeTypeDir, + Children: []*FileNode{ + { + Name: "deep.go", + Path: "a/deep.go", + Type: NodeTypeFile, + Size: 10, + Content: "package a", + }, + }, + }, + { + Name: "b.txt", + Path: "b.txt", + Type: NodeTypeFile, + Size: 5, + Content: "hello", + }, + }, + } + + result := &Result{ + RootNode: root, + TotalFiles: 2, + TotalSize: 15, + } + + opts := IngestionOptions{Source: "."} + + data, err := result.FormatJSON(opts) + if err != nil { + t.Fatalf("FormatJSON returned error: %v", err) + } + + var output JSONOutput + json.Unmarshal(data, &output) + + if len(output.Tree) == 0 { + t.Fatal("Tree is empty") + } + + // Root should have 2 children: "a" (dir) and "b.txt" (file) + firstChild := output.Tree[0] + if firstChild.Name != "a" { + t.Errorf("First tree child name = %q, want %q", firstChild.Name, "a") + } + if firstChild.Type != "directory" { + t.Errorf("First tree child type = %q, want %q", firstChild.Type, "directory") + } + if len(firstChild.Children) != 1 { + t.Fatalf("First tree child has %d children, want 1", len(firstChild.Children)) + } + if firstChild.Children[0].Name != "deep.go" { + t.Errorf("Nested child name = %q, want %q", firstChild.Children[0].Name, "deep.go") + } + + secondChild := output.Tree[1] + if secondChild.Name != "b.txt" { + t.Errorf("Second tree child name = %q, want %q", secondChild.Name, "b.txt") + } +} + +// Helper: count occurrences of substr in string +func containsN(s, substr string, n int) bool { + count := 0 + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + count++ + } + } + return count >= n +}