Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions cmd/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
40 changes: 40 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
Expand Down Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions module/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type Flags struct {
ShowReadableSize bool
ShowVersion bool
ShowDirsOnly bool
ShowJSON bool
HideSummary bool
RespectGitIgnore bool
TreeDepth int
Expand Down
156 changes: 156 additions & 0 deletions service/json.go
Original file line number Diff line number Diff line change
@@ -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++
}
4 changes: 4 additions & 0 deletions service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading