| | | 1 | | // Package commands contains the cobra command implementations for the neospec CLI. |
| | | 2 | | package commands |
| | | 3 | | |
| | | 4 | | import ( |
| | | 5 | | "context" |
| | | 6 | | "fmt" |
| | | 7 | | "os" |
| | | 8 | | "path/filepath" |
| | | 9 | | |
| | | 10 | | "github.com/spf13/cobra" |
| | | 11 | | |
| | | 12 | | "github.com/jedi-knights/neospec/internal/adapters/neovim" |
| | | 13 | | "github.com/jedi-knights/neospec/internal/adapters/reporter" |
| | | 14 | | "github.com/jedi-knights/neospec/internal/adapters/runner" |
| | | 15 | | "github.com/jedi-knights/neospec/internal/config" |
| | | 16 | | "github.com/jedi-knights/neospec/internal/domain" |
| | | 17 | | "github.com/jedi-knights/neospec/internal/ports" |
| | | 18 | | ) |
| | | 19 | | |
| | | 20 | | // runDeps holds injectable dependencies for runTests. Pass runDeps{} in |
| | | 21 | | // production — zero-value (nil) fields cause the real adapters to be constructed. |
| | | 22 | | // In tests, set individual fields to inject fakes without touching the network. |
| | | 23 | | // |
| | | 24 | | // testRunner bypasses runner construction entirely — use it when you only need |
| | | 25 | | // to control Discover/Run behaviour. runnerFactory intercepts the constructor |
| | | 26 | | // call itself, letting tests verify that the correct arguments (nvimPath, |
| | | 27 | | // verbose, initFile, coverageInclude) are threaded through from config. |
| | | 28 | | type runDeps struct { |
| | | 29 | | neovimProvider ports.NeovimProvider |
| | | 30 | | testRunner ports.TestRunner |
| | | 31 | | runnerFactory func(nvimPath string, verbose bool, initFile string, coverageInclude []string) ports.TestRunner |
| | | 32 | | } |
| | | 33 | | |
| | | 34 | | // runFlags holds values parsed from CLI flags for the run command. |
| | | 35 | | type runFlags struct { |
| | | 36 | | configPath string |
| | | 37 | | neovimVersion string |
| | | 38 | | patterns []string |
| | | 39 | | coverageDir string |
| | | 40 | | formats []string |
| | | 41 | | threshold float64 |
| | | 42 | | cacheDir string |
| | | 43 | | verbose bool |
| | | 44 | | initFile string |
| | | 45 | | coverageInclude []string |
| | | 46 | | } |
| | | 47 | | |
| | | 48 | | // NewRunCmd builds the `neospec run` (and default) command. |
| | 2 | 49 | | func NewRunCmd() *cobra.Command { |
| | 2 | 50 | | flags := &runFlags{} |
| | 2 | 51 | | |
| | 2 | 52 | | cmd := &cobra.Command{ |
| | 2 | 53 | | Use: "run", |
| | 2 | 54 | | Short: "Run Lua tests and collect coverage", |
| | 2 | 55 | | Long: `Discovers test files, executes them in an isolated Neovim subprocess, and emits reports.`, |
| | 0 | 56 | | RunE: func(cmd *cobra.Command, _ []string) error { |
| | 0 | 57 | | return runTests(cmd.Context(), flags, runDeps{}) |
| | 0 | 58 | | }, |
| | | 59 | | } |
| | | 60 | | |
| | 2 | 61 | | f := cmd.Flags() |
| | 2 | 62 | | f.StringVarP(&flags.configPath, "config", "c", "neospec.toml", "path to config file") |
| | 2 | 63 | | f.StringVar(&flags.neovimVersion, "neovim-version", "", "neovim version to use (e.g. stable, nightly, v0.10.4)") |
| | 2 | 64 | | f.StringArrayVar(&flags.patterns, "pattern", nil, "glob pattern(s) for test files (repeatable)") |
| | 2 | 65 | | f.StringVar(&flags.coverageDir, "coverage-dir", "", "directory for coverage report files") |
| | 2 | 66 | | f.StringArrayVar(&flags.formats, "format", nil, "output format(s): console, lcov, cobertura, coveralls, junit (repeata |
| | 2 | 67 | | f.Float64Var(&flags.threshold, "threshold", 0, "minimum coverage percentage (0 = disabled; cannot clear a non-zero val |
| | 2 | 68 | | f.StringVar(&flags.cacheDir, "cache-dir", "", "directory for cached Neovim binaries") |
| | 2 | 69 | | f.BoolVarP(&flags.verbose, "verbose", "v", false, "verbose output") |
| | 2 | 70 | | f.StringVar(&flags.initFile, "init-file", "", "path to a Lua file executed before the coverage hook (e.g. tests/minima |
| | 2 | 71 | | f.StringArrayVar(&flags.coverageInclude, "coverage-include", nil, "restrict coverage to files whose path contains this |
| | 2 | 72 | | |
| | 2 | 73 | | return cmd |
| | | 74 | | } |
| | | 75 | | |
| | 11 | 76 | | func runTests(ctx context.Context, flags *runFlags, deps runDeps) error { |
| | 11 | 77 | | cfg, err := config.Load(flags.configPath) |
| | 1 | 78 | | if err != nil { |
| | 1 | 79 | | return fmt.Errorf("loading config: %w", err) |
| | 1 | 80 | | } |
| | 10 | 81 | | applyFlags(&cfg, flags) |
| | 10 | 82 | | |
| | 10 | 83 | | version, err := domain.ParseVersion(cfg.NeovimVersion) |
| | 1 | 84 | | if err != nil { |
| | 1 | 85 | | return fmt.Errorf("parsing neovim version: %w", err) |
| | 1 | 86 | | } |
| | | 87 | | |
| | 9 | 88 | | platform, err := domain.CurrentPlatform() |
| | 0 | 89 | | if err != nil { |
| | 0 | 90 | | return fmt.Errorf("detecting platform: %w", err) |
| | 0 | 91 | | } |
| | | 92 | | |
| | 9 | 93 | | nvimProvider := deps.neovimProvider |
| | 0 | 94 | | if nvimProvider == nil { |
| | 0 | 95 | | nvimProvider = neovim.NewProvider(cfg.CacheDir) |
| | 0 | 96 | | } |
| | 9 | 97 | | nvimPath, err := provisionNeovim(ctx, cfg, version, platform, nvimProvider) |
| | 1 | 98 | | if err != nil { |
| | 1 | 99 | | return err |
| | 1 | 100 | | } |
| | | 101 | | |
| | 8 | 102 | | tr := deps.testRunner |
| | 2 | 103 | | if tr == nil { |
| | 2 | 104 | | if deps.runnerFactory != nil { |
| | 2 | 105 | | tr = deps.runnerFactory(nvimPath, cfg.Verbose, cfg.InitFile, cfg.CoverageInclude) |
| | 0 | 106 | | } else { |
| | 0 | 107 | | tr = runner.NewWithDefaultSandbox(nvimPath, cfg.Verbose, cfg.InitFile, cfg.CoverageInclude) |
| | 0 | 108 | | } |
| | | 109 | | } |
| | 8 | 110 | | suite, cov, err := executeTests(ctx, cfg, tr) |
| | 1 | 111 | | if err != nil { |
| | 1 | 112 | | return err |
| | 1 | 113 | | } |
| | 3 | 114 | | if suite == nil { |
| | 3 | 115 | | return nil // no test files found |
| | 3 | 116 | | } |
| | | 117 | | |
| | 1 | 118 | | if err := emitReports(ctx, cfg, suite, cov); err != nil { |
| | 1 | 119 | | return err |
| | 1 | 120 | | } |
| | | 121 | | |
| | 1 | 122 | | if err := checkThreshold(cfg, cov); err != nil { |
| | 1 | 123 | | return err |
| | 1 | 124 | | } |
| | | 125 | | |
| | 1 | 126 | | if !suite.Passed() { |
| | 1 | 127 | | return fmt.Errorf("test suite failed") |
| | 1 | 128 | | } |
| | | 129 | | |
| | 1 | 130 | | return nil |
| | | 131 | | } |
| | | 132 | | |
| | | 133 | | // provisionNeovim calls provider.Ensure for the given version and platform and |
| | | 134 | | // returns the path to the nvim binary. The provider is injected so tests can |
| | | 135 | | // supply a fake without touching the filesystem or network. |
| | 12 | 136 | | func provisionNeovim(ctx context.Context, cfg config.Config, version domain.Version, platform domain.Platform, provider |
| | 1 | 137 | | if cfg.Verbose { |
| | 1 | 138 | | fmt.Fprintf(os.Stderr, "neospec: platform=%s neovim=%s\n", platform, version) |
| | 1 | 139 | | } |
| | 12 | 140 | | nvimPath, err := provider.Ensure(ctx, version, platform) |
| | 2 | 141 | | if err != nil { |
| | 2 | 142 | | return "", fmt.Errorf("ensuring neovim binary: %w", err) |
| | 2 | 143 | | } |
| | 1 | 144 | | if cfg.Verbose { |
| | 1 | 145 | | fmt.Fprintf(os.Stderr, "neospec: nvim binary at %s\n", nvimPath) |
| | 1 | 146 | | } |
| | 10 | 147 | | return nvimPath, nil |
| | | 148 | | } |
| | | 149 | | |
| | | 150 | | // executeTests discovers and runs test files, then ensures the coverage output |
| | | 151 | | // directory exists. It returns nil suite and coverage (with no error) when no |
| | | 152 | | // test files are found so the caller can exit cleanly. The testRunner is injected |
| | | 153 | | // so tests can supply a fake without spawning Neovim subprocesses. |
| | 15 | 154 | | func executeTests(ctx context.Context, cfg config.Config, testRunner ports.TestRunner) (*domain.SuiteResult, *domain.Cov |
| | 15 | 155 | | files, err := testRunner.Discover(ctx, cfg.TestPatterns) |
| | 2 | 156 | | if err != nil { |
| | 2 | 157 | | return nil, nil, fmt.Errorf("discovering test files: %w", err) |
| | 2 | 158 | | } |
| | 4 | 159 | | if len(files) == 0 { |
| | 4 | 160 | | fmt.Fprintln(os.Stderr, "neospec: no test files found") |
| | 4 | 161 | | return nil, nil, nil |
| | 4 | 162 | | } |
| | 1 | 163 | | if cfg.Verbose { |
| | 1 | 164 | | fmt.Fprintf(os.Stderr, "neospec: found %d test file(s)\n", len(files)) |
| | 1 | 165 | | } |
| | | 166 | | |
| | 9 | 167 | | suite, cov, err := testRunner.Run(ctx, files) |
| | 1 | 168 | | if err != nil { |
| | 1 | 169 | | return nil, nil, fmt.Errorf("running tests: %w", err) |
| | 1 | 170 | | } |
| | | 171 | | |
| | 2 | 172 | | if err := os.MkdirAll(cfg.CoverageDir, 0o755); err != nil { |
| | 2 | 173 | | return nil, nil, fmt.Errorf("creating coverage dir: %w", err) |
| | 2 | 174 | | } |
| | | 175 | | |
| | 6 | 176 | | return suite, cov, nil |
| | | 177 | | } |
| | | 178 | | |
| | | 179 | | // emitReports writes all configured output formats to their respective destinations. |
| | 7 | 180 | | func emitReports(ctx context.Context, cfg config.Config, suite *domain.SuiteResult, cov *domain.CoverageData) error { |
| | 7 | 181 | | for _, format := range cfg.Formats { |
| | 2 | 182 | | if err := writeReport(ctx, cfg, format, suite, cov); err != nil { |
| | 2 | 183 | | return err |
| | 2 | 184 | | } |
| | | 185 | | } |
| | 5 | 186 | | return nil |
| | | 187 | | } |
| | | 188 | | |
| | | 189 | | // writeReport opens, writes, and closes the output for a single report format. |
| | | 190 | | // The file handle is deferred so it is closed even if r.Write panics. |
| | 7 | 191 | | func writeReport(ctx context.Context, cfg config.Config, format string, suite *domain.SuiteResult, cov *domain.CoverageD |
| | 7 | 192 | | r, f, err := reporterFor(format, cfg, cfg.Verbose) |
| | 2 | 193 | | if err != nil { |
| | 2 | 194 | | return err |
| | 2 | 195 | | } |
| | 1 | 196 | | if f != nil && f != os.Stdout { |
| | 1 | 197 | | defer func() { |
| | 0 | 198 | | if cerr := f.Close(); cerr != nil && retErr == nil { |
| | 0 | 199 | | retErr = cerr |
| | 0 | 200 | | } |
| | | 201 | | }() |
| | | 202 | | } |
| | 0 | 203 | | if err := r.Write(ctx, f, suite, cov); err != nil { |
| | 0 | 204 | | return fmt.Errorf("writing %s report: %w", format, err) |
| | 0 | 205 | | } |
| | 5 | 206 | | return nil |
| | | 207 | | } |
| | | 208 | | |
| | | 209 | | // checkThreshold returns an error if the measured coverage falls below the |
| | | 210 | | // configured minimum. A threshold of zero disables the check. A nil cov is |
| | | 211 | | // treated as 0% coverage — a conforming ports.TestRunner may return nil. |
| | 7 | 212 | | func checkThreshold(cfg config.Config, cov *domain.CoverageData) error { |
| | 3 | 213 | | if cfg.Threshold <= 0 { |
| | 3 | 214 | | return nil |
| | 3 | 215 | | } |
| | 4 | 216 | | pct := 0.0 |
| | 3 | 217 | | if cov != nil { |
| | 3 | 218 | | pct = cov.Percentage() |
| | 3 | 219 | | } |
| | 3 | 220 | | if pct < cfg.Threshold { |
| | 3 | 221 | | return fmt.Errorf("coverage %.1f%% is below threshold %.1f%%", pct, cfg.Threshold) |
| | 3 | 222 | | } |
| | 1 | 223 | | return nil |
| | | 224 | | } |
| | | 225 | | |
| | | 226 | | // reporterFor returns the Reporter and output writer for a named format. |
| | | 227 | | // For non-console formats the writer is a file in cfg.CoverageDir. |
| | 17 | 228 | | func reporterFor(format string, cfg config.Config, color bool) (ports.Reporter, *os.File, error) { |
| | 17 | 229 | | switch format { |
| | 5 | 230 | | case "console": |
| | 5 | 231 | | return reporter.NewConsole(color), os.Stdout, nil |
| | 3 | 232 | | case "lcov": |
| | 3 | 233 | | f, err := os.Create(filepath.Join(cfg.CoverageDir, "lcov.info")) |
| | 1 | 234 | | if err != nil { |
| | 1 | 235 | | return nil, nil, fmt.Errorf("creating lcov report file: %w", err) |
| | 1 | 236 | | } |
| | 2 | 237 | | return reporter.NewLCOV(), f, nil |
| | 2 | 238 | | case "cobertura": |
| | 2 | 239 | | f, err := os.Create(filepath.Join(cfg.CoverageDir, "cobertura.xml")) |
| | 1 | 240 | | if err != nil { |
| | 1 | 241 | | return nil, nil, fmt.Errorf("creating cobertura report file: %w", err) |
| | 1 | 242 | | } |
| | 1 | 243 | | return reporter.NewCobertura(), f, nil |
| | 2 | 244 | | case "coveralls": |
| | 2 | 245 | | f, err := os.Create(filepath.Join(cfg.CoverageDir, "coveralls.json")) |
| | 1 | 246 | | if err != nil { |
| | 1 | 247 | | return nil, nil, fmt.Errorf("creating coveralls report file: %w", err) |
| | 1 | 248 | | } |
| | 1 | 249 | | return reporter.NewCoveralls(), f, nil |
| | 2 | 250 | | case "junit": |
| | 2 | 251 | | f, err := os.Create(filepath.Join(cfg.CoverageDir, "junit.xml")) |
| | 1 | 252 | | if err != nil { |
| | 1 | 253 | | return nil, nil, fmt.Errorf("creating junit report file: %w", err) |
| | 1 | 254 | | } |
| | 1 | 255 | | return reporter.NewJUnit(), f, nil |
| | 3 | 256 | | default: |
| | 3 | 257 | | return nil, nil, fmt.Errorf("unknown format %q: choose from console, lcov, cobertura, coveralls, junit", format) |
| | | 258 | | } |
| | | 259 | | } |
| | | 260 | | |
| | | 261 | | // applyFlags overlays non-zero CLI flag values onto cfg. |
| | 16 | 262 | | func applyFlags(cfg *config.Config, flags *runFlags) { |
| | 2 | 263 | | if flags.neovimVersion != "" { |
| | 2 | 264 | | cfg.NeovimVersion = flags.neovimVersion |
| | 2 | 265 | | } |
| | 1 | 266 | | if len(flags.patterns) > 0 { |
| | 1 | 267 | | cfg.TestPatterns = flags.patterns |
| | 1 | 268 | | } |
| | 5 | 269 | | if flags.coverageDir != "" { |
| | 5 | 270 | | cfg.CoverageDir = flags.coverageDir |
| | 5 | 271 | | } |
| | 5 | 272 | | if len(flags.formats) > 0 { |
| | 5 | 273 | | cfg.Formats = flags.formats |
| | 5 | 274 | | } |
| | 2 | 275 | | if flags.threshold > 0 { |
| | 2 | 276 | | cfg.Threshold = flags.threshold |
| | 2 | 277 | | } |
| | 1 | 278 | | if flags.cacheDir != "" { |
| | 1 | 279 | | cfg.CacheDir = flags.cacheDir |
| | 1 | 280 | | } |
| | 1 | 281 | | if flags.verbose { |
| | 1 | 282 | | cfg.Verbose = true |
| | 1 | 283 | | } |
| | 3 | 284 | | if flags.initFile != "" { |
| | 3 | 285 | | cfg.InitFile = flags.initFile |
| | 3 | 286 | | } |
| | 1 | 287 | | if len(flags.coverageInclude) > 0 { |
| | 1 | 288 | | cfg.CoverageInclude = flags.coverageInclude |
| | 1 | 289 | | } |
| | | 290 | | } |