| | | 1 | | // Package config loads and merges neospec configuration from three sources: |
| | | 2 | | // a TOML file, environment variables, and CLI flags. Precedence is: |
| | | 3 | | // CLI flags > environment variables > TOML file > built-in defaults. |
| | | 4 | | package config |
| | | 5 | | |
| | | 6 | | import ( |
| | | 7 | | "errors" |
| | | 8 | | "fmt" |
| | | 9 | | "io/fs" |
| | | 10 | | "os" |
| | | 11 | | "path/filepath" |
| | | 12 | | "strconv" |
| | | 13 | | "strings" |
| | | 14 | | |
| | | 15 | | "github.com/BurntSushi/toml" |
| | | 16 | | ) |
| | | 17 | | |
| | | 18 | | // Config is the merged, validated configuration for a neospec run. |
| | | 19 | | type Config struct { |
| | | 20 | | // NeovimVersion is the version tag to download, e.g. "stable", "nightly", or "v0.10.4". |
| | | 21 | | NeovimVersion string `toml:"neovim_version"` |
| | | 22 | | // TestPatterns are glob patterns for discovering test files. |
| | | 23 | | TestPatterns []string `toml:"test_patterns"` |
| | | 24 | | // CoverageDir is the directory where coverage report files are written. |
| | | 25 | | CoverageDir string `toml:"coverage_dir"` |
| | | 26 | | // Formats lists the report formats to emit: lcov, cobertura, coveralls, junit, console. |
| | | 27 | | Formats []string `toml:"formats"` |
| | | 28 | | // Threshold is the minimum required coverage percentage; a non-zero value |
| | | 29 | | // causes neospec to exit non-zero when coverage falls below it. |
| | | 30 | | Threshold float64 `toml:"threshold"` |
| | | 31 | | // CacheDir is the directory where downloaded Neovim binaries are stored. |
| | | 32 | | CacheDir string `toml:"cache_dir"` |
| | | 33 | | // Verbose enables additional diagnostic output. |
| | | 34 | | Verbose bool `toml:"verbose"` |
| | | 35 | | // InitFile is an optional path to a Lua file executed before the coverage |
| | | 36 | | // hook and test harness. Use it to set up runtimepath, mock dependencies, or |
| | | 37 | | // otherwise configure the Neovim environment before tests run. The file is |
| | | 38 | | // not instrumented by the coverage hook because it executes before the hook |
| | | 39 | | // is installed. |
| | | 40 | | InitFile string `toml:"init_file"` |
| | | 41 | | // CoverageInclude is an optional list of path substrings. When non-empty, |
| | | 42 | | // only source files whose absolute path contains at least one of these |
| | | 43 | | // strings are recorded. Use it to restrict coverage to your plugin's own |
| | | 44 | | // source tree and exclude Neovim's internal runtime files. |
| | | 45 | | // Example: coverage_include = ["lua/", "plugin/"] |
| | | 46 | | CoverageInclude []string `toml:"coverage_include"` |
| | | 47 | | } |
| | | 48 | | |
| | | 49 | | // defaults returns a Config populated with built-in default values. |
| | 20 | 50 | | func defaults() Config { |
| | 20 | 51 | | cacheDir := filepath.Join(userCacheDirWith(defaultOSDirs), "neospec") |
| | 20 | 52 | | return Config{ |
| | 20 | 53 | | NeovimVersion: "stable", |
| | 20 | 54 | | TestPatterns: []string{"test/**/*_spec.lua"}, |
| | 20 | 55 | | CoverageDir: "coverage", |
| | 20 | 56 | | Formats: []string{"console"}, |
| | 20 | 57 | | Threshold: 0.0, |
| | 20 | 58 | | CacheDir: cacheDir, |
| | 20 | 59 | | Verbose: false, |
| | 20 | 60 | | } |
| | 20 | 61 | | } |
| | | 62 | | |
| | | 63 | | // Load reads neospec.toml from path (if it exists), then overlays environment |
| | | 64 | | // variables, and returns the merged Config. CLI flag overrides are applied |
| | | 65 | | // separately via the Apply* methods. |
| | 20 | 66 | | func Load(path string) (Config, error) { |
| | 20 | 67 | | cfg := defaults() |
| | 20 | 68 | | |
| | 6 | 69 | | if path != "" { |
| | 2 | 70 | | if err := loadTOML(path, &cfg); err != nil { |
| | 2 | 71 | | return cfg, err |
| | 2 | 72 | | } |
| | | 73 | | } |
| | | 74 | | |
| | 2 | 75 | | if err := applyEnv(&cfg); err != nil { |
| | 2 | 76 | | return cfg, err |
| | 2 | 77 | | } |
| | 16 | 78 | | return cfg, nil |
| | | 79 | | } |
| | | 80 | | |
| | | 81 | | // loadTOML reads a TOML file into cfg. Missing files are silently ignored; |
| | | 82 | | // malformed files return an error. |
| | 6 | 83 | | func loadTOML(path string, cfg *Config) error { |
| | 6 | 84 | | data, err := os.ReadFile(path) |
| | 1 | 85 | | if errors.Is(err, fs.ErrNotExist) { |
| | 1 | 86 | | return nil |
| | 1 | 87 | | } |
| | 1 | 88 | | if err != nil { |
| | 1 | 89 | | return fmt.Errorf("reading config file %s: %w", path, err) |
| | 1 | 90 | | } |
| | 1 | 91 | | if err := toml.Unmarshal(data, cfg); err != nil { |
| | 1 | 92 | | return fmt.Errorf("parsing config file %s: %w", path, err) |
| | 1 | 93 | | } |
| | 3 | 94 | | return nil |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | // applyEnv overlays environment variables onto cfg. Only non-empty env vars |
| | | 98 | | // override the current value. It returns an error if a numeric env var is |
| | | 99 | | // present but cannot be parsed, so user configuration mistakes are surfaced |
| | | 100 | | // immediately rather than silently running with a different value. |
| | 18 | 101 | | func applyEnv(cfg *Config) error { |
| | 1 | 102 | | if v := strings.TrimSpace(os.Getenv("NEOSPEC_NEOVIM_VERSION")); v != "" { |
| | 1 | 103 | | cfg.NeovimVersion = v |
| | 1 | 104 | | } |
| | 1 | 105 | | if v := strings.TrimSpace(os.Getenv("NEOSPEC_TEST_PATTERNS")); v != "" { |
| | 1 | 106 | | cfg.TestPatterns = splitTrimmed(v, ",") |
| | 1 | 107 | | } |
| | 1 | 108 | | if v := strings.TrimSpace(os.Getenv("NEOSPEC_COVERAGE_DIR")); v != "" { |
| | 1 | 109 | | cfg.CoverageDir = v |
| | 1 | 110 | | } |
| | 3 | 111 | | if v := strings.TrimSpace(os.Getenv("NEOSPEC_FORMATS")); v != "" { |
| | 3 | 112 | | cfg.Formats = splitTrimmed(v, ",") |
| | 3 | 113 | | } |
| | 1 | 114 | | if v := strings.TrimSpace(os.Getenv("NEOSPEC_CACHE_DIR")); v != "" { |
| | 1 | 115 | | cfg.CacheDir = v |
| | 1 | 116 | | } |
| | 4 | 117 | | if v := strings.TrimSpace(os.Getenv("NEOSPEC_VERBOSE")); v != "" { |
| | 4 | 118 | | switch v { |
| | 2 | 119 | | case "true", "1": |
| | 2 | 120 | | cfg.Verbose = true |
| | 1 | 121 | | case "false", "0": |
| | 1 | 122 | | cfg.Verbose = false |
| | 1 | 123 | | default: |
| | 1 | 124 | | return fmt.Errorf("NEOSPEC_VERBOSE %q is not a valid boolean (use true, false, 1, or 0; values are case-sensitive) |
| | | 125 | | } |
| | | 126 | | } |
| | 3 | 127 | | if v := strings.TrimSpace(os.Getenv("NEOSPEC_THRESHOLD")); v != "" { |
| | 3 | 128 | | f, err := strconv.ParseFloat(v, 64) |
| | 1 | 129 | | if err != nil { |
| | 1 | 130 | | return fmt.Errorf("NEOSPEC_THRESHOLD %q is not a valid float: %w", v, err) |
| | 1 | 131 | | } |
| | 2 | 132 | | cfg.Threshold = f |
| | | 133 | | } |
| | 2 | 134 | | if v := strings.TrimSpace(os.Getenv("NEOSPEC_INIT_FILE")); v != "" { |
| | 2 | 135 | | cfg.InitFile = v |
| | 2 | 136 | | } |
| | 1 | 137 | | if v := strings.TrimSpace(os.Getenv("NEOSPEC_COVERAGE_INCLUDE")); v != "" { |
| | 1 | 138 | | cfg.CoverageInclude = splitTrimmed(v, ",") |
| | 1 | 139 | | } |
| | 16 | 140 | | return nil |
| | | 141 | | } |
| | | 142 | | |
| | | 143 | | // splitTrimmed splits s by sep, trims whitespace from each element, and |
| | | 144 | | // removes empty elements. This handles CI-injected env vars like "lcov, junit" |
| | | 145 | | // (space after comma) and trailing commas like "lcov," that would otherwise |
| | | 146 | | // produce empty elements silently failing format matching at runtime. |
| | 5 | 147 | | func splitTrimmed(s, sep string) []string { |
| | 5 | 148 | | parts := strings.Split(s, sep) |
| | 5 | 149 | | out := make([]string, 0, len(parts)) |
| | 5 | 150 | | for _, p := range parts { |
| | 9 | 151 | | if t := strings.TrimSpace(p); t != "" { |
| | 9 | 152 | | out = append(out, t) |
| | 9 | 153 | | } |
| | | 154 | | } |
| | 5 | 155 | | return out |
| | | 156 | | } |
| | | 157 | | |
| | | 158 | | // osDirs bundles the OS directory functions used by defaults(). Holding them |
| | | 159 | | // in a struct lets tests inject controlled implementations without changing |
| | | 160 | | // the exported API or relying on OS-level manipulation. |
| | | 161 | | type osDirs struct { |
| | | 162 | | userCacheDir func() (string, error) |
| | | 163 | | userHomeDir func() (string, error) |
| | | 164 | | tempDir func() string |
| | | 165 | | } |
| | | 166 | | |
| | | 167 | | // defaultOSDirs is the production set of OS directory lookups. |
| | | 168 | | var defaultOSDirs = osDirs{ |
| | | 169 | | userCacheDir: os.UserCacheDir, |
| | | 170 | | userHomeDir: os.UserHomeDir, |
| | | 171 | | tempDir: os.TempDir, |
| | | 172 | | } |
| | | 173 | | |
| | | 174 | | // userCacheDirWith returns an absolute cache directory path using the |
| | | 175 | | // provided OS directory functions, with progressive fallbacks. On minimal |
| | | 176 | | // systems (containers without a passwd entry), both UserCacheDir and |
| | | 177 | | // UserHomeDir may fail; in that case we fall back to the OS temp directory, |
| | | 178 | | // which is guaranteed to be absolute. |
| | 23 | 179 | | func userCacheDirWith(d osDirs) string { |
| | 21 | 180 | | if dir, err := d.userCacheDir(); err == nil { |
| | 21 | 181 | | return dir |
| | 21 | 182 | | } |
| | 1 | 183 | | if home, err := d.userHomeDir(); err == nil { |
| | 1 | 184 | | return filepath.Join(home, ".cache") |
| | 1 | 185 | | } |
| | 1 | 186 | | return filepath.Join(d.tempDir(), "neospec") |
| | | 187 | | } |