diff --git a/cmd/cloudstic/cmd_list.go b/cmd/cloudstic/cmd_list.go index 2a3bad8..09b4482 100644 --- a/cmd/cloudstic/cmd_list.go +++ b/cmd/cloudstic/cmd_list.go @@ -9,12 +9,16 @@ import ( ) type listArgs struct { - g *globalFlags + g *globalFlags + group *bool } func parseListArgs() *listArgs { fs := flag.NewFlagSet("list", flag.ExitOnError) - a := &listArgs{g: addGlobalFlags(fs)} + a := &listArgs{ + g: addGlobalFlags(fs), + group: fs.Bool("group", false, "Group snapshots by source identity"), + } mustParse(fs) return a } @@ -31,7 +35,7 @@ func (r *runner) runList() int { if err != nil { return r.fail("List failed: %v", err) } - r.printListResult(result) + r.printListResult(result, *a.group) return 0 } @@ -43,7 +47,11 @@ func buildListOpts(a *listArgs) []cloudstic.ListOption { return listOpts } -func (r *runner) printListResult(result *cloudstic.ListResult) { +func (r *runner) printListResult(result *cloudstic.ListResult, group bool) { _, _ = fmt.Fprintf(r.out, "%d snapshots\n", len(result.Snapshots)) - r.renderSnapshotTable(result.Snapshots, nil) + if group { + r.renderGroupedSnapshotTables(result.Snapshots) + } else { + r.renderSnapshotTable(result.Snapshots, nil) + } } diff --git a/cmd/cloudstic/cmd_list_test.go b/cmd/cloudstic/cmd_list_test.go index 80bf78a..6917ce4 100644 --- a/cmd/cloudstic/cmd_list_test.go +++ b/cmd/cloudstic/cmd_list_test.go @@ -43,3 +43,38 @@ func TestRunList_Empty(t *testing.T) { t.Errorf("expected '0 snapshots', got: %s", out.String()) } } + +func TestRunList_Group(t *testing.T) { + os.Args = []string{"cloudstic", "list", "-group"} + var out strings.Builder + r := &runner{out: &out, errOut: &strings.Builder{}, client: &stubClient{ + listResult: &cloudstic.ListResult{ + Snapshots: []engine.SnapshotEntry{ + { + Ref: "snapshot/abc", + Snap: core.Snapshot{ + Seq: 1, Created: "2024-01-01", + Source: &core.SourceInfo{Type: "gdrive", Account: "a@b.com", Path: "/", VolumeLabel: "My Drive"}, + }, + }, + { + Ref: "snapshot/def", + Snap: core.Snapshot{ + Seq: 2, Created: "2024-01-02", + Source: &core.SourceInfo{Type: "local", Account: "host", Path: "/data"}, + }, + }, + }, + }, + }} + + r.runList() + + got := out.String() + if !strings.Contains(got, "2 snapshots") { + t.Errorf("expected '2 snapshots', got:\n%s", got) + } + if !strings.Contains(got, "──") { + t.Errorf("expected group headers with ──, got:\n%s", got) + } +} diff --git a/cmd/cloudstic/completion.go b/cmd/cloudstic/completion.go index 13cb7c0..e921299 100644 --- a/cmd/cloudstic/completion.go +++ b/cmd/cloudstic/completion.go @@ -109,7 +109,9 @@ _cloudstic() { cmd_flags="" ;; esac ;; - list|ls|diff|break-lock|version|help) + list) + cmd_flags="-group" ;; + ls|diff|break-lock|version|help) cmd_flags="" ;; esac @@ -246,7 +248,8 @@ _cloudstic() { ':snapshot ID:' ;; list) - _arguments $global_flags + _arguments $global_flags \ + '-group[Group output by source identity]' ;; ls) _arguments $global_flags \ @@ -402,6 +405,9 @@ complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l dry-run -d 'Sca complete -c cloudstic -n '__fish_seen_subcommand_from restore' -l output -r -F -d 'Output ZIP file path' complete -c cloudstic -n '__fish_seen_subcommand_from restore' -l dry-run -d 'Show what would be restored' +# list +complete -c cloudstic -n '__fish_seen_subcommand_from list' -l group -d 'Group output by source identity' + # prune complete -c cloudstic -n '__fish_seen_subcommand_from prune' -l dry-run -d 'Show what would be deleted' diff --git a/cmd/cloudstic/format.go b/cmd/cloudstic/format.go index 94215b5..c2abcd8 100644 --- a/cmd/cloudstic/format.go +++ b/cmd/cloudstic/format.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/cloudstic/cli/internal/core" "github.com/cloudstic/cli/internal/engine" "github.com/jedib0t/go-pretty/v6/table" ) @@ -67,3 +68,68 @@ func (r *runner) renderSnapshotTable(entries []engine.SnapshotEntry, reasons map t.Render() } + +// sourceGroupKey returns a string key that groups snapshots by source identity. +func sourceGroupKey(s *core.SourceInfo) string { + if s == nil { + return "" + } + if s.VolumeUUID != "" { + return s.Type + "\x00" + s.VolumeUUID + "\x00" + s.Path + } + return s.Type + "\x00" + s.Account + "\x00" + s.Path +} + +// sourceGroupLabel returns a human-readable label for a source group. +func sourceGroupLabel(s *core.SourceInfo) string { + if s == nil { + return "(unknown source)" + } + var parts []string + label := s.Type + if s.VolumeLabel != "" { + label += " (" + s.VolumeLabel + ")" + } + parts = append(parts, label) + if s.Account != "" { + parts = append(parts, s.Account) + } + if s.Path != "" { + parts = append(parts, s.Path) + } + return strings.Join(parts, " · ") +} + +// renderGroupedSnapshotTables prints one table per source group. +func (r *runner) renderGroupedSnapshotTables(entries []engine.SnapshotEntry) { + // Collect groups preserving first-seen order. + type group struct { + key string + label string + entries []engine.SnapshotEntry + } + var groups []group + idx := map[string]int{} + + for _, e := range entries { + k := sourceGroupKey(e.Snap.Source) + if i, ok := idx[k]; ok { + groups[i].entries = append(groups[i].entries, e) + } else { + idx[k] = len(groups) + groups = append(groups, group{ + key: k, + label: sourceGroupLabel(e.Snap.Source), + entries: []engine.SnapshotEntry{e}, + }) + } + } + + for i, g := range groups { + if i > 0 { + _, _ = fmt.Fprintln(r.out) + } + _, _ = fmt.Fprintf(r.out, "── %s (%d snapshots)\n", g.label, len(g.entries)) + r.renderSnapshotTable(g.entries, nil) + } +} diff --git a/cmd/cloudstic/format_test.go b/cmd/cloudstic/format_test.go index 4e82aad..cccb8a1 100644 --- a/cmd/cloudstic/format_test.go +++ b/cmd/cloudstic/format_test.go @@ -85,3 +85,141 @@ func TestRenderSnapshotTable_WithReasons(t *testing.T) { t.Errorf("expected 'REASONS' header in output, got:\n%s", got) } } + +func TestRenderSnapshotTable_VolumeLabel(t *testing.T) { + var out strings.Builder + r := &runner{out: &out, errOut: &strings.Builder{}} + + entries := []engine.SnapshotEntry{ + { + Ref: "snapshot/abc", + Snap: core.Snapshot{ + Seq: 1, + Created: "2024-01-01T00:00:00Z", + Source: &core.SourceInfo{ + Type: "gdrive", + Account: "user@gmail.com", + Path: "/", + VolumeLabel: "My Drive", + }, + }, + }, + } + r.renderSnapshotTable(entries, nil) + + got := out.String() + if !strings.Contains(got, "gdrive (My Drive)") { + t.Errorf("expected 'gdrive (My Drive)' in Source column, got:\n%s", got) + } + if !strings.Contains(got, "user@gmail.com") { + t.Errorf("expected account in Account column, got:\n%s", got) + } +} + +func TestSourceGroupKey(t *testing.T) { + tests := []struct { + name string + source *core.SourceInfo + want string + }{ + {"nil source", nil, ""}, + { + "local with UUID", + &core.SourceInfo{Type: "local", Account: "host", Path: ".", VolumeUUID: "UUID-1"}, + "local\x00UUID-1\x00.", + }, + { + "gdrive no UUID", + &core.SourceInfo{Type: "gdrive", Account: "user@gmail.com", Path: "/"}, + "gdrive\x00user@gmail.com\x00/", + }, + { + "shared drive with UUID", + &core.SourceInfo{Type: "gdrive", Account: "user@gmail.com", Path: "/", VolumeUUID: "drive-123"}, + "gdrive\x00drive-123\x00/", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sourceGroupKey(tt.source) + if got != tt.want { + t.Errorf("sourceGroupKey() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSourceGroupLabel(t *testing.T) { + tests := []struct { + name string + source *core.SourceInfo + want string + }{ + {"nil source", nil, "(unknown source)"}, + { + "local no label", + &core.SourceInfo{Type: "local", Account: "macbook", Path: "/data"}, + "local · macbook · /data", + }, + { + "gdrive with label", + &core.SourceInfo{Type: "gdrive", Account: "user@gmail.com", Path: "/", VolumeLabel: "My Drive"}, + "gdrive (My Drive) · user@gmail.com · /", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sourceGroupLabel(tt.source) + if got != tt.want { + t.Errorf("sourceGroupLabel() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRenderGroupedSnapshotTables(t *testing.T) { + var out strings.Builder + r := &runner{out: &out, errOut: &strings.Builder{}} + + entries := []engine.SnapshotEntry{ + { + Ref: "snapshot/aaa", + Snap: core.Snapshot{ + Seq: 1, Created: "2024-01-01T00:00:00Z", + Source: &core.SourceInfo{Type: "gdrive", Account: "user@gmail.com", Path: "/", VolumeLabel: "My Drive"}, + }, + }, + { + Ref: "snapshot/bbb", + Snap: core.Snapshot{ + Seq: 2, Created: "2024-01-02T00:00:00Z", + Source: &core.SourceInfo{Type: "local", Account: "macbook", Path: "."}, + }, + }, + { + Ref: "snapshot/ccc", + Snap: core.Snapshot{ + Seq: 3, Created: "2024-01-03T00:00:00Z", + Source: &core.SourceInfo{Type: "gdrive", Account: "user@gmail.com", Path: "/", VolumeLabel: "My Drive"}, + }, + }, + } + + r.renderGroupedSnapshotTables(entries) + + got := out.String() + // Should have two group headers. + if strings.Count(got, "──") != 2 { + t.Errorf("expected 2 group headers, got:\n%s", got) + } + if !strings.Contains(got, "gdrive (My Drive) · user@gmail.com · / (2 snapshots)") { + t.Errorf("expected gdrive group header with 2 snapshots, got:\n%s", got) + } + if !strings.Contains(got, "local · macbook · . (1 snapshots)") { + t.Errorf("expected local group header with 1 snapshot, got:\n%s", got) + } + // Both snapshot hashes should appear. + if !strings.Contains(got, "aaa") || !strings.Contains(got, "bbb") || !strings.Contains(got, "ccc") { + t.Errorf("expected all snapshot hashes in output, got:\n%s", got) + } +} diff --git a/cmd/cloudstic/usage.go b/cmd/cloudstic/usage.go index 9bdcb15..7951d90 100644 --- a/cmd/cloudstic/usage.go +++ b/cmd/cloudstic/usage.go @@ -122,7 +122,9 @@ func printUsage() { t.Blank() t.Command("list", "") - t.Note(" No additional flags.") + t.Flags([][2]string{ + {"-group", "Group snapshots by source identity"}, + }) t.Blank() t.Command("ls", "[snapshot_id]")