Add atomic writes, humanized sizes, debug logging, and -v/-q per-command

- Atomic writes for mfer gen: writes to temp file, renames on success,
  cleans up temp on error/interrupt. Prevents empty manifests on Ctrl-C.
- Humanized byte sizes using dustin/go-humanize (e.g., "10 MiB" not "10485760")
- Progress lines clear when done (using ANSI escape \r\033[K])
- Debug logging when files are added to manifest (mfer gen -vv)
- Move -v/-q flags from global to per-command for better UX
- Add tests for atomic write behavior with failing filesystem mock
This commit is contained in:
2025-12-17 15:57:20 -08:00
parent 444a4c8f45
commit c218fe56e9
12 changed files with 276 additions and 86 deletions

View File

@@ -6,6 +6,7 @@ import (
"sync"
"time"
"github.com/dustin/go-humanize"
"github.com/spf13/afero"
"github.com/urfave/cli/v2"
"sneak.berlin/go/mfer/internal/log"
@@ -36,9 +37,9 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
go func() {
defer enumWg.Done()
for status := range enumProgress {
log.Progressf("Enumerating: %d files, %.1f MB",
log.Progressf("Enumerating: %d files, %s",
status.FilesFound,
float64(status.BytesFound)/1e6)
humanize.IBytes(uint64(status.BytesFound)))
}
log.ProgressDone()
}()
@@ -66,7 +67,7 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
}
enumWg.Wait()
log.Infof("enumerated %d files, %d bytes total", s.FileCount(), s.TotalBytes())
log.Infof("enumerated %d files, %s total", s.FileCount(), humanize.IBytes(uint64(s.TotalBytes())))
// Check if output file exists
outputPath := ctx.String("output")
@@ -76,12 +77,21 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
}
}
// Open output file
outFile, err := mfa.Fs.Create(outputPath)
// Create temp file for atomic write
tmpPath := outputPath + ".tmp"
outFile, err := mfa.Fs.Create(tmpPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
return fmt.Errorf("failed to create temp file: %w", err)
}
defer func() { _ = outFile.Close() }()
// Clean up temp file on any error or interruption
success := false
defer func() {
_ = outFile.Close()
if !success {
_ = mfa.Fs.Remove(tmpPath)
}
}()
// Phase 2: Scan - read file contents and generate manifest
var scanProgress chan scanner.ScanStatus
@@ -93,16 +103,16 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
defer scanWg.Done()
for status := range scanProgress {
if status.ETA > 0 {
log.Progressf("Scanning: %d/%d files, %.1f MB/s, ETA %s",
log.Progressf("Scanning: %d/%d files, %s/s, ETA %s",
status.ScannedFiles,
status.TotalFiles,
status.BytesPerSec/1e6,
humanize.IBytes(uint64(status.BytesPerSec)),
status.ETA.Round(time.Second))
} else {
log.Progressf("Scanning: %d/%d files, %.1f MB/s",
log.Progressf("Scanning: %d/%d files, %s/s",
status.ScannedFiles,
status.TotalFiles,
status.BytesPerSec/1e6)
humanize.IBytes(uint64(status.BytesPerSec)))
}
}
log.ProgressDone()
@@ -115,9 +125,21 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
return fmt.Errorf("failed to generate manifest: %w", err)
}
// Close file before rename to ensure all data is flushed
if err := outFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
// Atomic rename
if err := mfa.Fs.Rename(tmpPath, outputPath); err != nil {
return fmt.Errorf("failed to rename temp file: %w", err)
}
success = true
elapsed := time.Since(mfa.startupTime).Seconds()
rate := float64(s.TotalBytes()) / elapsed / 1e6
log.Infof("wrote %d files (%.1f MB) to %s in %.1fs (%.1f MB/s)", s.FileCount(), float64(s.TotalBytes())/1e6, outputPath, elapsed, rate)
rate := float64(s.TotalBytes()) / elapsed
log.Infof("wrote %d files (%s) to %s in %.1fs (%s/s)", s.FileCount(), humanize.IBytes(uint64(s.TotalBytes())), outputPath, elapsed, humanize.IBytes(uint64(rate)))
return nil
}