diff --git a/cmd/restic/cmd_stats.go b/cmd/restic/cmd_stats.go index 14424fdcc..83d2a2954 100644 --- a/cmd/restic/cmd_stats.go +++ b/cmd/restic/cmd_stats.go @@ -7,8 +7,6 @@ import ( "fmt" "path/filepath" "strings" - "sync" - "time" "github.com/restic/chunker" "github.com/restic/restic/internal/crypto" @@ -19,6 +17,7 @@ import ( "github.com/restic/restic/internal/restorer" "github.com/restic/restic/internal/ui" "github.com/restic/restic/internal/ui/progress" + statsui "github.com/restic/restic/internal/ui/stats" "github.com/restic/restic/internal/ui/table" "github.com/restic/restic/internal/walker" @@ -141,13 +140,8 @@ func runStats(ctx context.Context, opts StatsOptions, gopts global.Options, args snapshots = append(snapshots, sn) } - statsProgress := newStatsProgress(term, !gopts.JSON, uint64(len(snapshots))) - - updater := progress.NewUpdater(progress.CalculateProgressInterval(!gopts.Quiet, gopts.JSON, term.CanUpdateStatus()), func(runtime time.Duration, final bool) { - statsProgress.printProgress(runtime, final) - }) - - defer updater.Done() + statsProgress := statsui.NewProgress(term, gopts.Quiet, gopts.JSON, uint64(len(snapshots))) + defer statsProgress.Done() for _, sn := range snapshots { err = statsWalkSnapshot(ctx, sn, repo, opts, stats, statsProgress) @@ -175,7 +169,7 @@ func runStats(ctx context.Context, opts StatsOptions, gopts global.Options, args } } stats.TotalBlobCount++ - statsProgress.update(0, 1, uint64(pbs[0].Length)) + statsProgress.Update(0, 1, uint64(pbs[0].Length)) } if stats.TotalCompressedBlobsSize > 0 { stats.CompressionRatio = float64(stats.TotalCompressedBlobsUncompressedSize) / float64(stats.TotalCompressedBlobsSize) @@ -186,7 +180,7 @@ func runStats(ctx context.Context, opts StatsOptions, gopts global.Options, args } } // stop progress bar to prevent mangled output - updater.Done() + statsProgress.Done() if gopts.JSON { err = json.NewEncoder(gopts.Term.OutputWriter()).Encode(stats) @@ -221,8 +215,8 @@ func runStats(ctx context.Context, opts StatsOptions, gopts global.Options, args return nil } -func statsWalkSnapshot(ctx context.Context, snapshot *data.Snapshot, repo restic.Loader, opts StatsOptions, stats *statsContainer, progress *statsProgress) error { - progress.processSnapshot() +func statsWalkSnapshot(ctx context.Context, snapshot *data.Snapshot, repo restic.Loader, opts StatsOptions, stats *statsContainer, progress *statsui.Progress) error { + progress.ProcessSnapshot() if snapshot.Tree == nil { return fmt.Errorf("snapshot %s has nil tree", snapshot.ID().Str()) } @@ -246,7 +240,7 @@ func statsWalkSnapshot(ctx context.Context, snapshot *data.Snapshot, repo restic return nil } -func statsWalkTree(repo restic.Loader, opts StatsOptions, stats *statsContainer, hardLinkIndex *restorer.HardlinkIndex[struct{}], progress *statsProgress) walker.WalkFunc { +func statsWalkTree(repo restic.Loader, opts StatsOptions, stats *statsContainer, hardLinkIndex *restorer.HardlinkIndex[struct{}], progress *statsui.Progress) walker.WalkFunc { return func(parentTreeID restic.ID, npath string, node *data.Node, nodeErr error) error { if nodeErr != nil { return nodeErr @@ -254,7 +248,7 @@ func statsWalkTree(repo restic.Loader, opts StatsOptions, stats *statsContainer, if node == nil { return nil } - progress.update(1, 0, uint64(node.Size)) + progress.Update(1, 0, uint64(node.Size)) if opts.countMode == countModeUniqueFilesByContents || opts.countMode == countModeBlobsPerFile { // only count this file if we haven't visited it before fid := makeFileIDByContents(node) @@ -274,7 +268,7 @@ func statsWalkTree(repo restic.Loader, opts StatsOptions, stats *statsContainer, // ensure we have this file (by path) in our map; in this // mode, a file is unique by both contents and path nodePath := filepath.Join(npath, node.Name) - progress.update(0, 1, 0) + progress.Update(0, 1, 0) if _, ok := stats.fileBlobs[nodePath]; !ok { stats.fileBlobs[nodePath] = restic.NewIDSet() stats.TotalFileCount++ @@ -371,76 +365,6 @@ type statsContainer struct { // independent of references to files blobs restic.AssociatedBlobSet } -type statsProgress struct { - term ui.Terminal - m sync.Mutex - snapshotCount uint64 - show bool - - processedSnapshotCount uint64 - processedFileCount uint64 - processedBlobCount uint64 - processedSize uint64 -} - -func newStatsProgress(term ui.Terminal, show bool, snapshotCount uint64) *statsProgress { - return &statsProgress{ - term: term, - show: show, - snapshotCount: snapshotCount, - } -} - -func (s *statsProgress) printProgress(runtime time.Duration, final bool) { - if !s.show { - return - } - s.m.Lock() - - progressBase := s.processedSnapshotCount - if progressBase > 0 && !final { - progressBase-- - } - - status := fmt.Sprintf("[%s] %s %d / %d snapshots", ui.FormatDuration(runtime), ui.FormatPercent(progressBase, s.snapshotCount), s.processedSnapshotCount, s.snapshotCount) - - if s.processedFileCount > 0 { - status += fmt.Sprintf(", %v files", s.processedFileCount) - } - - if s.processedBlobCount > 0 { - status += fmt.Sprintf(", %d blobs", s.processedBlobCount) - } - - status += fmt.Sprintf(", %s", ui.FormatBytes(s.processedSize)) - s.m.Unlock() - - if final { - s.term.SetStatus(nil) - s.term.Print(status) - } else { - s.term.SetStatus([]string{status}) - } -} - -func (s *statsProgress) update(fileCount uint64, blobCount uint64, size uint64) { - s.m.Lock() - defer s.m.Unlock() - - s.processedFileCount += fileCount - s.processedBlobCount += blobCount - s.processedSize += size -} - -func (s *statsProgress) processSnapshot() { - s.m.Lock() - defer s.m.Unlock() - - s.processedSnapshotCount++ - s.processedFileCount = 0 - s.processedBlobCount = 0 - s.processedSize = 0 -} // fileID is a 256-bit hash that distinguishes unique files. type fileID [32]byte diff --git a/cmd/restic/cmd_stats_test.go b/cmd/restic/cmd_stats_test.go index a2068de44..02d37acd9 100644 --- a/cmd/restic/cmd_stats_test.go +++ b/cmd/restic/cmd_stats_test.go @@ -2,10 +2,8 @@ package main import ( "testing" - "time" rtest "github.com/restic/restic/internal/test" - "github.com/restic/restic/internal/ui" ) func TestSizeHistogramNew(t *testing.T) { @@ -62,38 +60,3 @@ func TestSizeHistogramString(t *testing.T) { rtest.Equals(t, "Count: 3\nTotal Size: 11 B\nSize Count\n-------------------\n 0 - 0 Byte 1\n 1 - 9 Byte 1\n10 - 42 Byte 1\n-------------------\n", h.String()) }) } - -func TestStatsProgress(t *testing.T) { - term := &ui.MockTerminal{} - - progress := newStatsProgress(term, true, 2) - progress.printProgress(0*time.Second, false) - rtest.Equals(t, []string{"[0:00] 0.00% 0 / 2 snapshots, 0 B"}, term.Output) - - progress.processSnapshot() - progress.update(1, 2, 3) - progress.printProgress(5*time.Second, false) - // Output differs from the previous one because the progress is based on the number of processed snapshots, - // 1/2 snapshots means processing the snapshot 1 currently - rtest.Equals(t, []string{"[0:05] 0.00% 1 / 2 snapshots, 1 files, 2 blobs, 3 B"}, term.Output) - - progress.processSnapshot() - progress.printProgress(10*time.Second, false) - rtest.Equals(t, []string{"[0:10] 50.00% 2 / 2 snapshots, 0 B"}, term.Output) - - progress.update(4, 5, 6) - progress.printProgress(15*time.Second, false) - rtest.Equals(t, []string{"[0:15] 50.00% 2 / 2 snapshots, 4 files, 5 blobs, 6 B"}, term.Output) - - progress.printProgress(20*time.Second, true) - rtest.Equals(t, []string{"[0:20] 100.00% 2 / 2 snapshots, 4 files, 5 blobs, 6 B"}, term.Output) -} - -func TestStatsProgressJSON(t *testing.T) { - term := &ui.MockTerminal{} - - progress := newStatsProgress(term, false, 2) - progress.printProgress(0*time.Second, false) - // JSON output is not available yet, so just make sure to not break normal json output - rtest.Equals(t, nil, term.Output) -} diff --git a/internal/ui/stats/progress.go b/internal/ui/stats/progress.go new file mode 100644 index 000000000..5f568070e --- /dev/null +++ b/internal/ui/stats/progress.go @@ -0,0 +1,94 @@ +package stats + +import ( + "fmt" + "sync" + "time" + + "github.com/restic/restic/internal/ui" + "github.com/restic/restic/internal/ui/progress" +) + +// Progress reports progress for the stats command. +type Progress struct { + progress.Updater + + term ui.Terminal + m sync.Mutex + snapshotCount uint64 + show bool + + processedSnapshotCount uint64 + processedFileCount uint64 + processedBlobCount uint64 + processedSize uint64 +} + +// NewProgress returns a new stats progress reporter. +func NewProgress(term ui.Terminal, quiet, json bool, snapshotCount uint64) *Progress { + p := newProgress(term, !json, snapshotCount) + p.Updater = *progress.NewUpdater( + progress.CalculateProgressInterval(!quiet, json, term.CanUpdateStatus()), + p.printProgress, + ) + return p +} + +func newProgress(term ui.Terminal, show bool, snapshotCount uint64) *Progress { + return &Progress{ + term: term, + snapshotCount: snapshotCount, + show: show, + } +} + +func (p *Progress) printProgress(runtime time.Duration, final bool) { + if !p.show { + return + } + p.m.Lock() + + progressBase := p.processedSnapshotCount + if progressBase > 0 && !final { + progressBase-- + } + + status := fmt.Sprintf("[%s] %s %d / %d snapshots", ui.FormatDuration(runtime), ui.FormatPercent(progressBase, p.snapshotCount), p.processedSnapshotCount, p.snapshotCount) + + if p.processedFileCount > 0 { + status += fmt.Sprintf(", %v files", p.processedFileCount) + } + + if p.processedBlobCount > 0 { + status += fmt.Sprintf(", %d blobs", p.processedBlobCount) + } + + status += fmt.Sprintf(", %s", ui.FormatBytes(p.processedSize)) + p.m.Unlock() + + if final { + p.term.SetStatus(nil) + p.term.Print(status) + } else { + p.term.SetStatus([]string{status}) + } +} + +func (p *Progress) Update(fileCount uint64, blobCount uint64, size uint64) { + p.m.Lock() + defer p.m.Unlock() + + p.processedFileCount += fileCount + p.processedBlobCount += blobCount + p.processedSize += size +} + +func (p *Progress) ProcessSnapshot() { + p.m.Lock() + defer p.m.Unlock() + + p.processedSnapshotCount++ + p.processedFileCount = 0 + p.processedBlobCount = 0 + p.processedSize = 0 +} diff --git a/internal/ui/stats/progress_test.go b/internal/ui/stats/progress_test.go new file mode 100644 index 000000000..2508d0056 --- /dev/null +++ b/internal/ui/stats/progress_test.go @@ -0,0 +1,44 @@ +package stats + +import ( + "testing" + "time" + + rtest "github.com/restic/restic/internal/test" + "github.com/restic/restic/internal/ui" +) + +func TestStatsProgress(t *testing.T) { + term := &ui.MockTerminal{} + + progress := newProgress(term, true, 2) + progress.printProgress(0*time.Second, false) + rtest.Equals(t, []string{"[0:00] 0.00% 0 / 2 snapshots, 0 B"}, term.Output) + + progress.ProcessSnapshot() + progress.Update(1, 2, 3) + progress.printProgress(5*time.Second, false) + // Output differs from the previous one because the progress is based on the number of processed snapshots, + // 1/2 snapshots means processing the snapshot 1 currently + rtest.Equals(t, []string{"[0:05] 0.00% 1 / 2 snapshots, 1 files, 2 blobs, 3 B"}, term.Output) + + progress.ProcessSnapshot() + progress.printProgress(10*time.Second, false) + rtest.Equals(t, []string{"[0:10] 50.00% 2 / 2 snapshots, 0 B"}, term.Output) + + progress.Update(4, 5, 6) + progress.printProgress(15*time.Second, false) + rtest.Equals(t, []string{"[0:15] 50.00% 2 / 2 snapshots, 4 files, 5 blobs, 6 B"}, term.Output) + + progress.printProgress(20*time.Second, true) + rtest.Equals(t, []string{"[0:20] 100.00% 2 / 2 snapshots, 4 files, 5 blobs, 6 B"}, term.Output) +} + +func TestStatsProgressJSON(t *testing.T) { + term := &ui.MockTerminal{} + + progress := newProgress(term, false, 2) + progress.printProgress(0*time.Second, false) + // JSON output is not available yet, so just make sure to not break normal json output + rtest.Equals(t, nil, term.Output) +}