diff --git a/.claude/codebase/structure.md b/.claude/codebase/structure.md index 20f79ecf..6fe0db7a 100644 --- a/.claude/codebase/structure.md +++ b/.claude/codebase/structure.md @@ -9,7 +9,7 @@ ## Directory Layout ``` -cmd/micasa/main.go CLI entry (kong). runCmd, backupCmd, configCmd +cmd/micasa/main.go CLI entry (cobra). runOpts, backupOpts, newRootCmd internal/ app/ TUI package (~30K lines, largest package) model.go Model struct, Init/Update/View, key dispatch @@ -119,5 +119,5 @@ internal/ - `lrstanley/bubblezone` - Mouse zone tracking - `rmhubbert/bubbletea-overlay` - Overlay compositing - `brianvoe/gofakeit` - Random data generation -- `alecthomas/kong` - CLI parsing +- `spf13/cobra` - CLI parsing - `stretchr/testify` - Test assertions diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 642bbf8d..c8db38d1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -228,7 +228,7 @@ New status or season-like enum values require updates in four places: | `mozilla-ai/any-llm-go` | Multi-provider LLM client | | `lrstanley/bubblezone` | Mouse zone tracking | | `stretchr/testify` | Test assertions | -| `alecthomas/kong` | CLI parsing | +| `spf13/cobra` | CLI parsing | ## Nix Development Environment diff --git a/cmd/micasa/completion.go b/cmd/micasa/completion.go new file mode 100644 index 00000000..ace98d79 --- /dev/null +++ b/cmd/micasa/completion.go @@ -0,0 +1,52 @@ +// Copyright 2026 Phillip Cloud +// Licensed under the Apache License, Version 2.0 + +package main + +import ( + "github.com/spf13/cobra" +) + +func newCompletionCmd(root *cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "completion [bash|zsh|fish]", + Short: "Generate shell completion scripts", + SilenceErrors: true, + SilenceUsage: true, + } + + cmd.AddCommand( + &cobra.Command{ + Use: "bash", + Short: "Generate bash completion script", + SilenceErrors: true, + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return root.GenBashCompletionV2(cmd.OutOrStdout(), true) + }, + }, + &cobra.Command{ + Use: "zsh", + Short: "Generate zsh completion script", + SilenceErrors: true, + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return root.GenZshCompletion(cmd.OutOrStdout()) + }, + }, + &cobra.Command{ + Use: "fish", + Short: "Generate fish completion script", + SilenceErrors: true, + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return root.GenFishCompletion(cmd.OutOrStdout(), true) + }, + }, + ) + + return cmd +} diff --git a/cmd/micasa/help.go b/cmd/micasa/help.go new file mode 100644 index 00000000..a37a0220 --- /dev/null +++ b/cmd/micasa/help.go @@ -0,0 +1,111 @@ +// Copyright 2026 Phillip Cloud +// Licensed under the Apache License, Version 2.0 + +package main + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Wong palette colors (duplicated from internal/app/styles.go so the CLI +// binary does not import the full TUI package). +var ( + helpAccent = lipgloss.AdaptiveColor{Light: "#0072B2", Dark: "#56B4E9"} + helpSecondary = lipgloss.AdaptiveColor{Light: "#D55E00", Dark: "#E69F00"} + helpDim = lipgloss.AdaptiveColor{Light: "#4B5563", Dark: "#6B7280"} + helpMid = lipgloss.AdaptiveColor{Light: "#4B5563", Dark: "#9CA3AF"} +) + +var ( + helpHeading = lipgloss.NewStyle(). + Foreground(helpAccent). + Bold(true) + helpCmd = lipgloss.NewStyle(). + Foreground(helpSecondary) + helpFlag = lipgloss.NewStyle(). + Foreground(helpSecondary) + helpDesc = lipgloss.NewStyle(). + Foreground(helpMid) + helpDimStyle = lipgloss.NewStyle(). + Foreground(helpDim) +) + +func styledHelp(cmd *cobra.Command, _ []string) { + var b strings.Builder + + if cmd.Long != "" { + fmt.Fprintln(&b, helpDesc.Render(cmd.Long)) + fmt.Fprintln(&b) + } else if cmd.Short != "" { + fmt.Fprintln(&b, helpDesc.Render(cmd.Short)) + fmt.Fprintln(&b) + } + + if cmd.Runnable() { + fmt.Fprintln(&b, helpHeading.Render("Usage")) + fmt.Fprintf(&b, " %s\n\n", helpDimStyle.Render(cmd.UseLine())) + } + + if cmd.HasAvailableSubCommands() { + fmt.Fprintln(&b, helpHeading.Render("Commands")) + maxLen := 0 + for _, c := range cmd.Commands() { + if !c.IsAvailableCommand() && c.Name() != "help" { + continue + } + if n := len(c.Name()); n > maxLen { + maxLen = n + } + } + for _, c := range cmd.Commands() { + if !c.IsAvailableCommand() && c.Name() != "help" { + continue + } + name := helpCmd.Render(c.Name()) + pad := strings.Repeat(" ", maxLen-len(c.Name())) + fmt.Fprintf(&b, " %s%s %s\n", name, pad, helpDesc.Render(c.Short)) + } + fmt.Fprintln(&b) + } + + if cmd.HasAvailableLocalFlags() { + fmt.Fprintln(&b, helpHeading.Render("Flags")) + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + var names string + if f.Shorthand != "" { + names = fmt.Sprintf("-%s, --%s", f.Shorthand, f.Name) + } else { + names = fmt.Sprintf(" --%s", f.Name) + } + fmt.Fprintf(&b, " %s %s\n", helpFlag.Render(names), helpDesc.Render(f.Usage)) + }) + fmt.Fprintln(&b) + } + + if cmd.HasAvailableInheritedFlags() { + fmt.Fprintln(&b, helpHeading.Render("Global Flags")) + cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + var names string + if f.Shorthand != "" { + names = fmt.Sprintf("-%s, --%s", f.Shorthand, f.Name) + } else { + names = fmt.Sprintf(" --%s", f.Name) + } + fmt.Fprintf(&b, " %s %s\n", helpFlag.Render(names), helpDesc.Render(f.Usage)) + }) + fmt.Fprintln(&b) + } + + _, _ = fmt.Fprint(cmd.OutOrStdout(), b.String()) +} diff --git a/cmd/micasa/main.go b/cmd/micasa/main.go index 4c272d7d..04ad5b26 100644 --- a/cmd/micasa/main.go +++ b/cmd/micasa/main.go @@ -7,62 +7,81 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "runtime/debug" - "github.com/alecthomas/kong" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/cpcloud/micasa/internal/app" "github.com/cpcloud/micasa/internal/config" "github.com/cpcloud/micasa/internal/data" "github.com/cpcloud/micasa/internal/extract" + "github.com/spf13/cobra" ) // version is set at build time via -ldflags "-X main.version=...". var version = "dev" -type cli struct { - Run runCmd `cmd:"" default:"withargs" help:"Launch the TUI (default)."` - Backup backupCmd `cmd:"" help:"Back up the database to a file."` - Config configCmd `cmd:"" help:"Manage application configuration."` - Version kong.VersionFlag ` help:"Show version and exit." name:"version"` +// runOpts holds flags for the root (TUI launcher) command. +type runOpts struct { + dbPath string + demo bool + years int + printPath bool } -type runCmd struct { - DBPath string `arg:"" optional:"" help:"SQLite database path. Pass with --demo to persist demo data." env:"MICASA_DB_PATH"` - Demo bool ` help:"Launch with sample data in an in-memory database."` - Years int ` help:"Generate N years of simulated home ownership data. Requires --demo."` - PrintPath bool ` help:"Print the resolved database path and exit."` +// backupOpts holds flags for the backup subcommand. +type backupOpts struct { + dest string + source string + envDBPath string // populated from MICASA_DB_PATH in RunE } -type backupCmd struct { - Dest string `arg:"" optional:"" help:"Destination file path. Defaults to .backup."` - Source string ` help:"Source database path. Defaults to the standard location." default:"" env:"MICASA_DB_PATH"` -} +func newRootCmd() *cobra.Command { + opts := &runOpts{} -type configCmd struct { - Get configGetCmd `cmd:"" default:"withargs" help:"Query config values with a jq filter (default: identity)."` - Edit configEditCmd `cmd:"" help:"Open the config file in an editor."` -} + root := &cobra.Command{ + Use: data.AppName + " [database-path]", + Short: "A terminal UI for tracking everything about your home", + Long: "A terminal UI for tracking everything about your home.", + // Accept 0 or 1 positional args (optional database path). + Args: cobra.MaximumNArgs(1), + SilenceErrors: true, + SilenceUsage: true, + Version: versionString(), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.dbPath = args[0] + } + return runTUI(cmd.OutOrStdout(), opts) + }, + } + root.SetVersionTemplate("{{.Version}}\n") + root.SetHelpFunc(styledHelp) + root.CompletionOptions.HiddenDefaultCmd = true -type configGetCmd struct { - Filter string `arg:"" optional:"" help:"jq filter expression, e.g. .chat.llm.model or .extraction (default: identity)."` -} + root.Flags(). + BoolVar(&opts.demo, "demo", false, "Launch with sample data in an in-memory database") + root.Flags(). + IntVar(&opts.years, "years", 0, "Generate N years of simulated home ownership data (requires --demo)") + root.Flags(). + BoolVar(&opts.printPath, "print-path", false, "Print the resolved database path and exit") -type configEditCmd struct{} + root.AddCommand( + newBackupCmd(), + newConfigCmd(), + newCompletionCmd(root), + ) + + return root +} func main() { - var c cli - kctx := kong.Parse(&c, - kong.Name(data.AppName), - kong.Description("A terminal UI for tracking everything about your home."), - kong.UsageOnError(), - kong.Vars{"version": versionString()}, - ) - if err := kctx.Run(); err != nil { + root := newRootCmd() + if err := root.Execute(); err != nil { if errors.Is(err, tea.ErrInterrupted) { os.Exit(130) } @@ -71,19 +90,19 @@ func main() { } } -func (cmd *runCmd) Run() error { - dbPath, err := cmd.resolveDBPath() +func runTUI(w io.Writer, opts *runOpts) error { + dbPath, err := opts.resolveDBPath() if err != nil { return fmt.Errorf("resolve db path: %w", err) } - if cmd.PrintPath { - fmt.Println(dbPath) + if opts.printPath { + _, _ = fmt.Fprintln(w, dbPath) return nil } - if cmd.Years > 0 && !cmd.Demo { + if opts.years > 0 && !opts.demo { return fmt.Errorf("--years requires --demo") } - if cmd.Years < 0 { + if opts.years < 0 { return fmt.Errorf("--years must be non-negative") } store, err := data.Open(dbPath) @@ -96,16 +115,16 @@ func (cmd *runCmd) Run() error { if err := store.SeedDefaults(); err != nil { return fmt.Errorf("seed defaults: %w", err) } - if cmd.Demo { - if cmd.Years > 0 { - summary, err := store.SeedScaledData(cmd.Years) + if opts.demo { + if opts.years > 0 { + summary, err := store.SeedScaledData(opts.years) if err != nil { return fmt.Errorf("seed scaled data: %w", err) } fmt.Fprintf( os.Stderr, "seeded %d years: %d vendors, %d projects, %d appliances, %d maintenance, %d service logs, %d quotes, %d documents\n", - cmd.Years, + opts.years, summary.Vendors, summary.Projects, summary.Appliances, @@ -148,14 +167,14 @@ func (cmd *runCmd) Run() error { return fmt.Errorf("resolve currency: %w", err) } - opts := app.Options{ + appOpts := app.Options{ DBPath: dbPath, ConfigPath: config.Path(), FilePickerDir: cfg.Documents.ResolvedFilePickerDir(), } chatLLM := cfg.Chat.LLM - opts.SetChat( + appOpts.SetChat( cfg.Chat.IsEnabled(), chatLLM.Provider, chatLLM.BaseURL, @@ -172,7 +191,7 @@ func (cmd *runCmd) Run() error { 0, // pdftotext uses its own internal default timeout (30s) cfg.Extraction.OCR.IsEnabled(), ) - opts.SetExtraction( + appOpts.SetExtraction( exLLM.Provider, exLLM.BaseURL, exLLM.Model, @@ -185,7 +204,7 @@ func (cmd *runCmd) Run() error { cfg.Extraction.OCR.TSV.Threshold(), ) - model, err := app.NewModel(store, opts) + model, err := app.NewModel(store, appOpts) if err != nil { return fmt.Errorf("initialize app: %w", err) } @@ -200,57 +219,62 @@ func (cmd *runCmd) Run() error { return nil } -func (cmd *runCmd) resolveDBPath() (string, error) { - if cmd.DBPath != "" { - return data.ExpandHome(cmd.DBPath), nil - } - if cmd.Demo { +// resolveDBPath returns the database path to use. Precedence: +// 1. Explicit positional arg (opts.dbPath) +// 2. --demo → ":memory:" +// 3. data.DefaultDBPath(), which honors MICASA_DB_PATH env var internally +func (opts *runOpts) resolveDBPath() (string, error) { + if opts.dbPath != "" { + return data.ExpandHome(opts.dbPath), nil + } + if opts.demo { return ":memory:", nil } return data.DefaultDBPath() } -func (cmd *configGetCmd) Run() error { - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("load config: %w", err) +func newBackupCmd() *cobra.Command { + opts := &backupOpts{} + + cmd := &cobra.Command{ + Use: "backup [destination]", + Short: "Back up the database to a file", + Args: cobra.MaximumNArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.dest = args[0] + } + opts.envDBPath = os.Getenv("MICASA_DB_PATH") + return runBackup(cmd.OutOrStdout(), opts) + }, } - return cfg.Query(os.Stdout, cmd.Filter) + + cmd.Flags(). + StringVar(&opts.source, "source", "", "Source database path (default: standard location, honors MICASA_DB_PATH)") + + return cmd } -func (cmd *configEditCmd) Run() error { - path := config.Path() - if err := config.EnsureConfigFile(path); err != nil { - return err +// resolveBackupSource returns the source database path for backup. Precedence: +// 1. Explicit --source flag +// 2. MICASA_DB_PATH env var (passed via opts.envDBPath) +// 3. data.DefaultDBPath() platform default +func (opts *backupOpts) resolveBackupSource() (string, error) { + if opts.source != "" { + return data.ExpandHome(opts.source), nil } - name, args, err := config.EditorCommand(path) - if err != nil { - return err + if opts.envDBPath != "" { + return opts.envDBPath, nil } - c := exec.CommandContext( //nolint:gosec // user-controlled editor from $VISUAL/$EDITOR - context.Background(), - name, - args..., - ) - c.Stdin = os.Stdin - c.Stdout = os.Stdout - c.Stderr = os.Stderr - if err := c.Run(); err != nil { - return fmt.Errorf("run editor: %w", err) - } - return nil + return data.DefaultDBPath() } -func (cmd *backupCmd) Run() error { - sourcePath := cmd.Source - if sourcePath == "" { - var err error - sourcePath, err = data.DefaultDBPath() - if err != nil { - return fmt.Errorf("resolve source path: %w", err) - } - } else { - sourcePath = data.ExpandHome(sourcePath) +func runBackup(w io.Writer, opts *backupOpts) error { + sourcePath, err := opts.resolveBackupSource() + if err != nil { + return fmt.Errorf("resolve source path: %w", err) } if sourcePath == ":memory:" { return fmt.Errorf("cannot back up an in-memory database") @@ -262,7 +286,7 @@ func (cmd *backupCmd) Run() error { ) } - destPath := cmd.Dest + destPath := opts.dest if destPath == "" { destPath = sourcePath + ".backup" } else { @@ -304,7 +328,89 @@ func (cmd *backupCmd) Run() error { if err != nil { return fmt.Errorf("resolve absolute path: %w", err) } - fmt.Println(absPath) + _, _ = fmt.Fprintln(w, absPath) + return nil +} + +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config [filter]", + Short: "Manage application configuration", + Args: cobra.MaximumNArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + var filter string + if len(args) > 0 { + filter = args[0] + } + return runConfigGet(cmd.OutOrStdout(), filter) + }, + } + + cmd.AddCommand(newConfigGetCmd()) + cmd.AddCommand(newConfigEditCmd()) + + return cmd +} + +func newConfigGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get [filter]", + Short: "Query config values with a jq filter (default: identity)", + Args: cobra.MaximumNArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + var filter string + if len(args) > 0 { + filter = args[0] + } + return runConfigGet(cmd.OutOrStdout(), filter) + }, + } +} + +func runConfigGet(w io.Writer, filter string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + return cfg.Query(w, filter) +} + +func newConfigEditCmd() *cobra.Command { + return &cobra.Command{ + Use: "edit", + Short: "Open the config file in an editor", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + return runConfigEdit(config.Path()) + }, + } +} + +func runConfigEdit(path string) error { + if err := config.EnsureConfigFile(path); err != nil { + return err + } + name, args, err := config.EditorCommand(path) + if err != nil { + return err + } + c := exec.CommandContext( //nolint:gosec // user-controlled editor from $VISUAL/$EDITOR + context.Background(), + name, + args..., + ) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("run editor: %w", err) + } return nil } diff --git a/cmd/micasa/main_test.go b/cmd/micasa/main_test.go index 618c6cc7..eabd567c 100644 --- a/cmd/micasa/main_test.go +++ b/cmd/micasa/main_test.go @@ -4,22 +4,82 @@ package main import ( + "bytes" + "context" + "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" + "sync" "testing" + "time" "github.com/cpcloud/micasa/internal/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + code := m.Run() + if testBin != "" { + _ = os.RemoveAll(filepath.Dir(testBin)) + } + os.Exit(code) +} + +// executeCLI runs the CLI in-process with the given args and returns +// captured stdout and any error. +func executeCLI(args ...string) (string, error) { + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs(args) + err := root.Execute() + return buf.String(), err +} + +// testBin is a lazily-built binary for the few tests that need subprocess +// isolation (env vars, VCS info). Built once via sync.Once. +var ( + testBin string + testBinOnce sync.Once + testBinErr error +) + +func getTestBin(t *testing.T) string { + t.Helper() + testBinOnce.Do(func() { + ext := "" + if runtime.GOOS == "windows" { + ext = ".exe" + } + dir, err := os.MkdirTemp("", "micasa-test-*") + if err != nil { + testBinErr = fmt.Errorf("create temp dir: %w", err) + return + } + bin := filepath.Join(dir, "micasa"+ext) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", "-o", bin, ".") + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + out, err := cmd.CombinedOutput() + if err != nil { + testBinErr = fmt.Errorf("build: %w\n%s", err, out) + return + } + testBin = bin + }) + require.NoError(t, testBinErr, "building test binary") + return testBin +} + func TestResolveDBPath_ExplicitPath(t *testing.T) { t.Parallel() - cmd := runCmd{DBPath: "/custom/path.db"} - got, err := cmd.resolveDBPath() + opts := runOpts{dbPath: "/custom/path.db"} + got, err := opts.resolveDBPath() require.NoError(t, err) assert.Equal(t, "/custom/path.db", got) } @@ -27,16 +87,16 @@ func TestResolveDBPath_ExplicitPath(t *testing.T) { func TestResolveDBPath_ExplicitPathWithDemo(t *testing.T) { t.Parallel() // Explicit path takes precedence even when --demo is set. - cmd := runCmd{DBPath: "/tmp/demo.db", Demo: true} - got, err := cmd.resolveDBPath() + opts := runOpts{dbPath: "/tmp/demo.db", demo: true} + got, err := opts.resolveDBPath() require.NoError(t, err) assert.Equal(t, "/tmp/demo.db", got) } func TestResolveDBPath_DemoNoPath(t *testing.T) { t.Parallel() - cmd := runCmd{Demo: true} - got, err := cmd.resolveDBPath() + opts := runOpts{demo: true} + got, err := opts.resolveDBPath() require.NoError(t, err) assert.Equal(t, ":memory:", got) } @@ -45,8 +105,8 @@ func TestResolveDBPath_Default(t *testing.T) { // With no flags, resolveDBPath falls through to DefaultDBPath. // Clear the env override so the platform default is used. t.Setenv("MICASA_DB_PATH", "") - cmd := runCmd{} - got, err := cmd.resolveDBPath() + opts := runOpts{} + got, err := opts.resolveDBPath() require.NoError(t, err) assert.NotEmpty(t, got) assert.True( @@ -60,8 +120,8 @@ func TestResolveDBPath_Default(t *testing.T) { func TestResolveDBPath_EnvOverride(t *testing.T) { // MICASA_DB_PATH env var is honored when no positional arg is given. t.Setenv("MICASA_DB_PATH", "/env/override.db") - cmd := runCmd{} - got, err := cmd.resolveDBPath() + opts := runOpts{} + got, err := opts.resolveDBPath() require.NoError(t, err) assert.Equal(t, "/env/override.db", got) } @@ -69,36 +129,12 @@ func TestResolveDBPath_EnvOverride(t *testing.T) { func TestResolveDBPath_ExplicitPathBeatsEnv(t *testing.T) { // Positional arg takes precedence over env var. t.Setenv("MICASA_DB_PATH", "/env/override.db") - cmd := runCmd{DBPath: "/explicit/wins.db"} - got, err := cmd.resolveDBPath() + opts := runOpts{dbPath: "/explicit/wins.db"} + got, err := opts.resolveDBPath() require.NoError(t, err) assert.Equal(t, "/explicit/wins.db", got) } -// Version tests use exec.Command("go", "build") because debug.ReadBuildInfo() -// only embeds VCS revision info in binaries built with go build, not go test, -// and -ldflags -X injection likewise requires a real build step. - -func buildTestBinary(t *testing.T) string { - t.Helper() - ext := "" - if runtime.GOOS == "windows" { - ext = ".exe" - } - bin := filepath.Join(t.TempDir(), "micasa"+ext) - cmd := exec.CommandContext(t.Context(), - "go", - "build", - "-o", - bin, - ".", - ) - cmd.Env = append(os.Environ(), "CGO_ENABLED=0") - out, err := cmd.CombinedOutput() - require.NoError(t, err, "build failed:\n%s", out) - return bin -} - func TestVersion_DevShowsCommitHash(t *testing.T) { t.Parallel() // Skip when there is no .git directory (e.g. Nix sandbox builds from a @@ -106,7 +142,7 @@ func TestVersion_DevShowsCommitHash(t *testing.T) { if _, err := os.Stat(".git"); err != nil { t.Skip("no .git directory; VCS info unavailable (e.g. Nix sandbox)") } - bin := buildTestBinary(t) + bin := getTestBin(t) verCmd := exec.CommandContext( t.Context(), bin, @@ -121,111 +157,104 @@ func TestVersion_DevShowsCommitHash(t *testing.T) { } func TestVersion_Injected(t *testing.T) { - t.Parallel() - ext := "" - if runtime.GOOS == "windows" { - ext = ".exe" - } - bin := filepath.Join(t.TempDir(), "micasa"+ext) - cmd := exec.CommandContext(t.Context(), "go", "build", - "-ldflags", "-X main.version=1.2.3", - "-o", bin, ".") - cmd.Env = append(os.Environ(), "CGO_ENABLED=0") - out, err := cmd.CombinedOutput() - require.NoError(t, err, "build failed:\n%s", out) - verCmd := exec.CommandContext( - t.Context(), - bin, - "--version", - ) - verOut, err := verCmd.Output() - require.NoError(t, err, "--version failed") - assert.Equal(t, "1.2.3", strings.TrimSpace(string(verOut))) + // Not parallel: mutates the package-level version variable. + old := version + t.Cleanup(func() { version = old }) + version = "1.2.3" + assert.Equal(t, "1.2.3", versionString()) } func TestConfigCmd(t *testing.T) { t.Parallel() - bin := buildTestBinary(t) t.Run("GetScalar", func(t *testing.T) { - cmd := exec.CommandContext(t.Context(), bin, "config", "get", ".chat.llm.model") - out, err := cmd.CombinedOutput() - require.NoError(t, err, "config get .chat.llm.model failed: %s", out) - got := strings.TrimSpace(string(out)) + t.Parallel() + out, err := executeCLI("config", "get", ".chat.llm.model") + require.NoError(t, err) + got := strings.TrimSpace(out) assert.NotEmpty(t, got) assert.NotContains(t, got, `"`, "scalar should not be JSON-quoted") }) t.Run("GetSection", func(t *testing.T) { - cmd := exec.CommandContext(t.Context(), bin, "config", "get", ".chat.llm") - out, err := cmd.CombinedOutput() - require.NoError(t, err, "config get .chat.llm failed: %s", out) - s := string(out) - assert.Contains(t, s, "model =") - assert.Contains(t, s, "provider =") - assert.NotContains(t, s, "api_key") + t.Parallel() + out, err := executeCLI("config", "get", ".chat.llm") + require.NoError(t, err) + assert.Contains(t, out, "model =") + assert.Contains(t, out, "provider =") + assert.NotContains(t, out, "api_key") }) t.Run("GetNull", func(t *testing.T) { - cmd := exec.CommandContext(t.Context(), bin, "config", "get", ".bogus") - out, err := cmd.CombinedOutput() - require.NoError(t, err, "config get .bogus failed: %s", out) - assert.Equal(t, "null\n", string(out)) + t.Parallel() + out, err := executeCLI("config", "get", ".bogus") + require.NoError(t, err) + assert.Equal(t, "null\n", out) }) t.Run("GetKeys", func(t *testing.T) { - cmd := exec.CommandContext(t.Context(), bin, "config", "get", ".chat.llm | keys") - out, err := cmd.CombinedOutput() - require.NoError(t, err, "config get '.chat.llm | keys' failed: %s", out) - assert.Contains(t, string(out), `"model"`) + t.Parallel() + out, err := executeCLI("config", "get", ".chat.llm | keys") + require.NoError(t, err) + assert.Contains(t, out, `"model"`) }) t.Run("GetDefaultShowConfig", func(t *testing.T) { - cmd := exec.CommandContext(t.Context(), bin, "config", "get") - out, err := cmd.CombinedOutput() - require.NoError(t, err, "config get (no filter) failed: %s", out) - assert.Contains(t, string(out), "[chat.llm]") - assert.Contains(t, string(out), "model =") + t.Parallel() + out, err := executeCLI("config", "get") + require.NoError(t, err) + assert.Contains(t, out, "[chat.llm]") + assert.Contains(t, out, "model =") }) t.Run("GetDefaultViaConfig", func(t *testing.T) { - cmd := exec.CommandContext(t.Context(), bin, "config") - out, err := cmd.CombinedOutput() - require.NoError(t, err, "config (no args) failed: %s", out) - assert.Contains(t, string(out), "[chat.llm]") - assert.Contains(t, string(out), "model =") + t.Parallel() + out, err := executeCLI("config") + require.NoError(t, err) + assert.Contains(t, out, "[chat.llm]") + assert.Contains(t, out, "model =") }) +} - t.Run("EditCreatesConfig", func(t *testing.T) { - tmpDir := t.TempDir() - cmd := exec.CommandContext(t.Context(), bin, "config", "edit") - cmd.Env = envWithEditor(tmpDir, noopEditor()) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "config edit failed: %s", out) +func TestConfigEditCreatesConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + t.Setenv("EDITOR", noopEditor()) + t.Setenv("VISUAL", noopEditor()) + require.NoError(t, runConfigEdit(configPath)) - configPath := filepath.Join(tmpDir, "micasa", "config.toml") - info, statErr := os.Stat(configPath) - require.NoError(t, statErr, "config file should have been created") - assert.Positive(t, info.Size(), "config file should not be empty") - }) + info, statErr := os.Stat(configPath) + require.NoError(t, statErr, "config file should have been created") + assert.Positive(t, info.Size(), "config file should not be empty") +} - t.Run("EditExistingConfig", func(t *testing.T) { - tmpDir := t.TempDir() - dir := filepath.Join(tmpDir, "micasa") - require.NoError(t, os.MkdirAll(dir, 0o750)) - configPath := filepath.Join(dir, "config.toml") - original := "[locale]\ncurrency = \"EUR\"\n" - require.NoError(t, os.WriteFile(configPath, []byte(original), 0o600)) +func TestConfigEditExistingConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + original := "[locale]\ncurrency = \"EUR\"\n" + require.NoError(t, os.WriteFile(configPath, []byte(original), 0o600)) - cmd := exec.CommandContext(t.Context(), bin, "config", "edit") - cmd.Env = envWithEditor(tmpDir, noopEditor()) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "config edit failed: %s", out) + t.Setenv("EDITOR", noopEditor()) + t.Setenv("VISUAL", noopEditor()) + require.NoError(t, runConfigEdit(configPath)) - content, readErr := os.ReadFile(configPath) //nolint:gosec // test reads its own temp file - require.NoError(t, readErr) - assert.Equal(t, original, string(content), "existing config should be untouched") - }) + content, readErr := os.ReadFile(configPath) //nolint:gosec // test reads its own temp file + require.NoError(t, readErr) + assert.Equal(t, original, string(content), "existing config should be untouched") +} + +func TestCompletionCmd(t *testing.T) { + t.Parallel() + + for _, shell := range []string{"bash", "zsh", "fish"} { + t.Run(shell, func(t *testing.T) { + t.Parallel() + out, err := executeCLI("completion", shell) + require.NoError(t, err) + assert.NotEmpty(t, out) + assert.Contains(t, out, "micasa", "completion script should reference the app name") + }) + } } // createTestDB creates a migrated, seeded SQLite database file and returns @@ -243,23 +272,15 @@ func createTestDB(t *testing.T) string { func TestBackupCmd(t *testing.T) { t.Parallel() - bin := buildTestBinary(t) t.Run("ExplicitDest", func(t *testing.T) { + t.Parallel() src := createTestDB(t) dest := filepath.Join(t.TempDir(), "backup.db") - cmd := exec.CommandContext( - t.Context(), - bin, - "backup", - "--source", - src, - dest, - ) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "backup failed: %s", out) + out, err := executeCLI("backup", "--source", src, dest) + require.NoError(t, err) - got := strings.TrimSpace(string(out)) + got := strings.TrimSpace(out) assert.True(t, filepath.IsAbs(got), "expected absolute path, got %q", got) _, statErr := os.Stat(dest) @@ -267,50 +288,37 @@ func TestBackupCmd(t *testing.T) { }) t.Run("DefaultDest", func(t *testing.T) { + t.Parallel() src := createTestDB(t) - cmd := exec.CommandContext( - t.Context(), - bin, - "backup", - "--source", - src, - ) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "backup failed: %s", out) + out, err := executeCLI("backup", "--source", src) + require.NoError(t, err) wantPath, absErr := filepath.Abs(src + ".backup") require.NoError(t, absErr) - assert.Equal(t, wantPath, strings.TrimSpace(string(out))) + assert.Equal(t, wantPath, strings.TrimSpace(out)) _, statErr := os.Stat(src + ".backup") assert.NoError(t, statErr, "default destination should exist") }) t.Run("SourceFromEnv", func(t *testing.T) { + t.Parallel() src := createTestDB(t) dest := filepath.Join(t.TempDir(), "env-backup.db") - cmd := exec.CommandContext(t.Context(), bin, "backup", dest) - cmd.Env = append(os.Environ(), "MICASA_DB_PATH="+src) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "backup via MICASA_DB_PATH failed: %s", out) + var buf bytes.Buffer + err := runBackup(&buf, &backupOpts{dest: dest, envDBPath: src}) + require.NoError(t, err) _, statErr := os.Stat(dest) assert.NoError(t, statErr, "destination file should exist") }) t.Run("ProducesValidDB", func(t *testing.T) { + t.Parallel() src := createTestDB(t) dest := filepath.Join(t.TempDir(), "valid-backup.db") - cmd := exec.CommandContext( - t.Context(), - bin, - "backup", - "--source", - src, - dest, - ) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "backup failed: %s", out) + _, err := executeCLI("backup", "--source", src, dest) + require.NoError(t, err) backup, openErr := data.Open(dest) require.NoError(t, openErr, "backup should be a valid SQLite database") @@ -318,66 +326,42 @@ func TestBackupCmd(t *testing.T) { }) t.Run("MemorySourceRejected", func(t *testing.T) { + t.Parallel() dest := filepath.Join(t.TempDir(), "backup.db") - cmd := exec.CommandContext(t.Context(), - bin, - "backup", - "--source", - ":memory:", - dest, - ) - out, err := cmd.CombinedOutput() + _, err := executeCLI("backup", "--source", ":memory:", dest) require.Error(t, err) - assert.Contains(t, string(out), "in-memory") + assert.ErrorContains(t, err, "in-memory") }) t.Run("DestAlreadyExists", func(t *testing.T) { + t.Parallel() src := createTestDB(t) dest := filepath.Join(t.TempDir(), "existing.db") require.NoError(t, os.WriteFile(dest, []byte("x"), 0o600)) - cmd := exec.CommandContext( - t.Context(), - bin, - "backup", - "--source", - src, - dest, - ) - out, err := cmd.CombinedOutput() + _, err := executeCLI("backup", "--source", src, dest) require.Error(t, err) - assert.Contains(t, string(out), "already exists") + assert.ErrorContains(t, err, "already exists") }) t.Run("SourceNotFound", func(t *testing.T) { + t.Parallel() dest := filepath.Join(t.TempDir(), "backup.db") - cmd := exec.CommandContext(t.Context(), - bin, - "backup", - "--source", - "/nonexistent/path.db", - dest, - ) - out, err := cmd.CombinedOutput() + _, err := executeCLI("backup", "--source", "/nonexistent/path.db", dest) require.Error(t, err) - assert.Contains(t, string(out), "not found") + assert.ErrorContains(t, err, "not found") }) t.Run("InvalidDestPath", func(t *testing.T) { + t.Parallel() src := createTestDB(t) - cmd := exec.CommandContext(t.Context(), - bin, - "backup", - "--source", - src, - "file:///tmp/backup.db?mode=rwc", - ) - out, err := cmd.CombinedOutput() + _, err := executeCLI("backup", "--source", src, "file:///tmp/backup.db?mode=rwc") require.Error(t, err) - assert.Contains(t, string(out), "invalid destination") + assert.ErrorContains(t, err, "invalid destination") }) t.Run("SourceNotMicasaDB", func(t *testing.T) { + t.Parallel() // Create a valid SQLite database that isn't a micasa database. src := filepath.Join(t.TempDir(), "other.db") otherStore, err := data.Open(src) @@ -385,17 +369,9 @@ func TestBackupCmd(t *testing.T) { require.NoError(t, otherStore.Close()) dest := filepath.Join(t.TempDir(), "backup.db") - cmd := exec.CommandContext( - t.Context(), - bin, - "backup", - "--source", - src, - dest, - ) - out, err := cmd.CombinedOutput() + _, err = executeCLI("backup", "--source", src, dest) require.Error(t, err) - assert.Contains(t, string(out), "not a micasa database") + assert.ErrorContains(t, err, "not a micasa database") }) } @@ -408,24 +384,3 @@ func noopEditor() string { } return "true" } - -// envWithEditor returns a copy of os.Environ() with EDITOR and VISUAL -// replaced, and XDG_CONFIG_HOME set to configHome. This avoids the -// first-occurrence-wins semantics that would let the parent's EDITOR -// shadow the test's override. -func envWithEditor(configHome, editor string) []string { - var env []string - for _, e := range os.Environ() { - if strings.HasPrefix(e, "EDITOR=") || - strings.HasPrefix(e, "VISUAL=") || - strings.HasPrefix(e, "XDG_CONFIG_HOME=") { - continue - } - env = append(env, e) - } - return append(env, - "XDG_CONFIG_HOME="+configHome, - "EDITOR="+editor, - "VISUAL="+editor, - ) -} diff --git a/flake.nix b/flake.nix index ca4bf67f..52fab851 100644 --- a/flake.nix +++ b/flake.nix @@ -33,7 +33,7 @@ inherit version; src = ./.; subPackages = [ "cmd/micasa" ]; - vendorHash = "sha256-cEV851hKgaoF5CpoXc4SBVETf+QGexVmw1tN0p7wKls="; + vendorHash = "sha256-5dHhQEG2hqwi6EPtE+rMmYuZwTyHju7LtzbJEBkdA58="; env.CGO_ENABLED = 0; preCheck = '' export HOME="$(mktemp -d)" diff --git a/go.mod b/go.mod index 03f819e9..acae986a 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ go 1.25.5 require ( github.com/BurntSushi/toml v1.6.0 github.com/adrg/xdg v0.5.3 - github.com/alecthomas/kong v1.14.0 github.com/brianvoe/gofakeit/v7 v7.14.1 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 @@ -24,6 +23,8 @@ require ( github.com/lrstanley/bubblezone v1.0.0 github.com/mozilla-ai/any-llm-go v0.9.0 github.com/rmhubbert/bubbletea-overlay v0.6.5 + github.com/spf13/cobra v1.7.0 + github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.11.1 github.com/tj/go-naturaldate v1.3.0 golang.org/x/sys v0.42.0 @@ -70,6 +71,7 @@ require ( github.com/googleapis/gax-go/v2 v2.18.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index e5bbd66b..b6d335a1 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,6 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= -github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s= -github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= @@ -76,6 +74,7 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -132,6 +131,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= @@ -191,6 +192,11 @@ github.com/rmhubbert/bubbletea-overlay v0.6.5 h1:syK4TzrNn5Ef+etHjnuLQO4cBbqR0Zq github.com/rmhubbert/bubbletea-overlay v0.6.5/go.mod h1:EI4cLG6YAA7GIHTKOpf9yYijDEZuI6Iuw/IIIWLaYoo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=