From 70dee5938745f6f549ce5d332bbf3f8c1eae78aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=A9=E3=83=BC?= Date: Mon, 13 Apr 2026 13:37:05 -0400 Subject: [PATCH] feat: add JSON output format via --format flag Add `--format json` flag to output structured JSON instead of plain text. The JSON output includes: - `summary`: source path, total files/size, patterns, max file size - `tree`: full directory tree as nested objects with name, path, type, size - `files`: flat array of all processed files with path, size, type, and content (when available) - `git_info`: repository metadata when processing a Git URL This enables programmatic consumption of pathdigest output by tools, CI pipelines, and LLM integrations that need structured data. Usage: pathdigest ./my-project --format json pathdigest ./my-project -f json -o digest.json The default format remains "text" for backward compatibility. Made-with: Cursor --- cmd/root.go | 29 +++++++-- internal/digest/json.go | 140 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 internal/digest/json.go diff --git a/cmd/root.go b/cmd/root.go index 26145a5..06ccb59 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{ @@ -84,7 +85,19 @@ You can specify a local path or a repository URL as the source.`, os.Exit(1) } - ingestResult.FormatOutput(opts) + var outputContent string + + 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) + } + outputContent = string(jsonBytes) + } else { + ingestResult.FormatOutput(opts) + outputContent = ingestResult.TreeStructure + "\n" + ingestResult.FileContents + } if opts.OutputFile != "" && opts.OutputFile != "-" { outputDir := filepath.Dir(opts.OutputFile) @@ -95,20 +108,21 @@ You can specify a local path or a repository URL as the source.`, } } - fileContentToWrite := ingestResult.TreeStructure + "\n" + ingestResult.FileContents - err = os.WriteFile(opts.OutputFile, []byte(fileContentToWrite), 0644) + err = os.WriteFile(opts.OutputFile, []byte(outputContent), 0644) if 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.Println(outputContent) } - fmt.Fprintln(os.Stderr, "\n--- Summary ---") - fmt.Fprint(os.Stderr, ingestResult.Summary) + if outputFormat != "json" { + ingestResult.FormatOutput(opts) + fmt.Fprintln(os.Stderr, "\n--- Summary ---") + fmt.Fprint(os.Stderr, ingestResult.Summary) + } }, } @@ -142,4 +156,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..06cff74 --- /dev/null +++ b/internal/digest/json.go @@ -0,0 +1,140 @@ +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,omitempty"` + IncludePatterns []string `json:"include_patterns,omitempty"` + MaxFileSize int64 `json:"max_file_size,omitempty"` +} + +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,omitempty"` +} + +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), + } + if node.Content != "" { + f.Content = node.Content + } + *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) + } + } +}