diff --git a/README.md b/README.md index 0b5a4ab..ee94a25 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ treels -t # tree view treels -t --depth 2 # tree view, limited to two levels treels -t --gitignore # tree view, excluding .gitignore matches treels -t --dirs-only # show directory structure only +treels --json # machine-readable output treels --no-summary # hide the final count line ``` @@ -88,6 +89,7 @@ $ treels -t --depth 2 --no-icons service - `--gitignore` support to skip generated files, dependencies, logs, and build output. - `--depth N` to keep tree output readable in large repositories. - `--dirs-only` to inspect folder structure without file-level noise. +- `--json` for scripts and automation. - `--readable` file sizes. - `--all` support for hidden files. - `--no-icons` fallback for terminals without icon fonts. @@ -158,6 +160,13 @@ Show directories only: treels --tree --dirs-only ``` +Output JSON: + +```bash +treels --json +treels --tree --json --depth 2 +``` + Show readable sizes: ```bash @@ -185,6 +194,7 @@ treels --no-summary | `--dirs-only` | Show only directories. | | `--depth N` | Limit tree recursion depth. | | `--gitignore` | Respect `.gitignore` rules from the target directory. | +| `--json` | Output machine-readable JSON. | | `--no-icons` | Disable file and folder icons. | | `--no-summary` | Hide the final file and directory count. | | `-r`, `--readable` | Show human-readable file and directory sizes. | diff --git a/cmd/flag.go b/cmd/flag.go index 870f763..e9122a6 100644 --- a/cmd/flag.go +++ b/cmd/flag.go @@ -11,6 +11,7 @@ func FlagDefinition(cmd *cobra.Command, flags *module.Flags) { cmd.PersistentFlags().BoolVarP(&flags.ShowHidden, "all", "a", false, "List all files and directories") cmd.PersistentFlags().BoolVarP(&flags.ShowTreeView, "tree", "t", false, "Tree view of the directory") cmd.PersistentFlags().BoolVar(&flags.ShowDirsOnly, "dirs-only", false, "List directories only") + cmd.PersistentFlags().BoolVar(&flags.ShowJSON, "json", false, "Output machine-readable JSON") cmd.PersistentFlags().BoolVar(&flags.HideIcon, "no-icons", false, "Disable icons") cmd.PersistentFlags().BoolVar(&flags.HideSummary, "no-summary", false, "Hide the final file and directory count") cmd.PersistentFlags().BoolVar(&flags.RespectGitIgnore, "gitignore", false, "Respect .gitignore rules") diff --git a/cmd/root_test.go b/cmd/root_test.go index 6b75cdd..4e2036a 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/json" "io" "os" "path/filepath" @@ -252,6 +253,45 @@ func TestRootCmd_GitIgnoreFlag(t *testing.T) { } } +func TestRootCmd_JSONFlag(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + output := captureStdout(t, func() { + cmd := newRootCmd() + cmd.SetArgs([]string{"--json", dir}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v, want nil", err) + } + }) + + var got struct { + Tree bool `json:"tree"` + Summary struct { + Files int `json:"files"` + } `json:"summary"` + Entries []struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"entries"` + } + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v, output = %q", err, output) + } + if got.Tree { + t.Fatal("json tree = true, want false") + } + if got.Summary.Files != 1 { + t.Fatalf("json summary files = %d, want 1", got.Summary.Files) + } + if len(got.Entries) != 1 || got.Entries[0].Name != "main.go" || got.Entries[0].Type != "file" { + t.Fatalf("json entries = %+v, want main.go file", got.Entries) + } +} + func captureStdout(t *testing.T, run func()) string { t.Helper() diff --git a/module/types.go b/module/types.go index 6ac8bf1..6a40d7f 100644 --- a/module/types.go +++ b/module/types.go @@ -9,6 +9,7 @@ type Flags struct { ShowReadableSize bool ShowVersion bool ShowDirsOnly bool + ShowJSON bool HideSummary bool RespectGitIgnore bool TreeDepth int diff --git a/service/json.go b/service/json.go new file mode 100644 index 0000000..0c136aa --- /dev/null +++ b/service/json.go @@ -0,0 +1,156 @@ +package service + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +// jsonOutput represents the full machine-readable output. +type jsonOutput struct { + Root string `json:"root"` + Tree bool `json:"tree"` + Entries []jsonEntry `json:"entries"` + Summary jsonSummary `json:"summary"` +} + +// jsonEntry represents one file-system entry in JSON output. +type jsonEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Size int64 `json:"size"` + Children []jsonEntry `json:"children,omitempty"` +} + +// jsonSummary represents the filtered file and directory counts. +type jsonSummary struct { + Directories int `json:"directories"` + Files int `json:"files"` +} + +// printJSONDirectory prints directory contents as JSON. +func printJSONDirectory(options directoryOptions, output io.Writer) error { + var ( + entries []jsonEntry + summary jsonSummary + err error + ) + + if options.Flags.ShowTreeView { + entries, summary, err = collectJSONTreeEntries(options, 0) + } else { + entries, summary, err = collectJSONFlatEntries(options) + } + if err != nil { + return err + } + + result := jsonOutput{ + Root: options.Directory, + Tree: options.Flags.ShowTreeView, + Entries: entries, + Summary: summary, + } + + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + return encoder.Encode(result) +} + +// collectJSONFlatEntries returns visible direct children for flat JSON output. +func collectJSONFlatEntries(options directoryOptions) (entries []jsonEntry, summary jsonSummary, err error) { + files, d, err := readDirectory(options.Directory) + if err != nil { + return nil, jsonSummary{}, fmt.Errorf("read directory %q: %w", options.Directory, err) + } + defer func() { + closeErr := closeDirectory(d) + if err == nil && closeErr != nil { + err = closeErr + } + }() + + sortSlice(files) + for _, file := range files { + if !shouldShowFile(file, options) { + continue + } + + entries = append(entries, newJSONEntry(options.Directory, file)) + addJSONSummaryCount(&summary, file) + } + + return entries, summary, nil +} + +// collectJSONTreeEntries returns visible children recursively for tree JSON output. +func collectJSONTreeEntries(options directoryOptions, depth int) (entries []jsonEntry, summary jsonSummary, err error) { + if reachedMaxDepth(options.Flags, depth) { + return nil, jsonSummary{}, nil + } + + files, d, err := readDirectory(options.Directory) + if err != nil { + return nil, jsonSummary{}, fmt.Errorf("read directory %q: %w", options.Directory, err) + } + defer func() { + closeErr := closeDirectory(d) + if err == nil && closeErr != nil { + err = closeErr + } + }() + + sortSlice(files) + for _, file := range files { + if !shouldShowFile(file, options) { + continue + } + + entry := newJSONEntry(options.Directory, file) + addJSONSummaryCount(&summary, file) + + if file.IsDir() { + childOptions := options + childOptions.Directory = filepath.Join(options.Directory, file.Name()) + + children, childSummary, err := collectJSONTreeEntries(childOptions, depth+1) + if err != nil { + return nil, jsonSummary{}, err + } + entry.Children = children + summary.Directories += childSummary.Directories + summary.Files += childSummary.Files + } + + entries = append(entries, entry) + } + + return entries, summary, nil +} + +// newJSONEntry creates a JSON entry from file metadata. +func newJSONEntry(parent string, file os.FileInfo) jsonEntry { + entryType := "file" + if file.IsDir() { + entryType = "directory" + } + + return jsonEntry{ + Name: file.Name(), + Path: filepath.Join(parent, file.Name()), + Type: entryType, + Size: file.Size(), + } +} + +// addJSONSummaryCount increments summary counts for a file-system entry. +func addJSONSummaryCount(summary *jsonSummary, file os.FileInfo) { + if file.IsDir() { + summary.Directories++ + return + } + summary.Files++ +} diff --git a/service/service.go b/service/service.go index 805cbd2..47ba3e5 100644 --- a/service/service.go +++ b/service/service.go @@ -47,6 +47,10 @@ func dispatcher(options module.Options, output io.Writer) error { traversalOptions.gitIgnore = gitIgnore } + if options.Flags.ShowJSON { + return printJSONDirectory(traversalOptions, output) + } + var fileCount, dirCount int if _, err := fmt.Fprintln(output, dot); err != nil { return err diff --git a/service/service_test.go b/service/service_test.go index 1ca35f5..eb04331 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "encoding/json" "os" "path/filepath" "strings" @@ -894,6 +895,192 @@ func TestReadDirectory_FilePath(t *testing.T) { } } +func TestDispatcher_JSONFlatDirectory(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") + mustMkdir(t, filepath.Join(dir, "service")) + mustWriteFile(t, filepath.Join(dir, ".hidden"), "hidden") + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ShowJSON: true}, + }, &output) + if err != nil { + t.Fatalf("dispatcher() error = %v, want nil", err) + } + + var got jsonOutput + if err := json.Unmarshal(output.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v, output = %q", err, output.String()) + } + + if got.Root != dir { + t.Fatalf("json root = %q, want %q", got.Root, dir) + } + if got.Tree { + t.Fatal("json tree = true, want false") + } + if got.Summary.Directories != 1 || got.Summary.Files != 1 { + t.Fatalf("json summary = %+v, want 1 directory and 1 file", got.Summary) + } + if len(got.Entries) != 2 { + t.Fatalf("json entries length = %d, want 2", len(got.Entries)) + } + if got.Entries[0].Name != "main.go" || got.Entries[0].Type != "file" || got.Entries[0].Size != 12 { + t.Fatalf("first json entry = %+v, want main.go file", got.Entries[0]) + } + if got.Entries[1].Name != "service" || got.Entries[1].Type != "directory" { + t.Fatalf("second json entry = %+v, want service directory", got.Entries[1]) + } + if strings.Contains(output.String(), "directories,") || strings.Contains(output.String(), "├──") { + t.Fatalf("json output = %q, want no human formatted output", output.String()) + } +} + +func TestDispatcher_JSONTreeDirectoryWithDepth(t *testing.T) { + dir := t.TempDir() + mustMkdir(t, filepath.Join(dir, "cmd")) + mustMkdir(t, filepath.Join(dir, "cmd", "internal")) + mustWriteFile(t, filepath.Join(dir, "cmd", "root.go"), "package cmd") + mustWriteFile(t, filepath.Join(dir, "README.md"), "readme") + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ + ShowJSON: true, + ShowTreeView: true, + TreeDepth: 1, + LimitTreeDepth: true, + }, + }, &output) + if err != nil { + t.Fatalf("dispatcher() error = %v, want nil", err) + } + + var got jsonOutput + if err := json.Unmarshal(output.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if !got.Tree { + t.Fatal("json tree = false, want true") + } + if got.Summary.Directories != 1 || got.Summary.Files != 1 { + t.Fatalf("json summary = %+v, want depth-limited 1 directory and 1 file", got.Summary) + } + if len(got.Entries) != 2 { + t.Fatalf("json entries length = %d, want 2", len(got.Entries)) + } + cmdEntry := got.Entries[1] + if cmdEntry.Name != "cmd" { + t.Fatalf("second json entry = %+v, want cmd directory", cmdEntry) + } + if len(cmdEntry.Children) != 0 { + t.Fatalf("cmd children = %+v, want none at depth 1", cmdEntry.Children) + } +} + +func TestDispatcher_JSONWithGitIgnoreAndDirsOnly(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, ".gitignore"), "ignored/\n") + mustMkdir(t, filepath.Join(dir, "ignored")) + mustMkdir(t, filepath.Join(dir, "visible")) + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ + ShowJSON: true, + ShowTreeView: true, + ShowDirsOnly: true, + RespectGitIgnore: true, + }, + }, &output) + if err != nil { + t.Fatalf("dispatcher() error = %v, want nil", err) + } + + var got jsonOutput + if err := json.Unmarshal(output.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got.Summary.Directories != 1 || got.Summary.Files != 0 { + t.Fatalf("json summary = %+v, want 1 directory and 0 files", got.Summary) + } + if len(got.Entries) != 1 || got.Entries[0].Name != "visible" { + t.Fatalf("json entries = %+v, want only visible directory", got.Entries) + } +} + +func TestPrintJSONDirectoryErrors(t *testing.T) { + path := filepath.Join(t.TempDir(), "regular.txt") + mustWriteFile(t, path, "content") + + var output bytes.Buffer + err := printJSONDirectory(directoryOptions{Options: module.Options{Directory: path}}, &output) + if err == nil { + t.Fatal("printJSONDirectory() error = nil, want read error") + } + + _, _, err = collectJSONFlatEntries(directoryOptions{Options: module.Options{Directory: path}}) + if err == nil { + t.Fatal("collectJSONFlatEntries() error = nil, want read error") + } + + _, _, err = collectJSONTreeEntries(directoryOptions{Options: module.Options{Directory: path}}, 0) + if err == nil { + t.Fatal("collectJSONTreeEntries() error = nil, want read error") + } + + entries, summary, err := collectJSONTreeEntries(directoryOptions{ + Options: module.Options{ + Directory: path, + Flags: module.Flags{ + TreeDepth: 0, + LimitTreeDepth: true, + }, + }, + }, 0) + if err != nil { + t.Fatalf("collectJSONTreeEntries() error = %v, want nil at max depth", err) + } + if len(entries) != 0 || summary.Directories != 0 || summary.Files != 0 { + t.Fatalf("collectJSONTreeEntries() = entries %+v summary %+v, want empty", entries, summary) + } + + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") + err = printJSONDirectory(directoryOptions{Options: module.Options{Directory: dir}}, &failingWriter{}) + if err == nil { + t.Fatal("printJSONDirectory() error = nil, want write error") + } +} + +func TestJSONEntryAndSummaryHelpers(t *testing.T) { + file := fakeFileInfo{name: "main.go", size: 12} + fileEntry := newJSONEntry("/tmp/project", file) + if fileEntry.Name != "main.go" || fileEntry.Type != "file" || fileEntry.Size != 12 { + t.Fatalf("newJSONEntry() = %+v, want file entry", fileEntry) + } + + dir := fakeFileInfo{name: "cmd", isDir: true} + dirEntry := newJSONEntry("/tmp/project", dir) + if dirEntry.Name != "cmd" || dirEntry.Type != "directory" { + t.Fatalf("newJSONEntry() = %+v, want directory entry", dirEntry) + } + + var summary jsonSummary + addJSONSummaryCount(&summary, file) + addJSONSummaryCount(&summary, dir) + if summary.Files != 1 || summary.Directories != 1 { + t.Fatalf("summary = %+v, want 1 file and 1 directory", summary) + } +} + func TestIsHidden(t *testing.T) { tests := []struct { name string