Compare commits

1 Commits
Author SHA1 Message Date
sneak c2e40f3d25 Give -v to verbose, move --version to -V (closes #64)
check / check (push) Successful in 2m3s
urfave/cli's built-in version flag claimed -v, so "mfer -v --version"
failed to parse, and -v meant version at the root but verbose on
generate, check, freshen and fetch. Verbose is the more common meaning,
so the root now takes -v and -q too, the version flag takes -V, and a
-v or -q before the subcommand name applies to that subcommand.

User-visible changes: -v alone prints help with verbose logging, not
the version; use -V or --version. "mfer version" prints "mfer version
0.1.0 (...)", the same line as --version, instead of the bare
"0.1.0 (...)".

The error-message tests now send their log lines to io.Discard; they
were landing in other tests' output buffers.

Model: opus-4-8 (implementation); opus-5-5 (rework)
2026-10-04 01:10:43 +00:00
3 changed files with 73 additions and 9 deletions
+41
View File
@@ -177,6 +177,47 @@ func TestVersionFlagAndCommandMatch(t *testing.T) {
assert.Equal(t, testStdout(t, flagOpts), testStdout(t, cmdOpts)) assert.Equal(t, testStdout(t, flagOpts), testStdout(t, cmdOpts))
} }
// TestVerbosityFlagsBeforeSubcommand asserts that -q and -v given before the
// subcommand name take effect in that subcommand (issue #64).
func TestVerbosityFlagsBeforeSubcommand(t *testing.T) {
t.Parallel()
t.Run("quiet before fetch hides the banner", func(t *testing.T) {
t.Parallel()
opts := testOpts([]string{testApp, "-q", cmdFetch}, afero.NewMemMapFs())
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), errURLRequired.Error())
assert.NotContains(t, testStdout(t, opts), banner)
})
t.Run("verbose twice before fetch enables debug logging", func(t *testing.T) {
t.Parallel()
opts := testOpts([]string{testApp, "-v", "-v", cmdFetch}, afero.NewMemMapFs())
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), "fetchManifestOperation()")
})
t.Run("quiet before export hides the manifest summary", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
manifest := buildTestManifest(t, map[string][]byte{"a.txt": []byte("a")})
require.NoError(t, afero.WriteFile(fs, testManifest, manifest, 0o644))
loud := testOpts([]string{testApp, cmdExport, testManifest}, fs)
require.Equal(t, 0, runCLI(loud))
assert.Contains(t, testStderr(t, loud), "loaded manifest")
quiet := testOpts([]string{testApp, "-q", cmdExport, testManifest}, fs)
require.Equal(t, 0, runCLI(quiet))
assert.NotContains(t, testStderr(t, quiet), "loaded manifest")
})
}
func TestHelpCommand(t *testing.T) { func TestHelpCommand(t *testing.T) {
t.Parallel() t.Parallel()
+8 -2
View File
@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"flag" "flag"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -16,6 +17,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
urfcli "github.com/urfave/cli/v2" urfcli "github.com/urfave/cli/v2"
"sneak.berlin/go/mfer/internal/log"
"sneak.berlin/go/mfer/mfer" "sneak.berlin/go/mfer/mfer"
) )
@@ -36,12 +38,16 @@ const (
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
) )
// runLocked runs fn while holding runMu, so operations that write to the // runLocked runs fn while holding runMu and with the process-global logger
// process-global logger do not race the other CLI runs. // pointed at io.Discard, so fn's log lines neither race the other CLI runs
// nor land in the last run's buffers, which its test may still be reading.
func runLocked(fn func() error) error { func runLocked(fn func() error) error {
runMu.Lock() runMu.Lock()
defer runMu.Unlock() defer runMu.Unlock()
log.SetOutput(io.Discard, io.Discard)
log.Init()
return fn() return fn()
} }
+24 -7
View File
@@ -85,20 +85,34 @@ func (mfa *CLIApp) printBanner() {
} }
} }
// setVerbosity sets the log level from -v and -q, given before the subcommand
// name (the root's copies), after it (the subcommand's own copies), or both.
// urfave/cli reads a flag from the nearest command that defines it, so each
// command in the lineage is asked. The highest -v count wins rather than the
// sum, because a subcommand without its own copies reads the root's.
func (mfa *CLIApp) setVerbosity(c *cli.Context) { func (mfa *CLIApp) setVerbosity(c *cli.Context) {
_, present := os.LookupEnv("MFER_DEBUG") _, present := os.LookupEnv("MFER_DEBUG")
verbosity := 0
quiet := false
for _, ctx := range c.Lineage() {
verbosity = max(verbosity, ctx.Count("verbose"))
quiet = quiet || ctx.Bool("quiet")
}
switch { switch {
case present: case present:
log.EnableDebugLogging() log.EnableDebugLogging()
case c.Bool("quiet"): case quiet:
log.SetLevel(log.ErrorLevel) log.SetLevel(log.ErrorLevel)
default: default:
log.SetLevelFromVerbosity(c.Count("verbose")) log.SetLevelFromVerbosity(verbosity)
} }
} }
// commonFlags returns the flags shared by most commands (-v, -q) // commonFlags returns the -v and -q flags taken by the root and by the
// generate, check, freshen and fetch subcommands.
func commonFlags() []cli.Flag { func commonFlags() []cli.Flag {
return []cli.Flag{ return []cli.Flag{
&cli.BoolFlag{ &cli.BoolFlag{
@@ -266,6 +280,8 @@ func (mfa *CLIApp) exportCommand() *cli.Command {
Usage: "Export manifest contents as JSON", Usage: "Export manifest contents as JSON",
ArgsUsage: "[manifest file or URL]", ArgsUsage: "[manifest file or URL]",
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
mfa.setVerbosity(c)
return mfa.exportManifestOperation(c) return mfa.exportManifestOperation(c)
}, },
} }
@@ -333,10 +349,11 @@ func (mfa *CLIApp) run(args []string) {
log.Init() log.Init()
// -v means verbose, not version. urfave/cli's built-in version flag // -v means verbose, not version. urfave/cli's built-in version flag
// claims -v by default, colliding with the -v verbose alias used here and // claims -v by default, which made "mfer -v --version" fail to parse and
// on every subcommand; that collision makes "mfer -v --version" fail to // gave -v a different meaning at the root than on the generate, check,
// parse. Verbose is the more common meaning of -v in tools that offer // freshen and fetch subcommands, where it means verbose. Verbose is the
// both, so verbose keeps -v and the version flag takes the capital -V. // more common meaning of -v in tools that offer both, so -v means verbose
// at the root too and the version flag takes the capital -V.
// VersionFlag and VersionPrinter are urfave/cli package globals; run() is // VersionFlag and VersionPrinter are urfave/cli package globals; run() is
// serialized in tests, so assigning them here is safe. // serialized in tests, so assigning them here is safe.
cli.VersionFlag = &cli.BoolFlag{ cli.VersionFlag = &cli.BoolFlag{