Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 160db7710b Write gen DIR's manifest to DIR/index.mf (closes #178)
check / check (push) Canceled after 0s
Without --output, gen given one directory now writes index.mf in it,
and given one file writes index.mf beside it; with no path or several,
it still writes index.mf in the current directory. An --output given
with an empty value is refused. What gen lists depends only on its
arguments and the tree, never on where the manifest is written, so gen
DIR followed by check DIR passes.

Scanner.EnumeratePaths lists a file argument by its name, as
EnumerateFile does. Before, it listed the file under an empty path and
gen stopped with "path cannot be empty".

The --output help text and the README's Tool Examples state the default.

Model: opus-5-5
2026-10-08 00:14:25 +00:00
2 changed files with 108 additions and 31 deletions
+53
View File
@@ -1339,6 +1339,36 @@ func TestGenerateDefaultOutput(t *testing.T) {
} }
} }
// TestGenerateSeveralPathsDefaultOutput runs gen without --output on two
// directories: it writes index.mf in the current directory, listing both
// directories' files, and writes no index.mf in either directory.
//
//nolint:paralleltest // changes the process-global working directory
func TestGenerateSeveralPathsDefaultOutput(t *testing.T) {
root := t.TempDir()
fs := afero.NewOsFs()
dirs := []string{"first", "second"}
for _, dir := range dirs {
require.NoError(t, fs.MkdirAll(filepath.Join(root, dir), 0o750))
writeTestFile(t, fs, filepath.Join(root, dir, dir+".txt"), dir)
}
t.Chdir(root)
opts := testOpts(append([]string{testApp, cmdGenerate, "-q"}, dirs...), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, []string{"first.txt", "second.txt"},
manifestPaths(t, fs, filepath.Join(root, defaultManifestName)))
for _, dir := range dirs {
exists, err := afero.Exists(fs, filepath.Join(root, dir, defaultManifestName))
require.NoError(t, err)
assert.False(t, exists, "index.mf written in %s", dir)
}
}
// TestGenerateBytesDoNotDependOnOutput runs gen --seed on one tree, each // TestGenerateBytesDoNotDependOnOutput runs gen --seed on one tree, each
// time writing to another file, over a file already there and beside an // time writing to another file, over a file already there and beside an
// earlier run's temp file: neither is listed, and what is listed depends // earlier run's temp file: neither is listed, and what is listed depends
@@ -1410,6 +1440,29 @@ func TestGenerateRefusesExistingDefaultOutput(t *testing.T) {
assert.Equal(t, "previous manifest", string(content)) assert.Equal(t, "previous manifest", string(content))
} }
// TestGenerateRefusesEmptyOutput runs gen with --force and an --output
// given an empty value, as an unset shell variable gives it, on a
// directory already holding index.mf: gen fails and leaves that file as it
// was.
func TestGenerateRefusesEmptyOutput(t *testing.T) {
t.Parallel()
output := filepath.Join(testDir, defaultManifestName)
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testDir, 0o755))
writeTestFile(t, fs, testFile1, "hello")
writeTestFile(t, fs, output, "previous manifest")
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-f", "-o", "", testDir}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), errEmptyOutput.Error())
content, err := afero.ReadFile(fs, output)
require.NoError(t, err)
assert.Equal(t, "previous manifest", string(content))
}
func TestGenerateAtomicWriteUsesTemp(t *testing.T) { func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
t.Parallel() t.Parallel()
+55 -31
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@@ -26,6 +27,8 @@ var (
// rendered message stays exactly as mfer has always printed it. // rendered message stays exactly as mfer has always printed it.
errOutputExists = errors.New( errOutputExists = errors.New(
"already exists (use --force to overwrite)") "already exists (use --force to overwrite)")
// errEmptyOutput indicates --output given with an empty value.
errEmptyOutput = errors.New("--output must not be empty")
) )
// reportEnumProgress renders enumeration progress until the channel // reportEnumProgress renders enumeration progress until the channel
@@ -90,14 +93,20 @@ func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
// outputPath returns the file gen writes the manifest to: the one --output // outputPath returns the file gen writes the manifest to: the one --output
// names, or else index.mf in the directory the only argument names, or // names, or else index.mf in the directory the only argument names, or
// beside the file it names, or else in the current directory. // beside the file it names, or else in the current directory. An --output
func (mfa *CLIApp) outputPath(cmd *cli.Command) string { // given with an empty value is refused.
if output := cmd.String("output"); output != "" { func (mfa *CLIApp) outputPath(cmd *cli.Command) (string, error) {
return output if cmd.IsSet("output") {
output := cmd.String("output")
if output == "" {
return "", errEmptyOutput
}
return output, nil
} }
if cmd.Args().Len() != 1 { if cmd.Args().Len() != 1 {
return defaultManifestName return defaultManifestName, nil
} }
arg := cmd.Args().First() arg := cmd.Args().First()
@@ -105,10 +114,10 @@ func (mfa *CLIApp) outputPath(cmd *cli.Command) string {
// A path that does not exist is refused when it is enumerated. // A path that does not exist is refused when it is enumerated.
info, err := mfa.Fs.Stat(arg) info, err := mfa.Fs.Stat(arg)
if err == nil && !info.IsDir() { if err == nil && !info.IsDir() {
return filepath.Join(filepath.Dir(arg), defaultManifestName) return filepath.Join(filepath.Dir(arg), defaultManifestName), nil
} }
return filepath.Join(arg, defaultManifestName) return filepath.Join(arg, defaultManifestName), nil
} }
// buildScannerOptions constructs scanner options from the CLI flags and // buildScannerOptions constructs scanner options from the CLI flags and
@@ -227,21 +236,52 @@ func (mfa *CLIApp) runEnumeratePhase(cmd *cli.Command, s *mfer.Scanner) error {
return nil return nil
} }
// runScanPhase reads the enumerated files and writes the manifest to out,
// with optional progress reporting.
func (mfa *CLIApp) runScanPhase(
ctx context.Context, cmd *cli.Command, s *mfer.Scanner, out io.Writer,
) error {
var (
scanProgress chan mfer.ScanStatus
scanWg sync.WaitGroup
)
if cmd.Bool("progress") {
scanProgress = make(chan mfer.ScanStatus, 1)
scanWg.Add(1)
go reportScanProgress(scanProgress, &scanWg)
}
err := s.ToManifest(ctx, out, scanProgress)
scanWg.Wait()
if err != nil {
return fmt.Errorf("generate manifest: %w", err)
}
return nil
}
func (mfa *CLIApp) generateManifestOperation( func (mfa *CLIApp) generateManifestOperation(
ctx context.Context, cmd *cli.Command, ctx context.Context, cmd *cli.Command,
) error { ) error {
log.Debug("generateManifestOperation()") log.Debug("generateManifestOperation()")
outputPath := mfa.outputPath(cmd) outputPath, err := mfa.outputPath(cmd)
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd, outputPath))
// Phase 1: Enumeration - collect paths and stat files
err := mfa.runEnumeratePhase(cmd, s)
if err != nil { if err != nil {
return err return err
} }
showProgress := cmd.Bool("progress") s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd, outputPath))
// Phase 1: Enumeration - collect paths and stat files
err = mfa.runEnumeratePhase(cmd, s)
if err != nil {
return err
}
// Check if output file exists // Check if output file exists
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") { if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") {
@@ -274,25 +314,9 @@ func (mfa *CLIApp) generateManifestOperation(
}() }()
// Phase 2: Scan - read file contents and generate manifest // Phase 2: Scan - read file contents and generate manifest
var ( err = mfa.runScanPhase(ctx, cmd, s, outFile)
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 { if err != nil {
return fmt.Errorf("generate manifest: %w", err) return err
} }
// Close file before rename to ensure all data is flushed // Close file before rename to ensure all data is flushed