From 4beb6b3bd2788d57b3d831896c84612d2cfa71de Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 22 Jan 2026 09:48:37 -0800 Subject: [PATCH] feat(ls,tree): implement ls and tree commands Add commands for listing and displaying archive contents: - `blob ls` lists files/directories with optional long format, human-readable sizes, and digest display - `blob tree` shows hierarchical directory structure with optional depth limit and dirs-first sorting - Both commands support text and JSON output formats - Create internal/archive package with inspection, formatting, and tree rendering utilities Co-Authored-By: Claude Opus 4.5 --- cmd/ls.go | 220 ++++++++++++++++++++++++++- cmd/ls_test.go | 248 ++++++++++++++++++++++++++++++ cmd/tree.go | 153 ++++++++++++++++++- cmd/tree_test.go | 253 +++++++++++++++++++++++++++++++ internal/archive/archive.go | 216 ++++++++++++++++++++++++++ internal/archive/archive_test.go | 106 +++++++++++++ internal/archive/format.go | 84 ++++++++++ internal/archive/format_test.go | 97 ++++++++++++ internal/archive/tree.go | 90 +++++++++++ internal/archive/tree_test.go | 209 +++++++++++++++++++++++++ 10 files changed, 1669 insertions(+), 7 deletions(-) create mode 100644 cmd/ls_test.go create mode 100644 cmd/tree_test.go create mode 100644 internal/archive/archive.go create mode 100644 internal/archive/archive_test.go create mode 100644 internal/archive/format.go create mode 100644 internal/archive/format_test.go create mode 100644 internal/archive/tree.go create mode 100644 internal/archive/tree_test.go diff --git a/cmd/ls.go b/cmd/ls.go index 5502c1e..a229e9b 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -1,7 +1,18 @@ package cmd import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "time" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/meigma/blob-cli/internal/archive" + internalcfg "github.com/meigma/blob-cli/internal/config" ) var lsCmd = &cobra.Command{ @@ -15,13 +26,214 @@ is provided, lists the root directory.`, blob ls -lh ghcr.io/acme/configs:v1.0.0 /etc blob ls --digest ghcr.io/acme/configs:v1.0.0`, Args: cobra.RangeArgs(1, 2), - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, + RunE: runLs, } func init() { - lsCmd.Flags().BoolP("long", "l", false, "long format (permissions, size, hash)") + // Pre-define help without -h shorthand so we can use -h for human + lsCmd.Flags().Bool("help", false, "help for ls") lsCmd.Flags().BoolP("human", "h", false, "human-readable sizes (use with -l)") + lsCmd.Flags().BoolP("long", "l", false, "long format (permissions, size, hash)") lsCmd.Flags().Bool("digest", false, "show file digests") } + +// lsFlags holds the parsed command flags. +type lsFlags struct { + long bool + human bool + digest bool +} + +// lsResult contains the ls output data for JSON format. +type lsResult struct { + Ref string `json:"ref"` + Path string `json:"path"` + Entries []lsEntryJSON `json:"entries"` +} + +// lsEntryJSON represents a single entry in JSON output. +type lsEntryJSON struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Mode string `json:"mode,omitempty"` + Size uint64 `json:"size,omitempty"` + SizeHuman string `json:"size_human,omitempty"` + Digest string `json:"digest,omitempty"` + ModTime string `json:"mod_time,omitempty"` +} + +func runLs(cmd *cobra.Command, args []string) error { + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + ref := cfg.ResolveAlias(args[0]) + dirPath := "/" + if len(args) > 1 { + dirPath = args[1] + } + + flags, err := parseLsFlags(cmd) + if err != nil { + return err + } + + result, err := archive.Inspect(cmd.Context(), ref) + if err != nil { + return err + } + + entries, err := archive.ListDir(result.Index(), dirPath) + if err != nil { + return err + } + + if cfg.Quiet { + return nil + } + + if viper.GetString("output") == internalcfg.OutputJSON { + return lsJSON(ref, dirPath, entries, flags) + } + return lsText(entries, flags) +} + +func parseLsFlags(cmd *cobra.Command) (lsFlags, error) { + var flags lsFlags + var err error + + flags.long, err = cmd.Flags().GetBool("long") + if err != nil { + return flags, fmt.Errorf("reading long flag: %w", err) + } + + flags.human, err = cmd.Flags().GetBool("human") + if err != nil { + return flags, fmt.Errorf("reading human flag: %w", err) + } + + flags.digest, err = cmd.Flags().GetBool("digest") + if err != nil { + return flags, fmt.Errorf("reading digest flag: %w", err) + } + + return flags, nil +} + +func lsJSON(ref, dirPath string, entries []*archive.DirEntry, flags lsFlags) error { + result := lsResult{ + Ref: ref, + Path: dirPath, + Entries: make([]lsEntryJSON, 0, len(entries)), + } + + for _, entry := range entries { + jsonEntry := lsEntryJSON{ + Name: entry.Name, + Path: entry.Path, + IsDir: entry.IsDir, + } + + if flags.long { + jsonEntry.Mode = archive.FormatMode(entry.Mode, entry.IsDir) + if !entry.IsDir { + jsonEntry.Size = entry.Size + jsonEntry.ModTime = entry.ModTime.Format(time.RFC3339) + if flags.human { + jsonEntry.SizeHuman = archive.FormatSize(entry.Size) + } + } + } + + if flags.digest && !entry.IsDir && len(entry.Hash) > 0 { + jsonEntry.Digest = archive.FormatDigest(entry.Hash) + } + + result.Entries = append(result.Entries, jsonEntry) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func lsText(entries []*archive.DirEntry, flags lsFlags) error { + if len(entries) == 0 { + return nil + } + + maxSizeWidth := calculateMaxSizeWidth(entries, flags) + + for _, entry := range entries { + printLsEntry(entry, flags, maxSizeWidth) + } + + return nil +} + +func calculateMaxSizeWidth(entries []*archive.DirEntry, flags lsFlags) int { + if !flags.long { + return 0 + } + + var maxWidth int + for _, entry := range entries { + sizeStr := formatEntrySize(entry.Size, flags.human) + if len(sizeStr) > maxWidth { + maxWidth = len(sizeStr) + } + } + return maxWidth +} + +func formatEntrySize(size uint64, human bool) string { + if human { + return archive.FormatSize(size) + } + return strconv.FormatUint(size, 10) +} + +func printLsEntry(entry *archive.DirEntry, flags lsFlags, maxSizeWidth int) { + name := entry.Name + if entry.IsDir { + name += "/" + } + + switch { + case flags.long && flags.digest: + printLongWithDigest(entry, name, maxSizeWidth, flags.human) + case flags.long: + printLong(entry, name, maxSizeWidth, flags.human) + case flags.digest: + printDigestOnly(entry, name) + default: + fmt.Println(name) + } +} + +func printLongWithDigest(entry *archive.DirEntry, name string, maxSizeWidth int, human bool) { + mode := archive.FormatMode(entry.Mode, entry.IsDir) + sizeStr := formatEntrySize(entry.Size, human) + digest := formatEntryDigest(entry) + fmt.Printf("%s %*s %-20s %s\n", mode, maxSizeWidth, sizeStr, digest, name) +} + +func printLong(entry *archive.DirEntry, name string, maxSizeWidth int, human bool) { + mode := archive.FormatMode(entry.Mode, entry.IsDir) + sizeStr := formatEntrySize(entry.Size, human) + fmt.Printf("%s %*s %s\n", mode, maxSizeWidth, sizeStr, name) +} + +func printDigestOnly(entry *archive.DirEntry, name string) { + digest := formatEntryDigest(entry) + fmt.Printf("%-20s %s\n", digest, name) +} + +func formatEntryDigest(entry *archive.DirEntry) string { + if entry.IsDir || len(entry.Hash) == 0 { + return "" + } + return archive.FormatDigest(entry.Hash) +} diff --git a/cmd/ls_test.go b/cmd/ls_test.go new file mode 100644 index 0000000..de758ef --- /dev/null +++ b/cmd/ls_test.go @@ -0,0 +1,248 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "io/fs" + "os" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/blob-cli/internal/archive" +) + +func TestLsCmd_NilConfig(t *testing.T) { + viper.Reset() + + ctx := context.Background() + + lsCmd.SetContext(ctx) + err := lsCmd.RunE(lsCmd, []string{"ghcr.io/test:v1"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "configuration not loaded") +} + +func TestLsText_Empty(t *testing.T) { + var entries []*archive.DirEntry + flags := lsFlags{} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := lsText(entries, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + assert.Empty(t, buf.String()) +} + +func TestLsText_SimpleList(t *testing.T) { + entries := []*archive.DirEntry{ + {Name: "config", IsDir: true}, + {Name: "README.md", IsDir: false}, + } + flags := lsFlags{} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := lsText(entries, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + assert.Equal(t, "config/\nREADME.md\n", buf.String()) +} + +func TestLsText_Long(t *testing.T) { + entries := []*archive.DirEntry{ + {Name: "config", IsDir: true, Mode: fs.ModeDir | 0o755}, + {Name: "file.txt", IsDir: false, Mode: 0o644, Size: 1024}, + } + flags := lsFlags{long: true} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := lsText(entries, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "drwxr-xr-x") + assert.Contains(t, output, "-rw-r--r--") + assert.Contains(t, output, "1024") + assert.Contains(t, output, "config/") + assert.Contains(t, output, "file.txt") +} + +func TestLsText_LongHuman(t *testing.T) { + entries := []*archive.DirEntry{ + {Name: "large.bin", IsDir: false, Mode: 0o644, Size: 1536 * 1024}, // 1.5M + } + flags := lsFlags{long: true, human: true} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := lsText(entries, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "1.5M") +} + +func TestLsText_Digest(t *testing.T) { + hash := []byte{0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a} + entries := []*archive.DirEntry{ + {Name: "file.txt", IsDir: false, Hash: hash}, + } + flags := lsFlags{digest: true} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := lsText(entries, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "sha256:abcdef123456") +} + +func TestLsJSON(t *testing.T) { + modTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + hash := []byte{0xab, 0xcd, 0xef, 0x12, 0x34, 0x56} + entries := []*archive.DirEntry{ + {Name: "config", Path: "config", IsDir: true, Mode: fs.ModeDir | 0o755}, + {Name: "file.txt", Path: "file.txt", IsDir: false, Mode: 0o644, Size: 1024, ModTime: modTime, Hash: hash}, + } + flags := lsFlags{long: true, human: true, digest: true} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := lsJSON("ghcr.io/test:v1", "/", entries, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + var got lsResult + err = json.Unmarshal(buf.Bytes(), &got) + require.NoError(t, err) + + assert.Equal(t, "ghcr.io/test:v1", got.Ref) + assert.Equal(t, "/", got.Path) + require.Len(t, got.Entries, 2) + + // Check directory entry + assert.Equal(t, "config", got.Entries[0].Name) + assert.True(t, got.Entries[0].IsDir) + assert.Equal(t, "drwxr-xr-x", got.Entries[0].Mode) + + // Check file entry + assert.Equal(t, "file.txt", got.Entries[1].Name) + assert.False(t, got.Entries[1].IsDir) + assert.Equal(t, "-rw-r--r--", got.Entries[1].Mode) + assert.Equal(t, uint64(1024), got.Entries[1].Size) + assert.Equal(t, "1.0K", got.Entries[1].SizeHuman) + assert.Equal(t, "sha256:abcdef123456", got.Entries[1].Digest) +} + +func TestFormatEntrySize(t *testing.T) { + tests := []struct { + name string + size uint64 + human bool + want string + }{ + {name: "raw", size: 1024, human: false, want: "1024"}, + {name: "human", size: 1024, human: true, want: "1.0K"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatEntrySize(tt.size, tt.human) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFormatEntryDigest(t *testing.T) { + tests := []struct { + name string + entry *archive.DirEntry + want string + }{ + { + name: "directory", + entry: &archive.DirEntry{IsDir: true, Hash: []byte{0xab, 0xcd}}, + want: "", + }, + { + name: "file_no_hash", + entry: &archive.DirEntry{IsDir: false, Hash: nil}, + want: "", + }, + { + name: "file_with_hash", + entry: &archive.DirEntry{IsDir: false, Hash: []byte{0xab, 0xcd, 0xef, 0x12, 0x34, 0x56}}, + want: "sha256:abcdef123456", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatEntryDigest(tt.entry) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/tree.go b/cmd/tree.go index e8cf576..3ad4312 100644 --- a/cmd/tree.go +++ b/cmd/tree.go @@ -1,7 +1,16 @@ package cmd import ( + "encoding/json" + "errors" + "fmt" + "os" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/meigma/blob-cli/internal/archive" + internalcfg "github.com/meigma/blob-cli/internal/config" ) var treeCmd = &cobra.Command{ @@ -14,12 +23,150 @@ archive, similar to the tree command.`, Example: ` blob tree ghcr.io/acme/configs:v1.0.0 blob tree -L 2 ghcr.io/acme/configs:v1.0.0 /etc`, Args: cobra.RangeArgs(1, 2), - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, + RunE: runTree, } func init() { treeCmd.Flags().IntP("level", "L", 0, "descend only n levels deep (0 = unlimited)") treeCmd.Flags().Bool("dirsfirst", false, "list directories before files") } + +// treeFlags holds the parsed command flags. +type treeFlags struct { + level int + dirsFirst bool +} + +// treeResult contains the tree output data for JSON format. +type treeResult struct { + Ref string `json:"ref"` + Path string `json:"path"` + Root *treeNode `json:"root"` + DirCount int `json:"directory_count"` + FileCount int `json:"file_count"` +} + +// treeNode represents a single node in the JSON tree. +type treeNode struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Children []*treeNode `json:"children,omitempty"` +} + +func runTree(cmd *cobra.Command, args []string) error { + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + ref := cfg.ResolveAlias(args[0]) + dirPath := "/" + if len(args) > 1 { + dirPath = args[1] + } + + flags, err := parseTreeFlags(cmd) + if err != nil { + return err + } + + result, err := archive.Inspect(cmd.Context(), ref) + if err != nil { + return err + } + + root, err := archive.BuildTree(result.Index(), dirPath, flags.level) + if err != nil { + return err + } + + if cfg.Quiet { + return nil + } + + if viper.GetString("output") == internalcfg.OutputJSON { + return treeJSON(ref, dirPath, root, flags) + } + return treeText(root, flags) +} + +func parseTreeFlags(cmd *cobra.Command) (treeFlags, error) { + var flags treeFlags + var err error + + flags.level, err = cmd.Flags().GetInt("level") + if err != nil { + return flags, fmt.Errorf("reading level flag: %w", err) + } + + flags.dirsFirst, err = cmd.Flags().GetBool("dirsfirst") + if err != nil { + return flags, fmt.Errorf("reading dirsfirst flag: %w", err) + } + + return flags, nil +} + +func treeJSON(ref, dirPath string, root *archive.DirEntry, flags treeFlags) error { + dirs, files := archive.Counts(root) + + result := treeResult{ + Ref: ref, + Path: dirPath, + Root: convertToTreeNode(root, flags.dirsFirst), + DirCount: dirs, + FileCount: files, + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func convertToTreeNode(entry *archive.DirEntry, dirsFirst bool) *treeNode { + node := &treeNode{ + Name: entry.Name, + Path: entry.Path, + IsDir: entry.IsDir, + } + + if len(entry.Children) > 0 { + children := make([]*archive.DirEntry, len(entry.Children)) + copy(children, entry.Children) + + if dirsFirst { + archive.SortDirsFirst(children) + } + + node.Children = make([]*treeNode, 0, len(children)) + for _, child := range children { + node.Children = append(node.Children, convertToTreeNode(child, dirsFirst)) + } + } + + return node +} + +func treeText(root *archive.DirEntry, flags treeFlags) error { + printer := &archive.TreePrinter{ + DirsFirst: flags.dirsFirst, + Writer: os.Stdout, + } + + printer.Print(root) + + // Print summary line + dirs, files := archive.Counts(root) + fmt.Println() + fmt.Printf("%s, %s\n", pluralize(dirs, "directory", "directories"), pluralize(files, "file", "files")) + + return nil +} + +func pluralize(n int, singular, plural string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, singular) + } + return fmt.Sprintf("%d %s", n, plural) +} diff --git a/cmd/tree_test.go b/cmd/tree_test.go new file mode 100644 index 0000000..017193d --- /dev/null +++ b/cmd/tree_test.go @@ -0,0 +1,253 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/blob-cli/internal/archive" +) + +func TestTreeCmd_NilConfig(t *testing.T) { + viper.Reset() + + ctx := context.Background() + + treeCmd.SetContext(ctx) + err := treeCmd.RunE(treeCmd, []string{"ghcr.io/test:v1"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "configuration not loaded") +} + +func TestTreeText(t *testing.T) { + root := &archive.DirEntry{ + Name: ".", + IsDir: true, + Children: []*archive.DirEntry{ + { + Name: "config", + IsDir: true, + Children: []*archive.DirEntry{ + {Name: "app.yaml", IsDir: false}, + }, + }, + {Name: "README.md", IsDir: false}, + }, + } + flags := treeFlags{} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := treeText(root, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + output := buf.String() + + // Check tree structure + assert.Contains(t, output, "./") + assert.Contains(t, output, "config/") + assert.Contains(t, output, "app.yaml") + assert.Contains(t, output, "README.md") + + // Check summary line + assert.Contains(t, output, "1 directory") + assert.Contains(t, output, "2 files") +} + +func TestTreeText_DirsFirst(t *testing.T) { + root := &archive.DirEntry{ + Name: ".", + IsDir: true, + Children: []*archive.DirEntry{ + {Name: "README.md", IsDir: false}, + {Name: "config", IsDir: true}, + {Name: "Makefile", IsDir: false}, + }, + } + flags := treeFlags{dirsFirst: true} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := treeText(root, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + // config/ should appear before files + configIdx := bytes.Index(buf.Bytes(), []byte("config/")) + readmeIdx := bytes.Index(buf.Bytes(), []byte("README.md")) + makefileIdx := bytes.Index(buf.Bytes(), []byte("Makefile")) + + assert.Less(t, configIdx, readmeIdx) + assert.Less(t, configIdx, makefileIdx) +} + +func TestTreeText_Empty(t *testing.T) { + root := &archive.DirEntry{ + Name: ".", + IsDir: true, + } + flags := treeFlags{} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := treeText(root, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + assert.Contains(t, buf.String(), "./") + assert.Contains(t, buf.String(), "0 directories") + assert.Contains(t, buf.String(), "0 files") +} + +func TestTreeJSON(t *testing.T) { + root := &archive.DirEntry{ + Name: ".", + Path: "", + IsDir: true, + Children: []*archive.DirEntry{ + { + Name: "config", + Path: "config", + IsDir: true, + Children: []*archive.DirEntry{ + {Name: "app.yaml", Path: "config/app.yaml", IsDir: false}, + }, + }, + {Name: "README.md", Path: "README.md", IsDir: false}, + }, + } + flags := treeFlags{} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := treeJSON("ghcr.io/test:v1", "/", root, flags) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + var got treeResult + err = json.Unmarshal(buf.Bytes(), &got) + require.NoError(t, err) + + assert.Equal(t, "ghcr.io/test:v1", got.Ref) + assert.Equal(t, "/", got.Path) + assert.Equal(t, 1, got.DirCount) + assert.Equal(t, 2, got.FileCount) + + assert.NotNil(t, got.Root) + assert.Equal(t, ".", got.Root.Name) + assert.True(t, got.Root.IsDir) + require.Len(t, got.Root.Children, 2) + + // Check config directory + assert.Equal(t, "config", got.Root.Children[0].Name) + assert.True(t, got.Root.Children[0].IsDir) + require.Len(t, got.Root.Children[0].Children, 1) + assert.Equal(t, "app.yaml", got.Root.Children[0].Children[0].Name) +} + +func TestPluralize(t *testing.T) { + tests := []struct { + n int + singular string + plural string + want string + }{ + {0, "file", "files", "0 files"}, + {1, "file", "files", "1 file"}, + {2, "file", "files", "2 files"}, + {1, "directory", "directories", "1 directory"}, + {5, "directory", "directories", "5 directories"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := pluralize(tt.n, tt.singular, tt.plural) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestConvertToTreeNode(t *testing.T) { + entry := &archive.DirEntry{ + Name: "root", + Path: "/", + IsDir: true, + Children: []*archive.DirEntry{ + {Name: "file.txt", Path: "/file.txt", IsDir: false}, + {Name: "dir", Path: "/dir", IsDir: true}, + }, + } + + node := convertToTreeNode(entry, false) + + assert.Equal(t, "root", node.Name) + assert.Equal(t, "/", node.Path) + assert.True(t, node.IsDir) + require.Len(t, node.Children, 2) + assert.Equal(t, "file.txt", node.Children[0].Name) + assert.Equal(t, "dir", node.Children[1].Name) +} + +func TestConvertToTreeNode_DirsFirst(t *testing.T) { + entry := &archive.DirEntry{ + Name: "root", + Path: "/", + IsDir: true, + Children: []*archive.DirEntry{ + {Name: "file.txt", Path: "/file.txt", IsDir: false}, + {Name: "dir", Path: "/dir", IsDir: true}, + }, + } + + node := convertToTreeNode(entry, true) + + require.Len(t, node.Children, 2) + // With dirsFirst, directory should come first + assert.Equal(t, "dir", node.Children[0].Name) + assert.True(t, node.Children[0].IsDir) + assert.Equal(t, "file.txt", node.Children[1].Name) + assert.False(t, node.Children[1].IsDir) +} diff --git a/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 0000000..144413c --- /dev/null +++ b/internal/archive/archive.go @@ -0,0 +1,216 @@ +// Package archive provides utilities for inspecting and listing blob archives. +package archive + +import ( + "cmp" + "context" + "fmt" + "io/fs" + "path" + "slices" + "strings" + "time" + + "github.com/meigma/blob" +) + +// DirEntry represents a file or synthesized directory for display. +// Archives only store files; directories are synthesized from file paths. +type DirEntry struct { + Name string // Base name (not full path) + Path string // Full path in archive + IsDir bool // True for synthesized directories + Mode fs.FileMode // File mode bits + Size uint64 // Original (uncompressed) size + ModTime time.Time // Modification time + Hash []byte // SHA256 hash (files only) + + // Children holds nested entries for tree building. + // Only populated by BuildTree. + Children []*DirEntry +} + +// Inspect fetches archive metadata from a registry without downloading file data. +func Inspect(ctx context.Context, ref string) (*blob.InspectResult, error) { + client, err := blob.NewClient(blob.WithDockerConfig()) + if err != nil { + return nil, fmt.Errorf("creating client: %w", err) + } + + result, err := client.Inspect(ctx, ref) + if err != nil { + return nil, fmt.Errorf("inspecting archive %s: %w", ref, err) + } + + return result, nil +} + +// ListDir returns the immediate children of a directory path. +// If dirPath is empty or "/", lists the root directory. +// Returns entries sorted alphabetically by name. +func ListDir(index *blob.IndexView, dirPath string) ([]*DirEntry, error) { + dirPath = normalizePath(dirPath) + + // Build prefix for filtering entries + var prefix string + if dirPath == "" { + prefix = "" + } else { + prefix = dirPath + "/" + } + + seen := make(map[string]*DirEntry) + + for entry := range index.EntriesWithPrefix(prefix) { + entryPath := entry.Path() + + // Get the relative path after the prefix + relPath := strings.TrimPrefix(entryPath, prefix) + if relPath == "" { + continue + } + + // Get the first path component (immediate child) + var name string + slashIdx := strings.Index(relPath, "/") + if slashIdx == -1 { + name = relPath + } else { + name = relPath[:slashIdx] + } + + // Skip if we've already seen this name + if _, exists := seen[name]; exists { + continue + } + + if slashIdx == -1 { + // This is a file (no more path components) + hashBytes := entry.HashBytes() + hash := make([]byte, len(hashBytes)) + copy(hash, hashBytes) + + seen[name] = &DirEntry{ + Name: name, + Path: entryPath, + IsDir: false, + Mode: entry.Mode(), + Size: entry.OriginalSize(), + ModTime: entry.ModTime(), + Hash: hash, + } + } else { + // This is a directory (synthesized) + childPath := dirPath + "/" + name + if dirPath == "" { + childPath = name + } + seen[name] = &DirEntry{ + Name: name, + Path: childPath, + IsDir: true, + Mode: fs.ModeDir | 0o755, // Default directory mode + } + } + } + + // Convert to slice and sort + entries := make([]*DirEntry, 0, len(seen)) + for _, e := range seen { + entries = append(entries, e) + } + slices.SortFunc(entries, func(a, b *DirEntry) int { + return cmp.Compare(a.Name, b.Name) + }) + + return entries, nil +} + +// BuildTree builds a hierarchical tree structure rooted at dirPath. +// If maxDepth is 0, the tree depth is unlimited. +// If maxDepth is > 0, the tree is limited to that many levels. +func BuildTree(index *blob.IndexView, dirPath string, maxDepth int) (*DirEntry, error) { + dirPath = normalizePath(dirPath) + + // Create the root entry + rootName := "." + if dirPath != "" { + rootName = path.Base(dirPath) + } + root := &DirEntry{ + Name: rootName, + Path: dirPath, + IsDir: true, + Mode: fs.ModeDir | 0o755, + } + + // Build tree recursively + if err := buildTreeRecursive(index, root, dirPath, 1, maxDepth); err != nil { + return nil, err + } + + return root, nil +} + +func buildTreeRecursive(index *blob.IndexView, parent *DirEntry, dirPath string, currentDepth, maxDepth int) error { + // Check depth limit + if maxDepth > 0 && currentDepth > maxDepth { + return nil + } + + entries, err := ListDir(index, dirPath) + if err != nil { + return err + } + + for _, entry := range entries { + parent.Children = append(parent.Children, entry) + + if entry.IsDir { + if err := buildTreeRecursive(index, entry, entry.Path, currentDepth+1, maxDepth); err != nil { + return err + } + } + } + + return nil +} + +// SortDirsFirst sorts entries with directories first, then files. +// Within each group, entries are sorted alphabetically. +func SortDirsFirst(entries []*DirEntry) { + slices.SortFunc(entries, func(a, b *DirEntry) int { + // Directories come before files + if a.IsDir && !b.IsDir { + return -1 + } + if !a.IsDir && b.IsDir { + return 1 + } + // Within same type, sort alphabetically + return cmp.Compare(a.Name, b.Name) + }) +} + +// normalizePath normalizes a path for consistent handling. +// - Empty string, ".", "/" all become "" +// - Removes leading "/" and trailing "/" +// - "foo/" becomes "foo" +// - "/foo/bar/" becomes "foo/bar" +func normalizePath(p string) string { + // Handle special cases + if p == "" || p == "." || p == "/" { + return "" + } + + // Clean the path and remove leading/trailing slashes + p = path.Clean(p) + p = strings.TrimPrefix(p, "/") + p = strings.TrimSuffix(p, "/") + + if p == "." { + return "" + } + + return p +} diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go new file mode 100644 index 0000000..5e8469b --- /dev/null +++ b/internal/archive/archive_test.go @@ -0,0 +1,106 @@ +package archive + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "empty", input: "", want: ""}, + {name: "dot", input: ".", want: ""}, + {name: "root", input: "/", want: ""}, + {name: "simple", input: "foo", want: "foo"}, + {name: "leading_slash", input: "/foo", want: "foo"}, + {name: "trailing_slash", input: "foo/", want: "foo"}, + {name: "both_slashes", input: "/foo/", want: "foo"}, + {name: "nested", input: "/foo/bar", want: "foo/bar"}, + {name: "nested_trailing", input: "/foo/bar/", want: "foo/bar"}, + {name: "double_slash", input: "//foo//bar//", want: "foo/bar"}, + {name: "dot_path", input: "./foo", want: "foo"}, + {name: "dot_dot_path", input: "../foo", want: "../foo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := normalizePath(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSortDirsFirst(t *testing.T) { + t.Parallel() + + entries := []*DirEntry{ + {Name: "zebra.txt", IsDir: false}, + {Name: "alpha", IsDir: true}, + {Name: "beta.txt", IsDir: false}, + {Name: "gamma", IsDir: true}, + {Name: "apple.txt", IsDir: false}, + } + + SortDirsFirst(entries) + + // Directories should come first, alphabetically + assert.Equal(t, "alpha", entries[0].Name) + assert.True(t, entries[0].IsDir) + assert.Equal(t, "gamma", entries[1].Name) + assert.True(t, entries[1].IsDir) + + // Then files, alphabetically + assert.Equal(t, "apple.txt", entries[2].Name) + assert.False(t, entries[2].IsDir) + assert.Equal(t, "beta.txt", entries[3].Name) + assert.False(t, entries[3].IsDir) + assert.Equal(t, "zebra.txt", entries[4].Name) + assert.False(t, entries[4].IsDir) +} + +func TestSortDirsFirst_EmptySlice(t *testing.T) { + t.Parallel() + + var entries []*DirEntry + SortDirsFirst(entries) + assert.Empty(t, entries) +} + +func TestSortDirsFirst_OnlyDirs(t *testing.T) { + t.Parallel() + + entries := []*DirEntry{ + {Name: "charlie", IsDir: true}, + {Name: "alpha", IsDir: true}, + {Name: "bravo", IsDir: true}, + } + + SortDirsFirst(entries) + + assert.Equal(t, "alpha", entries[0].Name) + assert.Equal(t, "bravo", entries[1].Name) + assert.Equal(t, "charlie", entries[2].Name) +} + +func TestSortDirsFirst_OnlyFiles(t *testing.T) { + t.Parallel() + + entries := []*DirEntry{ + {Name: "charlie.txt", IsDir: false}, + {Name: "alpha.txt", IsDir: false}, + {Name: "bravo.txt", IsDir: false}, + } + + SortDirsFirst(entries) + + assert.Equal(t, "alpha.txt", entries[0].Name) + assert.Equal(t, "bravo.txt", entries[1].Name) + assert.Equal(t, "charlie.txt", entries[2].Name) +} diff --git a/internal/archive/format.go b/internal/archive/format.go new file mode 100644 index 0000000..7a58319 --- /dev/null +++ b/internal/archive/format.go @@ -0,0 +1,84 @@ +package archive + +import ( + "encoding/hex" + "fmt" + "io/fs" + "strconv" +) + +const ( + _ = iota + kb uint64 = 1 << (10 * iota) + mb + gb + tb +) + +// FormatSize returns a human-readable size string. +// Examples: "0", "512", "1.2K", "3.4M", "5.6G", "1.2T" +func FormatSize(bytes uint64) string { + switch { + case bytes >= tb: + return fmt.Sprintf("%.1fT", float64(bytes)/float64(tb)) + case bytes >= gb: + return fmt.Sprintf("%.1fG", float64(bytes)/float64(gb)) + case bytes >= mb: + return fmt.Sprintf("%.1fM", float64(bytes)/float64(mb)) + case bytes >= kb: + return fmt.Sprintf("%.1fK", float64(bytes)/float64(kb)) + default: + return strconv.FormatUint(bytes, 10) + } +} + +// FormatDigest returns a truncated SHA256 digest string. +// Returns empty string if hash is nil or empty. +// Example: "sha256:abc123def456" +func FormatDigest(hash []byte) string { + if len(hash) == 0 { + return "" + } + hexStr := hex.EncodeToString(hash) + if len(hexStr) > 12 { + hexStr = hexStr[:12] + } + return "sha256:" + hexStr +} + +// FormatMode returns a Unix-style file mode string. +// Examples: "-rw-r--r--", "drwxr-xr-x" +func FormatMode(mode fs.FileMode, isDir bool) string { + var buf [10]byte + + // File type indicator + if isDir { + buf[0] = 'd' + } else { + buf[0] = '-' + } + + // Owner permissions + buf[1] = permChar(mode, 0o400, 'r') + buf[2] = permChar(mode, 0o200, 'w') + buf[3] = permChar(mode, 0o100, 'x') + + // Group permissions + buf[4] = permChar(mode, 0o040, 'r') + buf[5] = permChar(mode, 0o020, 'w') + buf[6] = permChar(mode, 0o010, 'x') + + // Other permissions + buf[7] = permChar(mode, 0o004, 'r') + buf[8] = permChar(mode, 0o002, 'w') + buf[9] = permChar(mode, 0o001, 'x') + + return string(buf[:]) +} + +func permChar(mode, bit fs.FileMode, c byte) byte { + if mode&bit != 0 { + return c + } + return '-' +} diff --git a/internal/archive/format_test.go b/internal/archive/format_test.go new file mode 100644 index 0000000..ecd7af6 --- /dev/null +++ b/internal/archive/format_test.go @@ -0,0 +1,97 @@ +package archive + +import ( + "io/fs" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatSize(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + bytes uint64 + want string + }{ + {name: "zero", bytes: 0, want: "0"}, + {name: "small", bytes: 512, want: "512"}, + {name: "one_kb", bytes: 1024, want: "1.0K"}, + {name: "kb_with_fraction", bytes: 1536, want: "1.5K"}, + {name: "one_mb", bytes: 1024 * 1024, want: "1.0M"}, + {name: "mb_with_fraction", bytes: 1536 * 1024, want: "1.5M"}, + {name: "one_gb", bytes: 1024 * 1024 * 1024, want: "1.0G"}, + {name: "gb_with_fraction", bytes: 1536 * 1024 * 1024, want: "1.5G"}, + {name: "one_tb", bytes: 1024 * 1024 * 1024 * 1024, want: "1.0T"}, + {name: "tb_with_fraction", bytes: 1536 * 1024 * 1024 * 1024, want: "1.5T"}, + {name: "just_under_kb", bytes: 1023, want: "1023"}, + {name: "just_under_mb", bytes: 1024*1024 - 1, want: "1024.0K"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := FormatSize(tt.bytes) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFormatDigest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hash []byte + want string + }{ + {name: "nil", hash: nil, want: ""}, + {name: "empty", hash: []byte{}, want: ""}, + { + name: "valid_sha256", + hash: []byte{0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a}, + want: "sha256:abcdef123456", + }, + { + name: "short_hash", + hash: []byte{0xab, 0xcd}, + want: "sha256:abcd", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := FormatDigest(tt.hash) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFormatMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode fs.FileMode + isDir bool + want string + }{ + {name: "regular_file_644", mode: 0o644, isDir: false, want: "-rw-r--r--"}, + {name: "regular_file_755", mode: 0o755, isDir: false, want: "-rwxr-xr-x"}, + {name: "regular_file_600", mode: 0o600, isDir: false, want: "-rw-------"}, + {name: "directory_755", mode: 0o755, isDir: true, want: "drwxr-xr-x"}, + {name: "directory_700", mode: 0o700, isDir: true, want: "drwx------"}, + {name: "no_permissions", mode: 0o000, isDir: false, want: "----------"}, + {name: "all_permissions", mode: 0o777, isDir: false, want: "-rwxrwxrwx"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := FormatMode(tt.mode, tt.isDir) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/archive/tree.go b/internal/archive/tree.go new file mode 100644 index 0000000..decf6ba --- /dev/null +++ b/internal/archive/tree.go @@ -0,0 +1,90 @@ +package archive + +import ( + "fmt" + "io" +) + +// Tree drawing characters (Unicode box-drawing). +const ( + treeBranch = "├── " + treeLast = "└── " + treeVert = "│ " + treeSpace = " " +) + +// TreePrinter renders directory trees with Unicode box characters. +type TreePrinter struct { + DirsFirst bool + Writer io.Writer +} + +// Print renders the tree starting from root. +// The root entry itself is printed, followed by its children. +func (p *TreePrinter) Print(root *DirEntry) { + // Print root name + name := root.Name + if root.IsDir { + name += "/" + } + fmt.Fprintln(p.Writer, name) + + // Print children + p.printChildren(root.Children, "") +} + +func (p *TreePrinter) printChildren(children []*DirEntry, prefix string) { + if p.DirsFirst { + SortDirsFirst(children) + } + + for i, child := range children { + isLast := i == len(children)-1 + + // Choose the appropriate connector + var connector string + if isLast { + connector = treeLast + } else { + connector = treeBranch + } + + // Format the entry name + name := child.Name + if child.IsDir { + name += "/" + } + + // Print this entry + fmt.Fprintf(p.Writer, "%s%s%s\n", prefix, connector, name) + + // If this is a directory with children, recurse + if child.IsDir && len(child.Children) > 0 { + var childPrefix string + if isLast { + childPrefix = prefix + treeSpace + } else { + childPrefix = prefix + treeVert + } + p.printChildren(child.Children, childPrefix) + } + } +} + +// Counts returns the number of directories and files in a tree. +// The root directory is not counted. +func Counts(root *DirEntry) (dirs, files int) { + countRecursive(root, &dirs, &files) + return dirs, files +} + +func countRecursive(entry *DirEntry, dirs, files *int) { + for _, child := range entry.Children { + if child.IsDir { + *dirs++ + countRecursive(child, dirs, files) + } else { + *files++ + } + } +} diff --git a/internal/archive/tree_test.go b/internal/archive/tree_test.go new file mode 100644 index 0000000..1eb5537 --- /dev/null +++ b/internal/archive/tree_test.go @@ -0,0 +1,209 @@ +package archive + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTreePrinter_Print(t *testing.T) { + t.Parallel() + + root := &DirEntry{ + Name: ".", + IsDir: true, + Children: []*DirEntry{ + { + Name: "config", + IsDir: true, + Children: []*DirEntry{ + {Name: "app.yaml", IsDir: false}, + {Name: "db.yaml", IsDir: false}, + }, + }, + {Name: "README.md", IsDir: false}, + }, + } + + var buf bytes.Buffer + printer := &TreePrinter{Writer: &buf} + printer.Print(root) + + expected := `./ +├── config/ +│ ├── app.yaml +│ └── db.yaml +└── README.md +` + assert.Equal(t, expected, buf.String()) +} + +func TestTreePrinter_Print_DirsFirst(t *testing.T) { + t.Parallel() + + root := &DirEntry{ + Name: ".", + IsDir: true, + Children: []*DirEntry{ + {Name: "README.md", IsDir: false}, + { + Name: "config", + IsDir: true, + Children: []*DirEntry{ + {Name: "app.yaml", IsDir: false}, + }, + }, + {Name: "Makefile", IsDir: false}, + }, + } + + var buf bytes.Buffer + printer := &TreePrinter{Writer: &buf, DirsFirst: true} + printer.Print(root) + + expected := `./ +├── config/ +│ └── app.yaml +├── Makefile +└── README.md +` + assert.Equal(t, expected, buf.String()) +} + +func TestTreePrinter_Print_Empty(t *testing.T) { + t.Parallel() + + root := &DirEntry{ + Name: ".", + IsDir: true, + } + + var buf bytes.Buffer + printer := &TreePrinter{Writer: &buf} + printer.Print(root) + + expected := "./\n" + assert.Equal(t, expected, buf.String()) +} + +func TestTreePrinter_Print_DeepNesting(t *testing.T) { + t.Parallel() + + root := &DirEntry{ + Name: ".", + IsDir: true, + Children: []*DirEntry{ + { + Name: "a", + IsDir: true, + Children: []*DirEntry{ + { + Name: "b", + IsDir: true, + Children: []*DirEntry{ + { + Name: "c", + IsDir: true, + Children: []*DirEntry{ + {Name: "file.txt", IsDir: false}, + }, + }, + }, + }, + }, + }, + }, + } + + var buf bytes.Buffer + printer := &TreePrinter{Writer: &buf} + printer.Print(root) + + expected := `./ +└── a/ + └── b/ + └── c/ + └── file.txt +` + assert.Equal(t, expected, buf.String()) +} + +func TestCounts(t *testing.T) { + t.Parallel() + + root := &DirEntry{ + Name: ".", + IsDir: true, + Children: []*DirEntry{ + { + Name: "config", + IsDir: true, + Children: []*DirEntry{ + {Name: "app.yaml", IsDir: false}, + {Name: "db.yaml", IsDir: false}, + }, + }, + { + Name: "scripts", + IsDir: true, + Children: []*DirEntry{ + {Name: "setup.sh", IsDir: false}, + }, + }, + {Name: "README.md", IsDir: false}, + }, + } + + dirs, files := Counts(root) + assert.Equal(t, 2, dirs) + assert.Equal(t, 4, files) +} + +func TestCounts_Empty(t *testing.T) { + t.Parallel() + + root := &DirEntry{ + Name: ".", + IsDir: true, + } + + dirs, files := Counts(root) + assert.Equal(t, 0, dirs) + assert.Equal(t, 0, files) +} + +func TestCounts_OnlyFiles(t *testing.T) { + t.Parallel() + + root := &DirEntry{ + Name: ".", + IsDir: true, + Children: []*DirEntry{ + {Name: "a.txt", IsDir: false}, + {Name: "b.txt", IsDir: false}, + {Name: "c.txt", IsDir: false}, + }, + } + + dirs, files := Counts(root) + assert.Equal(t, 0, dirs) + assert.Equal(t, 3, files) +} + +func TestCounts_OnlyDirs(t *testing.T) { + t.Parallel() + + root := &DirEntry{ + Name: ".", + IsDir: true, + Children: []*DirEntry{ + {Name: "a", IsDir: true}, + {Name: "b", IsDir: true}, + }, + } + + dirs, files := Counts(root) + assert.Equal(t, 2, dirs) + assert.Equal(t, 0, files) +}