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
18 changes: 13 additions & 5 deletions cmd/cloudstic/cmd_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}

Expand All @@ -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)
}
}
35 changes: 35 additions & 0 deletions cmd/cloudstic/cmd_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 8 additions & 2 deletions cmd/cloudstic/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -246,7 +248,8 @@ _cloudstic() {
':snapshot ID:'
;;
list)
_arguments $global_flags
_arguments $global_flags \
'-group[Group output by source identity]'
;;
ls)
_arguments $global_flags \
Expand Down Expand Up @@ -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'

Expand Down
66 changes: 66 additions & 0 deletions cmd/cloudstic/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
138 changes: 138 additions & 0 deletions cmd/cloudstic/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 3 additions & 1 deletion cmd/cloudstic/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]")
Expand Down
Loading