Files
mfer/internal/cli/gen.go
clawbot de476708e9
Some checks failed
check / check (push) Has been cancelled
Update golangci-lint to v2.12.2 with canonical config (closes #60)
Adopts golangci-lint v2.12.2 and the canonical .golangci.yml (default: all), and fixes all resulting findings across the tree.

Two intended behavior changes: absent MFFilePath.Mtime is handled explicitly in freshen, list and export rather than dereferenced (main panicked); gpg positional key IDs now follow an explicit -- end-of-options marker.

All twelve reworded user-visible error messages restored to byte-identical parity with main and pinned by tests.
2026-08-10 16:06:12 +02:00

288 lines
7.1 KiB
Go

package cli
import (
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"github.com/dustin/go-humanize"
"github.com/spf13/afero"
"github.com/urfave/cli/v2"
"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(ctx *cli.Context) *mfer.ScannerOptions {
opts := &mfer.ScannerOptions{
IncludeDotfiles: ctx.Bool("include-dotfiles"),
FollowSymLinks: ctx.Bool("follow-symlinks"),
IncludeTimestamps: ctx.Bool("include-timestamps"),
Fs: mfa.Fs,
}
// Set seed for deterministic UUID if provided
if seed := ctx.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 := ctx.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(ctx *cli.Context, s *mfer.Scanner) error {
// Set up enumeration progress reporting
var (
enumProgress chan mfer.EnumerateStatus
enumWg sync.WaitGroup
)
if ctx.Bool("progress") {
enumProgress = make(chan mfer.EnumerateStatus, 1)
enumWg.Add(1)
go reportEnumProgress(enumProgress, &enumWg)
}
err := mfa.enumerateInputs(s, ctx.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 *cli.Context) error {
log.Debug("generateManifestOperation()")
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(ctx))
// Phase 1: Enumeration - collect paths and stat files
err := mfa.runEnumeratePhase(ctx, s)
if err != nil {
return err
}
showProgress := ctx.Bool("progress")
// Check if output file exists
outputPath := ctx.String("output")
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !ctx.Bool("force") {
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
}
// Create temp file for atomic write
tmpPath := outputPath + ".tmp"
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.Context, 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
}