Files
mfer/internal/cli/gen.go
T
sneak 16a82ff176
check / check (push) Failing after 2s
Move the CLI to urfave/cli v3 (closes #110)
Ported per the library's v2-to-v3 migration guide: the app is a root
cli.Command, actions take a context and the command, and flag
environment variables become value sources. -v, -q and the version flag
are local so, as before, only the commands defining them accept them.
Every command stops reading flags at its first argument, as v2 did.
ErrWriter is stdout so usage errors print with their help. The action's
context reaches the manifest download in check, export and list.
testify rises to v1.12.1, which v3 requires; the urfave_cli_no_docs
build tag, which v3 lacks, is dropped. v3 accepts a flag given under two
names, so -v --verbose gives debug output, and the test pinning the
refusal becomes a TestVerboseCount case.

Model: opus-5-5
2026-10-04 19:07:01 +00:00

295 lines
7.3 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"github.com/dustin/go-humanize"
"github.com/spf13/afero"
"github.com/urfave/cli/v3"
"sneak.berlin/go/mfer/internal/log"
"sneak.berlin/go/mfer/mfer"
)
var (
// errPathNotExist indicates an input path that does not exist.
errPathNotExist = errors.New("path does not exist")
// errOutputExists indicates the output file already exists and
// --force was not given. It is wrapped mid-sentence so that the
// rendered message stays exactly as mfer has always printed it.
errOutputExists = errors.New(
"already exists (use --force to overwrite)")
)
// reportEnumProgress renders enumeration progress until the channel
// closes.
func reportEnumProgress(progress <-chan mfer.EnumerateStatus, wg *sync.WaitGroup) {
defer wg.Done()
for status := range progress {
log.Progressf("Enumerating: %d files, %s",
status.FilesFound,
humanize.IBytes(safeUint64(int64(status.BytesFound))))
}
log.ProgressDone()
}
// reportScanProgress renders scan progress until the channel closes.
func reportScanProgress(progress <-chan mfer.ScanStatus, wg *sync.WaitGroup) {
defer wg.Done()
for status := range progress {
if status.ETA > 0 {
log.Progressf("Scanning: %d/%d files, %s/s, ETA %s",
status.ScannedFiles,
status.TotalFiles,
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
status.ETA.Round(time.Second))
} else {
log.Progressf("Scanning: %d/%d files, %s/s",
status.ScannedFiles,
status.TotalFiles,
humanize.IBytes(safeRateUint64(status.BytesPerSec)))
}
}
log.ProgressDone()
}
// collectInputPaths validates the input path arguments and returns them
// as absolute paths.
func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
paths := make([]string, 0, args.Len())
for i := range args.Len() {
inputPath := args.Get(i)
ap, err := filepath.Abs(inputPath)
if err != nil {
return nil, fmt.Errorf("generate: invalid path %q: %w", inputPath, err)
}
// Validate path exists before adding to list
if exists, _ := afero.Exists(mfa.Fs, ap); !exists {
return nil, fmt.Errorf("%w: %s", errPathNotExist, inputPath)
}
log.Debugf("enumerating path: %s", ap)
paths = append(paths, ap)
}
return paths, nil
}
// buildScannerOptions constructs scanner options from the CLI flags.
func (mfa *CLIApp) buildScannerOptions(cmd *cli.Command) *mfer.ScannerOptions {
output := cmd.String("output")
opts := &mfer.ScannerOptions{
IncludeDotfiles: cmd.Bool("include-dotfiles"),
FollowSymLinks: cmd.Bool("follow-symlinks"),
IncludeTimestamps: cmd.Bool("include-timestamps"),
Fs: mfa.Fs,
// Neither a manifest being replaced nor a temp file left by an
// interrupted run belongs in the new manifest.
ExcludePaths: []string{output, manifestTempPath(output)},
}
// Set seed for deterministic UUID if provided
if seed := cmd.String("seed"); seed != "" {
opts.Seed = seed
log.Infof("using deterministic seed for manifest UUID")
}
// Set up signing options if sign-key is provided
if signKey := cmd.String("sign-key"); signKey != "" {
opts.SigningOptions = &mfer.SigningOptions{
KeyID: mfer.GPGKeyID(signKey),
}
log.Infof("signing manifest with GPG key: %s", signKey)
}
return opts
}
// enumerateInputs runs the enumeration phase over the argument paths,
// or the current directory when no arguments are given.
func (mfa *CLIApp) enumerateInputs(
s *mfer.Scanner, args cli.Args, enumProgress chan mfer.EnumerateStatus,
) error {
if args.Len() == 0 {
// Default to current directory
err := s.EnumeratePath(".", enumProgress)
if err != nil {
return fmt.Errorf(
"generate: failed to enumerate current directory: %w", err)
}
return nil
}
// Collect and validate all paths first
paths, err := mfa.collectInputPaths(args)
if err != nil {
return err
}
err = s.EnumeratePaths(enumProgress, paths...)
if err != nil {
return fmt.Errorf("generate: failed to enumerate paths: %w", err)
}
return nil
}
// cleanupOnSignal installs a handler that removes the temp output file
// and exits when the process is interrupted. It returns the signal
// channel so the caller can stop and close it when done.
func (mfa *CLIApp) cleanupOnSignal(outFile afero.File, tmpPath string) chan os.Signal {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
sig, ok := <-sigChan
if !ok || sig == nil {
return // Channel closed normally, not a signal
}
_ = outFile.Close()
_ = mfa.Fs.Remove(tmpPath)
os.Exit(1)
}()
return sigChan
}
// runEnumeratePhase enumerates all input paths with optional progress
// reporting and logs the totals.
func (mfa *CLIApp) runEnumeratePhase(cmd *cli.Command, s *mfer.Scanner) error {
// Set up enumeration progress reporting
var (
enumProgress chan mfer.EnumerateStatus
enumWg sync.WaitGroup
)
if cmd.Bool("progress") {
enumProgress = make(chan mfer.EnumerateStatus, 1)
enumWg.Add(1)
go reportEnumProgress(enumProgress, &enumWg)
}
err := mfa.enumerateInputs(s, cmd.Args(), enumProgress)
if err != nil {
return err
}
enumWg.Wait()
log.Infof("enumerated %d files, %s total", s.FileCount(),
humanize.IBytes(safeUint64(int64(s.TotalBytes()))))
return nil
}
func (mfa *CLIApp) generateManifestOperation(
ctx context.Context, cmd *cli.Command,
) error {
log.Debug("generateManifestOperation()")
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd))
// Phase 1: Enumeration - collect paths and stat files
err := mfa.runEnumeratePhase(cmd, s)
if err != nil {
return err
}
showProgress := cmd.Bool("progress")
// Check if output file exists
outputPath := cmd.String("output")
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") {
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
}
// Create temp file for atomic write
tmpPath := manifestTempPath(outputPath)
outFile, err := mfa.Fs.Create(tmpPath)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
// Set up signal handler to clean up temp file on Ctrl-C
sigChan := mfa.cleanupOnSignal(outFile, tmpPath)
// Clean up temp file on any error or interruption
success := false
defer func() {
signal.Stop(sigChan)
close(sigChan)
_ = outFile.Close()
if !success {
_ = mfa.Fs.Remove(tmpPath)
}
}()
// Phase 2: Scan - read file contents and generate manifest
var (
scanProgress chan mfer.ScanStatus
scanWg sync.WaitGroup
)
if showProgress {
scanProgress = make(chan mfer.ScanStatus, 1)
scanWg.Add(1)
go reportScanProgress(scanProgress, &scanWg)
}
err = s.ToManifest(ctx, outFile, scanProgress)
scanWg.Wait()
if err != nil {
return fmt.Errorf("failed to generate manifest: %w", err)
}
// Close file before rename to ensure all data is flushed
err = outFile.Close()
if err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
// Atomic rename
err = mfa.Fs.Rename(tmpPath, outputPath)
if 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
log.Infof("wrote %d files (%s) to %s in %.1fs (%s/s)", s.FileCount(),
humanize.IBytes(safeUint64(int64(s.TotalBytes()))), outputPath, elapsed,
humanize.IBytes(safeRateUint64(rate)))
return nil
}