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))
}
// 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) {
t.Parallel()
+8 -2
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"flag"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -16,6 +17,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
urfcli "github.com/urfave/cli/v2"
"sneak.berlin/go/mfer/internal/log"
"sneak.berlin/go/mfer/mfer"
)
@@ -36,12 +38,16 @@ const (
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
)
// runLocked runs fn while holding runMu, so operations that write to the
// process-global logger do not race the other CLI runs.
// runLocked runs fn while holding runMu and with the process-global logger
// 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 {
runMu.Lock()
defer runMu.Unlock()
log.SetOutput(io.Discard, io.Discard)
log.Init()
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) {
_, 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 {
case present:
log.EnableDebugLogging()
case c.Bool("quiet"):
case quiet:
log.SetLevel(log.ErrorLevel)
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 {
return []cli.Flag{
&cli.BoolFlag{
@@ -266,6 +280,8 @@ func (mfa *CLIApp) exportCommand() *cli.Command {
Usage: "Export manifest contents as JSON",
ArgsUsage: "[manifest file or URL]",
Action: func(c *cli.Context) error {
mfa.setVerbosity(c)
return mfa.exportManifestOperation(c)
},
}
@@ -333,10 +349,11 @@ func (mfa *CLIApp) run(args []string) {
log.Init()
// -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
// on every subcommand; that collision makes "mfer -v --version" fail to
// parse. Verbose is the more common meaning of -v in tools that offer
// both, so verbose keeps -v and the version flag takes the capital -V.
// claims -v by default, which made "mfer -v --version" fail to parse and
// gave -v a different meaning at the root than on the generate, check,
// freshen and fetch subcommands, where it means verbose. Verbose is the
// 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
// serialized in tests, so assigning them here is safe.
cli.VersionFlag = &cli.BoolFlag{