package cli import ( "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" ) func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error { log.Debug("generateManifestOperation()") opts := &mfer.ScannerOptions{ IncludeDotfiles: ctx.Bool("IncludeDotfiles"), FollowSymLinks: ctx.Bool("FollowSymLinks"), Fs: mfa.Fs, } s := mfer.NewScannerWithOptions(opts) // Phase 1: Enumeration - collect paths and stat files args := ctx.Args() showProgress := ctx.Bool("progress") // Set up enumeration progress reporting var enumProgress chan mfer.EnumerateStatus var enumWg sync.WaitGroup if showProgress { enumProgress = make(chan mfer.EnumerateStatus, 1) enumWg.Add(1) go func() { defer enumWg.Done() for status := range enumProgress { log.Progressf("Enumerating: %d files, %s", status.FilesFound, humanize.IBytes(uint64(status.BytesFound))) } log.ProgressDone() }() } if args.Len() == 0 { // Default to current directory if err := s.EnumeratePath(".", enumProgress); err != nil { return err } } else { // Collect and validate all paths first paths := make([]string, 0, args.Len()) for i := 0; i < args.Len(); i++ { inputPath := args.Get(i) ap, err := filepath.Abs(inputPath) if err != nil { return err } // Validate path exists before adding to list if exists, _ := afero.Exists(mfa.Fs, ap); !exists { return fmt.Errorf("path does not exist: %s", inputPath) } log.Debugf("enumerating path: %s", ap) paths = append(paths, ap) } if err := s.EnumeratePaths(enumProgress, paths...); err != nil { return err } } enumWg.Wait() log.Infof("enumerated %d files, %s total", s.FileCount(), humanize.IBytes(uint64(s.TotalBytes()))) // Check if output file exists outputPath := ctx.String("output") if exists, _ := afero.Exists(mfa.Fs, outputPath); exists { if !ctx.Bool("force") { return fmt.Errorf("output file %s already exists (use --force to overwrite)", 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 temp file: %w", err) } // Set up signal handler to clean up temp file on Ctrl-C 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) }() // 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 var scanWg sync.WaitGroup if showProgress { scanProgress = make(chan mfer.ScanStatus, 1) scanWg.Add(1) go func() { defer scanWg.Done() for status := range scanProgress { if status.ETA > 0 { log.Progressf("Scanning: %d/%d files, %s/s, ETA %s", status.ScannedFiles, status.TotalFiles, humanize.IBytes(uint64(status.BytesPerSec)), status.ETA.Round(time.Second)) } else { log.Progressf("Scanning: %d/%d files, %s/s", status.ScannedFiles, status.TotalFiles, humanize.IBytes(uint64(status.BytesPerSec))) } } log.ProgressDone() }() } 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 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 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 }