Skip to main content

linesmith_core/
config.rs

1//! User config: parse `config.toml`, resolve its path, and apply
2//! per-segment overrides. Full contract in `docs/specs/config.md`.
3
4use schemars::JsonSchema;
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9
10/// Parsed `config.toml`. Serde ignores unknown keys so a file from a
11/// newer linesmith still parses on an older binary; fields this
12/// version doesn't know are dropped rather than rejected. The
13/// schema-side `additionalProperties: false` tightens editor
14/// validation so typos like `thme = "default"` get flagged at the
15/// authoring layer; the runtime stays permissive (the unknown-key
16/// warning channel lives in `from_str_validated`).
17#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
18#[serde(default)]
19#[schemars(extend("additionalProperties" = false))]
20pub struct Config {
21    pub line: Option<LineConfig>,
22    pub theme: Option<String>,
23    /// Top-level layout mode. Defaults to [`LayoutMode::SingleLine`]
24    /// when the field is omitted, preserving pre-multi-line config
25    /// behavior. [`LayoutMode::MultiLine`] triggers per-`[line.N]`
26    /// rendering.
27    pub layout: LayoutMode,
28    pub layout_options: Option<LayoutOptions>,
29    #[serde(default)]
30    pub segments: BTreeMap<String, SegmentOverride>,
31    /// Extra directories to scan for user plugin scripts (`.rhai`
32    /// files). Scanned in list order before the default XDG
33    /// directory. See `docs/specs/config.md` §Plugin directories and
34    /// `docs/specs/plugin-api.md` §Plugin file location.
35    #[serde(default)]
36    pub plugin_dirs: Vec<PathBuf>,
37    /// Spec-listed forward-compat key. Parsed and runtime-ignored;
38    /// surfacing it in the schema so editor tooling doesn't flag
39    /// user configs that include it. Allow-listed in `KNOWN_TOP_LEVEL`
40    /// to suppress the unknown-key warning.
41    pub preset: Option<String>,
42    /// Forward-compat `[plugins.*]` table. Typed as a string-keyed
43    /// map so a non-table value (`plugins = "oops"`) fails parse at
44    /// load-time instead of silently dropping; per-plugin sub-table
45    /// shape is open until the plugin-config spec lands. Schema
46    /// mirror remaps `toml::Value` to `serde_json::Value` for the
47    /// same reason as `extra` / `numbered`: `toml::Value` has no
48    /// `JsonSchema` impl.
49    #[serde(default)]
50    #[schemars(with = "Option<BTreeMap<String, serde_json::Value>>")]
51    pub plugins: Option<BTreeMap<String, toml::Value>>,
52    /// Editor-tooling `$schema` directive. Some users put it as a
53    /// top-level TOML key instead of (or alongside) the `#:schema`
54    /// comment directive `linesmith init` writes. Must be quoted in
55    /// TOML (`"$schema" = "..."`) — `$` is not legal in bare keys.
56    /// Parsed and ignored at runtime; surfaced here so the schema
57    /// validates configs using the alternate form.
58    #[serde(default, rename = "$schema")]
59    pub schema_url: Option<String>,
60}
61
62/// `[layout_options]` section: render-path tunables that aren't tied
63/// to a specific segment. See `docs/specs/config.md` §layout_options.
64#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
65#[serde(default)]
66#[non_exhaustive]
67#[schemars(extend("additionalProperties" = false))]
68pub struct LayoutOptions {
69    pub color: ColorPolicy,
70    pub claude_padding: u16,
71    /// Inter-segment separator. Stored as a raw string; the segment
72    /// builder parses it into a [`crate::segments::Separator`] at
73    /// build time so unknown values warn and fall back to `space`
74    /// rather than failing the whole config load. See
75    /// `docs/specs/config.md` for the reserved-keyword set
76    /// (`space`, `powerline`, `capsule`, `flex`, `""`) and the
77    /// arbitrary-literal fallback.
78    pub separator: Option<String>,
79    /// Cell-count for the Nerd Font powerline chevron (U+E0B0). Only
80    /// `1` (the default; matches modern Nerd Fonts at standard sizes)
81    /// and `2` (some older builds / larger sizes) are meaningful.
82    /// Takes effect only when a powerline separator is in use; setting
83    /// it under `separator = "space"` is harmless but inert.
84    pub powerline_width: Option<u16>,
85}
86
87/// Config-level color override. `auto` honors CLI flags and env vars;
88/// `always` forces color even in non-TTY output; `never` strips all
89/// color. Sits below CLI flags and env vars in the precedence chain.
90#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
91#[serde(rename_all = "lowercase")]
92#[non_exhaustive]
93pub enum ColorPolicy {
94    #[default]
95    Auto,
96    Always,
97    Never,
98}
99
100/// `[line]` section: ordered list of segment ids to render in
101/// single-line mode, plus any numbered child tables (`[line.1]`,
102/// `[line.2]`, ...) for multi-line mode. The `flatten`-captured
103/// [`numbered`](Self::numbered) map carries every other key as a
104/// raw [`toml::Value`]. Key validation (positive integer pointing
105/// at a table with a `segments` array) and ordering happen in the
106/// segment builder, which keeps the spec's "unknown keys are
107/// warnings, not errors" forward-compat contract: a typo like
108/// `[line] segmnts = [...]` parses as a `toml::Value::Array`,
109/// reaches the builder, and emits a warning rather than failing the
110/// config load. Per spec `docs/specs/config.md` §Multi-line layouts.
111#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
112#[serde(default)]
113pub struct LineConfig {
114    pub segments: Vec<String>,
115    /// Anything under `[line]` other than `segments`. Holds
116    /// `[line.N]` table values plus any forward-compat scalar keys
117    /// future versions may add. The builder routes table values
118    /// with positive-integer keys to multi-line rendering and warns
119    /// on the rest.
120    ///
121    /// Schema bypass: `toml::Value` has no `JsonSchema` impl, so
122    /// remap to `serde_json::Value`'s open-ended schema (any JSON
123    /// type) for the `additionalProperties` fallthrough.
124    #[serde(flatten)]
125    #[schemars(with = "serde_json::Value")]
126    pub numbered: BTreeMap<String, toml::Value>,
127}
128
129/// Top-level `layout = "..."` selector. Defaults to `SingleLine`
130/// (preserves pre-multi-line config behavior). `MultiLine` instructs
131/// the builder + render loop to consume `[line.N]` sub-tables.
132#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
133#[serde(rename_all = "kebab-case")]
134#[non_exhaustive]
135pub enum LayoutMode {
136    #[default]
137    SingleLine,
138    MultiLine,
139}
140
141/// `[segments.<id>]` override block. Each typed field, when `Some`,
142/// replaces the segment's built-in default. Any unrecognized keys land
143/// in [`extra`](Self::extra), which the segment builder forwards to
144/// plugin scripts as `ctx.config.<key>`. `style` is stored as a raw
145/// string; `segments::builder::apply_override` parses it at build time
146/// so parse errors emit warnings through the same callback that
147/// handles unknown-ID and inverted-bounds diagnostics.
148///
149/// `Eq` isn't derived because [`toml::Value`] holds `f64` and so is
150/// `PartialEq` only — `extra` propagates that constraint.
151#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
152#[serde(default)]
153pub struct SegmentOverride {
154    pub priority: Option<u8>,
155    pub width: Option<WidthBoundsConfig>,
156    pub style: Option<String>,
157    /// Plugin-config bag: every TOML key under `[segments.<plugin-id>]`
158    /// not matched by a typed field. Surfaced to the rhai script as
159    /// `ctx.config.<key>` per `docs/specs/plugin-api.md` §ctx shape.
160    /// Built-in segments ignore this; the unknown-key validator still
161    /// warns when a built-in's table contains keys outside its schema.
162    ///
163    /// Schema bypass: `toml::Value` has no `JsonSchema` impl, so
164    /// remap to `serde_json::Value`'s open-ended schema for the
165    /// `additionalProperties` fallthrough.
166    #[serde(flatten)]
167    #[schemars(with = "serde_json::Value")]
168    pub extra: BTreeMap<String, toml::Value>,
169}
170
171/// URL for the published JSON Schema, pinned to `main`. Single
172/// canonical URL — same shape bacon, starship, and dprint ship.
173/// The schema evolves forward-compatibly (fields added, not
174/// removed); editors validate "config field is allowed by schema"
175/// rather than "binary supports field," so a schema slightly ahead
176/// of the installed binary loosens validation rather than tightens
177/// it. Versioned per-tag self-hosted URLs (biome's model) are the
178/// destination once `linesmith` has its own website plus
179/// schemastore.org coverage.
180pub const SCHEMA_URL: &str =
181    "https://raw.githubusercontent.com/oakoss/linesmith/main/config.schema.json";
182
183/// Prepend `#:schema <url>` directive (taplo / VS Code / Zed
184/// convention) to a freshly-generated config body so editors pick up
185/// the published schema without per-user setup.
186pub fn with_schema_directive(body: &str) -> String {
187    format!("#:schema {SCHEMA_URL}\n\n{body}")
188}
189
190/// Width-bounds override. Either side may be omitted; a missing side
191/// inherits from the segment's built-in default.
192#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
193#[schemars(extend("additionalProperties" = false))]
194pub struct WidthBoundsConfig {
195    pub min: Option<u16>,
196    pub max: Option<u16>,
197}
198
199/// Failure modes when loading a config. A missing file is not an error;
200/// callers treat it as "use defaults."
201#[derive(Debug)]
202#[non_exhaustive]
203pub enum ConfigError {
204    /// `fs::read` failed for a reason other than `NotFound`. Carries
205    /// the offending path so the stderr diagnostic is self-describing.
206    Io {
207        path: PathBuf,
208        source: std::io::Error,
209    },
210    /// Invalid TOML. `path` is `None` for in-memory parses.
211    Parse {
212        path: Option<PathBuf>,
213        source: toml::de::Error,
214    },
215}
216
217impl std::fmt::Display for ConfigError {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        match self {
220            Self::Io { path, source } => write!(f, "config I/O at {}: {source}", path.display()),
221            Self::Parse {
222                path: Some(p),
223                source,
224            } => write!(f, "config parse at {}: {source}", p.display()),
225            Self::Parse { path: None, source } => write!(f, "config parse: {source}"),
226        }
227    }
228}
229
230impl std::error::Error for ConfigError {
231    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
232        match self {
233            Self::Io { source, .. } => Some(source),
234            Self::Parse { source, .. } => Some(source),
235        }
236    }
237}
238
239impl FromStr for Config {
240    type Err = ConfigError;
241
242    fn from_str(s: &str) -> Result<Self, Self::Err> {
243        toml::from_str(s).map_err(|source| ConfigError::Parse { path: None, source })
244    }
245}
246
247impl Config {
248    /// Read and parse the file at `path`. Returns `Ok(None)` when the
249    /// file doesn't exist (normal case for first-run users); other I/O
250    /// errors propagate so callers can log them. Unknown keys are
251    /// silently ignored — callers that want typo warnings use
252    /// [`Config::load_validated`] instead.
253    pub fn load(path: &Path) -> Result<Option<Self>, ConfigError> {
254        let raw = match std::fs::read_to_string(path) {
255            Ok(s) => s,
256            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
257            Err(source) => {
258                return Err(ConfigError::Io {
259                    path: path.to_owned(),
260                    source,
261                })
262            }
263        };
264        toml::from_str(&raw)
265            .map(Some)
266            .map_err(|source| ConfigError::Parse {
267                path: Some(path.to_owned()),
268                source,
269            })
270    }
271
272    /// Same as [`Config::load`] but emits one warning per unknown key
273    /// encountered (top-level, `[layout_options]`, or `[segments.<id>]`).
274    /// The allow-list tolerates every spec-documented key (see
275    /// `KNOWN_TOP_LEVEL` and `KNOWN_LAYOUT_OPTIONS`), so forward-compat
276    /// configs stay silent while typos surface.
277    pub fn load_validated(
278        path: &Path,
279        warn: impl FnMut(&str),
280    ) -> Result<Option<Self>, ConfigError> {
281        let raw = match std::fs::read_to_string(path) {
282            Ok(s) => s,
283            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
284            Err(source) => {
285                return Err(ConfigError::Io {
286                    path: path.to_owned(),
287                    source,
288                })
289            }
290        };
291        Self::from_str_validated_impl(&raw, Some(path), warn).map(Some)
292    }
293
294    /// [`FromStr`]-equivalent with unknown-key warnings. The plain
295    /// `FromStr` impl remains the non-validating form; validation is
296    /// opt-in so callers that don't want the allow-list surface (unit
297    /// tests, programmatic config construction) bypass it.
298    pub fn from_str_validated(s: &str, warn: impl FnMut(&str)) -> Result<Self, ConfigError> {
299        Self::from_str_validated_impl(s, None, warn)
300    }
301
302    fn from_str_validated_impl(
303        s: &str,
304        path: Option<&Path>,
305        mut warn: impl FnMut(&str),
306    ) -> Result<Self, ConfigError> {
307        let raw: toml::Value = toml::from_str(s).map_err(|source| ConfigError::Parse {
308            path: path.map(Path::to_owned),
309            source,
310        })?;
311        validate_keys(&raw, &mut warn);
312        raw.try_into()
313            .map_err(|source: toml::de::Error| ConfigError::Parse {
314                path: path.map(Path::to_owned),
315                source,
316            })
317    }
318}
319
320/// Top-level config keys we recognize. Spec-documented keys (some
321/// runtime-consumed, some forward-compat parsed-and-ignored) plus
322/// `$schema` for editor tooling. Anything not in this list raises a
323/// per-key warning through `from_str_validated_impl`.
324const KNOWN_TOP_LEVEL: &[&str] = &[
325    "line",
326    "theme",
327    "layout_options",
328    "segments",
329    "plugin_dirs",
330    "preset",
331    "layout",
332    "plugins",
333    "$schema",
334];
335
336/// Fields under `[layout_options]`. `separator` is tolerated ahead
337/// of its implementation so forward-compat configs don't warn.
338const KNOWN_LAYOUT_OPTIONS: &[&str] = &["color", "claude_padding", "separator", "powerline_width"];
339
340/// Per-segment override schema. Returns `None` for segment ids we
341/// don't recognize so plugin segments (which own their own schema)
342/// bypass validation. Most built-ins share the universal allow-list;
343/// rate-limit segments extend it with per-family knobs that
344/// `segments::rate_limit_format` reads from the TOML extras bag.
345fn segment_override_schema(id: &str) -> Option<&'static [&'static str]> {
346    const BUILT_IN_COMMON: &[&str] = &["priority", "width", "style", "visible_if"];
347    const RATE_LIMIT_COMMON: &[&str] = &[
348        "priority",
349        "width",
350        "style",
351        "visible_if",
352        "icon",
353        "label",
354        "stale_marker",
355        "progress_width",
356        "format",
357    ];
358    const PERCENT_SEGMENT: &[&str] = &[
359        "priority",
360        "width",
361        "style",
362        "visible_if",
363        "icon",
364        "label",
365        "stale_marker",
366        "progress_width",
367        "format",
368        "invert",
369    ];
370    const RESET_SEGMENT: &[&str] = &[
371        "priority",
372        "width",
373        "style",
374        "visible_if",
375        "icon",
376        "label",
377        "stale_marker",
378        "progress_width",
379        "format",
380        "compact",
381        "use_days",
382    ];
383    // Nested tables like `dirty` are validated shallowly per
384    // `validate_segments_table` — their inner keys pass through
385    // without warning. Inner schemas live in the segment's
386    // `from_extras` validator.
387    const GIT_BRANCH_SEGMENT: &[&str] = &[
388        "priority",
389        "width",
390        "style",
391        "visible_if",
392        "icon",
393        "label",
394        "max_length",
395        "truncation_marker",
396        "short_sha_length",
397        "dirty",
398        "ahead_behind",
399    ];
400    const MODEL_SEGMENT: &[&str] = &["priority", "width", "style", "visible_if", "format"];
401    match id {
402        "model" => Some(MODEL_SEGMENT),
403        "workspace" | "cost" | "effort" | "context_window" => Some(BUILT_IN_COMMON),
404        "rate_limit_5h" | "rate_limit_7d" => Some(PERCENT_SEGMENT),
405        "rate_limit_5h_reset" | "rate_limit_7d_reset" => Some(RESET_SEGMENT),
406        "extra_usage" => Some(RATE_LIMIT_COMMON),
407        "git_branch" => Some(GIT_BRANCH_SEGMENT),
408        _ => None,
409    }
410}
411
412/// Walk `raw` and emit one warning per key outside the allow-list.
413/// Scope is intentionally shallow: top-level, `[layout_options]`
414/// fields, and fields directly under each `[segments.<id>]` table.
415/// Deeper nesting (plugin configs, per-line segments) stays silent
416/// until those features land with their own schemas.
417fn validate_keys(raw: &toml::Value, warn: &mut impl FnMut(&str)) {
418    let Some(top) = raw.as_table() else {
419        return;
420    };
421    for (key, value) in top {
422        if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
423            warn(&format!("unknown top-level config key '{key}'; ignoring"));
424            continue;
425        }
426        match key.as_str() {
427            "layout_options" => {
428                validate_flat_table(value, "layout_options", KNOWN_LAYOUT_OPTIONS, warn)
429            }
430            "segments" => validate_segments_table(value, warn),
431            _ => {}
432        }
433    }
434}
435
436fn validate_flat_table(
437    value: &toml::Value,
438    label: &str,
439    allowed: &[&str],
440    warn: &mut impl FnMut(&str),
441) {
442    let Some(table) = value.as_table() else {
443        return;
444    };
445    for key in table.keys() {
446        if !allowed.contains(&key.as_str()) {
447            warn(&format!("unknown key '{key}' in [{label}]; ignoring"));
448        }
449    }
450}
451
452fn validate_segments_table(value: &toml::Value, warn: &mut impl FnMut(&str)) {
453    let Some(segments) = value.as_table() else {
454        return;
455    };
456    for (id, block) in segments {
457        let Some(block_table) = block.as_table() else {
458            continue;
459        };
460        let Some(allowed) = segment_override_schema(id) else {
461            // Plugin or not-yet-shipped segment id; skip so plugin
462            // config keys pass through. Plugins own their schema via
463            // the plugin API when that lands.
464            continue;
465        };
466        for key in block_table.keys() {
467            if !allowed.contains(&key.as_str()) {
468                warn(&format!("unknown key '{key}' in [segments.{id}]; ignoring"));
469            }
470        }
471    }
472}
473
474/// Where linesmith found its config path and how. `explicit = true`
475/// means the user named a path directly (`--config` or
476/// `LINESMITH_CONFIG`); the run-time diagnostics use this to decide
477/// whether a missing file is worth warning about (explicit paths
478/// warn, implicit XDG fallbacks stay silent for first-run users).
479#[derive(Debug, Clone, PartialEq, Eq)]
480pub struct ConfigPath {
481    pub path: PathBuf,
482    pub explicit: bool,
483}
484
485/// Where linesmith looks for its config file, in precedence order.
486#[must_use]
487pub fn resolve_config_path(
488    cli_override: Option<PathBuf>,
489    env_override: Option<&str>,
490    xdg_config_home: Option<&str>,
491    home: Option<&str>,
492) -> Option<ConfigPath> {
493    if let Some(p) = cli_override.filter(|p| !p.as_os_str().is_empty()) {
494        return Some(ConfigPath {
495            path: p,
496            explicit: true,
497        });
498    }
499    if let Some(p) = env_override.filter(|s| !s.is_empty()) {
500        return Some(ConfigPath {
501            path: PathBuf::from(p),
502            explicit: true,
503        });
504    }
505    if let Some(p) = xdg_config_home.filter(|s| !s.is_empty()) {
506        return Some(ConfigPath {
507            path: PathBuf::from(p).join("linesmith").join("config.toml"),
508            explicit: false,
509        });
510    }
511    home.filter(|s| !s.is_empty()).map(|h| ConfigPath {
512        path: PathBuf::from(h).join(".config/linesmith/config.toml"),
513        explicit: false,
514    })
515}
516
517/// Thin wrapper around [`resolve_config_path`] that reads the process
518/// env directly. Used at startup.
519#[must_use]
520pub fn detect_config_path(cli_override: Option<PathBuf>) -> Option<ConfigPath> {
521    let env_override = std::env::var("LINESMITH_CONFIG").ok();
522    let xdg_config_home = std::env::var("XDG_CONFIG_HOME").ok();
523    let home = std::env::var("HOME").ok();
524    resolve_config_path(
525        cli_override,
526        env_override.as_deref(),
527        xdg_config_home.as_deref(),
528        home.as_deref(),
529    )
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    // --- parse ---
537
538    #[test]
539    fn empty_config_parses() {
540        let c = Config::from_str("").expect("parse ok");
541        assert_eq!(c.line, None);
542        assert!(c.segments.is_empty());
543    }
544
545    #[test]
546    fn line_segments_parse_in_order() {
547        let c = Config::from_str(
548            r#"
549                [line]
550                segments = ["model", "workspace", "cost"]
551            "#,
552        )
553        .expect("parse ok");
554        let line = c.line.expect("line present");
555        assert_eq!(line.segments, vec!["model", "workspace", "cost"]);
556        assert!(line.numbered.is_empty(), "no numbered tables expected");
557    }
558
559    #[test]
560    fn layout_field_defaults_to_single_line_when_omitted() {
561        let c = Config::from_str("").expect("parse ok");
562        assert_eq!(c.layout, LayoutMode::SingleLine);
563    }
564
565    #[test]
566    fn layout_field_parses_kebab_case_variants() {
567        let c = Config::from_str(r#"layout = "single-line""#).expect("parse ok");
568        assert_eq!(c.layout, LayoutMode::SingleLine);
569        let c = Config::from_str(r#"layout = "multi-line""#).expect("parse ok");
570        assert_eq!(c.layout, LayoutMode::MultiLine);
571    }
572
573    /// Pull the `segments` array out of a `[line.N]` raw value. The
574    /// flatten map carries `toml::Value`, so test helpers do the
575    /// same shape-walk the builder's `extract_line_segments` does
576    /// without depending on the production helper directly.
577    fn numbered_segments(value: &toml::Value) -> Vec<String> {
578        let table = value.as_table().expect("expected table value");
579        let array = table["segments"]
580            .as_array()
581            .expect("expected segments array");
582        array
583            .iter()
584            .map(|v| v.as_str().expect("expected string").to_string())
585            .collect()
586    }
587
588    #[test]
589    fn line_numbered_only_parses() {
590        // Multi-line shape without a sibling `segments`: every key
591        // under `[line]` is a numbered child table.
592        let c = Config::from_str(
593            r#"
594                [line.1]
595                segments = ["model"]
596                [line.2]
597                segments = ["workspace", "cost"]
598            "#,
599        )
600        .expect("parse ok");
601        let line = c.line.expect("line present");
602        assert!(
603            line.segments.is_empty(),
604            "no top-level segments key expected"
605        );
606        assert_eq!(line.numbered.len(), 2);
607        assert_eq!(numbered_segments(&line.numbered["1"]), vec!["model"]);
608        assert_eq!(
609            numbered_segments(&line.numbered["2"]),
610            vec!["workspace", "cost"]
611        );
612    }
613
614    #[test]
615    fn line_with_segments_and_numbered_children_coexist() {
616        // The serde flatten + sibling field combination must accept
617        // both shapes simultaneously: `[line].segments` parses to the
618        // typed field, `[line.N]` sub-tables flatten into the
619        // numbered map. Edge case #3 from spec §Edge cases.
620        let c = Config::from_str(
621            r#"
622                [line]
623                segments = ["fallback"]
624                [line.1]
625                segments = ["a", "b"]
626                [line.2]
627                segments = ["c"]
628            "#,
629        )
630        .expect("parse ok");
631        let line = c.line.expect("line present");
632        assert_eq!(line.segments, vec!["fallback"]);
633        assert_eq!(line.numbered.len(), 2);
634        assert_eq!(numbered_segments(&line.numbered["1"]), vec!["a", "b"]);
635        assert_eq!(numbered_segments(&line.numbered["2"]), vec!["c"]);
636    }
637
638    #[test]
639    fn line_numbered_keys_preserved_verbatim_for_builder_validation() {
640        // The parser doesn't validate that numbered keys are positive
641        // integers — that's the builder's job (with a warning). Pin
642        // that contract so a future "smart" parser doesn't silently
643        // start dropping `[line.foo]` and break the warn-and-skip
644        // edge-case path.
645        let c = Config::from_str(
646            r#"
647                [line.foo]
648                segments = ["bogus"]
649                [line.10]
650                segments = ["valid"]
651            "#,
652        )
653        .expect("parse ok");
654        let line = c.line.expect("line present");
655        assert_eq!(line.numbered.len(), 2);
656        assert!(line.numbered.contains_key("foo"));
657        assert!(line.numbered.contains_key("10"));
658    }
659
660    #[test]
661    fn line_unknown_scalar_key_does_not_fail_parse_forward_compat() {
662        // CX-2-A regression guard: a typo'd or future-version scalar
663        // key under `[line]` (e.g. `[line] segmnts = [...]` or
664        // `[line] separator = "..."`) must NOT fail config load.
665        // The flatten map captures it as a raw `toml::Value`; the
666        // builder's `extract_line_segments` will warn-and-drop at
667        // render time. Without this contract, the spec's "unknown
668        // keys are warnings" forward-compat rule would silently
669        // regress for everything under `[line]`.
670        let c = Config::from_str(
671            r#"
672                [line]
673                segments = ["model"]
674                segmnts = ["typo"]              # scalar / array
675                future_separator = " | "        # scalar string
676                [line.1]
677                segments = ["valid"]
678            "#,
679        )
680        .expect("parse ok despite unknown sibling keys");
681        let line = c.line.expect("line present");
682        assert_eq!(line.segments, vec!["model"]);
683        // Unknown siblings show up in the flatten map; the [line.1]
684        // table sits next to them.
685        assert!(line.numbered.contains_key("segmnts"));
686        assert!(line.numbered.contains_key("future_separator"));
687        assert!(line.numbered.contains_key("1"));
688    }
689
690    #[test]
691    fn segment_override_priority_parses() {
692        let c = Config::from_str(
693            r#"
694                [segments.model]
695                priority = 16
696            "#,
697        )
698        .expect("parse ok");
699        assert_eq!(c.segments["model"].priority, Some(16));
700        assert_eq!(c.segments["model"].width, None);
701    }
702
703    #[test]
704    fn layout_options_color_and_padding_parse() {
705        let c = Config::from_str(
706            r#"
707                [layout_options]
708                color = "always"
709                claude_padding = 3
710            "#,
711        )
712        .expect("parse ok");
713        let lo = c.layout_options.expect("layout_options present");
714        assert_eq!(lo.color, ColorPolicy::Always);
715        assert_eq!(lo.claude_padding, 3);
716    }
717
718    #[test]
719    fn layout_options_color_accepts_all_three_variants() {
720        for (toml_val, expected) in [
721            ("auto", ColorPolicy::Auto),
722            ("always", ColorPolicy::Always),
723            ("never", ColorPolicy::Never),
724        ] {
725            let src = format!("[layout_options]\ncolor = \"{toml_val}\"\n");
726            let c = Config::from_str(&src).expect("parse ok");
727            assert_eq!(c.layout_options.map(|l| l.color), Some(expected));
728        }
729    }
730
731    // --- unknown-key validation ---
732
733    fn collect_warnings(src: &str) -> Vec<String> {
734        let mut warnings = Vec::new();
735        let _ = Config::from_str_validated(src, |msg| warnings.push(msg.to_string()));
736        warnings
737    }
738
739    #[test]
740    fn plugin_dirs_deserializes_from_toml_as_path_list() {
741        // Lock in the serde contract: `plugin_dirs = [...]` → Vec<PathBuf>
742        // with each entry preserved as written. This is the public
743        // entry point from user config into plugin discovery; a
744        // renamed field or lost `#[serde(default)]` would silently
745        // stop discovery from seeing user-declared dirs.
746        let cfg: Config = Config::from_str(
747            r#"
748                plugin_dirs = ["/etc/linesmith/segments", "./vendor/plugins"]
749                [line]
750                segments = ["model"]
751            "#,
752        )
753        .expect("parse");
754        assert_eq!(
755            cfg.plugin_dirs,
756            vec![
757                PathBuf::from("/etc/linesmith/segments"),
758                PathBuf::from("./vendor/plugins"),
759            ]
760        );
761    }
762
763    #[test]
764    fn plugin_dirs_defaults_to_empty_when_absent() {
765        let cfg: Config = Config::from_str("theme = \"default\"\n").expect("parse");
766        assert!(cfg.plugin_dirs.is_empty());
767    }
768
769    #[test]
770    fn from_str_validated_warns_on_unknown_top_level_key() {
771        let warnings = collect_warnings("thme = \"oops\"\n[line]\nsegments = []\n");
772        assert_eq!(warnings.len(), 1);
773        assert!(warnings[0].contains("thme"));
774        assert!(warnings[0].contains("top-level"));
775    }
776
777    #[test]
778    fn from_str_validated_allows_implemented_and_forward_compat_top_level_keys() {
779        // Spec-listed keys parse cleanly. Forward-compat keys
780        // (`preset`, `plugins`, `$schema`) populate their fields so
781        // a future `#[serde(skip_deserializing)]` regression or a
782        // dropped `rename = "$schema"` shows up here, not silently.
783        // `"$schema"` is quoted because TOML rejects `$` in bare keys.
784        let toml = r#"
785            "$schema" = "https://example.invalid/schema.json"
786            theme = "default"
787            preset = "developer"
788            layout = "single-line"
789            [line]
790            segments = ["model"]
791            [layout_options]
792            color = "auto"
793            [plugins.example]
794            foo = "bar"
795        "#;
796        let warnings = collect_warnings(toml);
797        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
798        let cfg = Config::from_str(toml).expect("parses");
799        assert_eq!(cfg.preset.as_deref(), Some("developer"));
800        assert_eq!(
801            cfg.schema_url.as_deref(),
802            Some("https://example.invalid/schema.json")
803        );
804        let plugins = cfg.plugins.expect("plugins table populated");
805        assert!(plugins.contains_key("example"));
806    }
807
808    #[test]
809    fn schema_for_config_round_trips_as_valid_json() {
810        // The drift check catches *changes* in generator output but
811        // not whether the output is well-formed JSON Schema in the
812        // first place. A future schemars-API typo could produce
813        // unserializable output; CI would only catch it after the
814        // committed schema bit-rotted. This pins basic validity.
815        let schema = schemars::schema_for!(Config);
816        let json = serde_json::to_string(&schema).expect("schema serializes as JSON");
817        let parsed: serde_json::Value =
818            serde_json::from_str(&json).expect("schema round-trips as JSON");
819        let obj = parsed.as_object().expect("schema root is an object");
820        assert_eq!(
821            obj.get("$schema").and_then(|v| v.as_str()),
822            Some("https://json-schema.org/draft/2020-12/schema"),
823            "schema must declare its meta-schema URI"
824        );
825        assert_eq!(
826            obj.get("title").and_then(|v| v.as_str()),
827            Some("Config"),
828            "schema must title the root type"
829        );
830        // Pin that the round-7→round-8 forward-compat fields
831        // actually materialized into the schema. A future
832        // `#[serde(skip)]` slipping onto one of them would still
833        // round-trip cleanly through the asserts above; this
834        // catches the materialization gap directly.
835        let properties = obj
836            .get("properties")
837            .and_then(|v| v.as_object())
838            .expect("schema declares properties");
839        for key in ["preset", "plugins", "$schema"] {
840            assert!(
841                properties.contains_key(key),
842                "schema must expose {key:?} as a top-level property"
843            );
844        }
845    }
846
847    #[test]
848    fn schema_directive_wrapped_body_round_trips_as_toml() {
849        // `with_schema_directive` prepends `#:schema URL\n\n` ahead
850        // of the preset body. A future regression that drops the
851        // separator (yielding `#:schema URL[body-first-line]`) would
852        // pass the position-pin tests in driver.rs but corrupt TOML
853        // parsing on bodies that start with `#` comments. Pin both
854        // the structural separator and the round-trip here.
855        let body = "[line]\nsegments = [\"model\"]\n";
856        let wrapped = with_schema_directive(body);
857        assert!(
858            wrapped.starts_with("#:schema https://"),
859            "directive at byte 0"
860        );
861        assert!(
862            wrapped.contains("\n\n["),
863            "blank-line separator before first table"
864        );
865        let parsed: Config = wrapped.parse().expect("wrapped body parses as Config");
866        assert_eq!(
867            parsed.line.expect("line").segments,
868            vec!["model".to_string()]
869        );
870    }
871
872    #[test]
873    fn from_str_validated_warns_on_unknown_layout_options_key() {
874        let warnings = collect_warnings(
875            r#"
876                [layout_options]
877                separatr = "powerline"
878            "#,
879        );
880        assert_eq!(warnings.len(), 1);
881        assert!(warnings[0].contains("separatr"));
882        assert!(warnings[0].contains("[layout_options]"));
883    }
884
885    #[test]
886    fn from_str_validated_allows_separator_and_other_known_layout_options_keys() {
887        // The known-keys allow-list lets `separator` through without
888        // an unknown-key warning; the segment builder parses the
889        // string and emits its own warnings (unknown values, v0.2+
890        // stubs).
891        let warnings = collect_warnings(
892            r#"
893                [layout_options]
894                color = "always"
895                claude_padding = 2
896                separator = "powerline"
897            "#,
898        );
899        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
900    }
901
902    #[test]
903    fn from_str_validated_warns_on_unknown_segment_override_key() {
904        let warnings = collect_warnings(
905            r#"
906                [segments.model]
907                priorty = 16
908            "#,
909        );
910        assert_eq!(warnings.len(), 1);
911        assert!(warnings[0].contains("priorty"));
912        assert!(warnings[0].contains("[segments.model]"));
913    }
914
915    #[test]
916    fn from_str_validated_names_the_segment_id_in_warnings() {
917        // Each segment block gets its own warnings namespaced by id so
918        // users with many segments can find which one has the typo.
919        let warnings = collect_warnings(
920            r#"
921                [segments.workspace]
922                bogus = "x"
923                [segments.cost]
924                alsobogus = 1
925            "#,
926        );
927        assert_eq!(warnings.len(), 2);
928        assert!(warnings
929            .iter()
930            .any(|w| w.contains("[segments.workspace]") && w.contains("bogus")));
931        assert!(warnings
932            .iter()
933            .any(|w| w.contains("[segments.cost]") && w.contains("alsobogus")));
934    }
935
936    #[test]
937    fn from_str_validated_skips_unknown_segment_ids_because_plugins_own_their_schema() {
938        // A segment id not in the built-in registry is either a future
939        // built-in or a plugin segment; plugins declare their own
940        // override keys, so we can't know what's valid. Skip rather
941        // than emit false positives.
942        let warnings = collect_warnings(
943            r#"
944                [segments.my_plugin]
945                foo = "bar"
946                baz = 42
947
948                [segments.another_plugin]
949                show_ahead_behind = true
950                show_dirty = true
951            "#,
952        );
953        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
954    }
955
956    #[test]
957    fn from_str_validated_rejects_segment_specific_keys_on_wrong_built_in() {
958        // `show_dirty` is a git_branch concept; putting it on `model`
959        // is a user mistake the validator should catch.
960        let warnings = collect_warnings(
961            r#"
962                [segments.model]
963                show_dirty = true
964            "#,
965        );
966        assert_eq!(warnings.len(), 1);
967        assert!(warnings[0].contains("show_dirty"));
968        assert!(warnings[0].contains("[segments.model]"));
969    }
970
971    #[test]
972    fn from_str_validated_allows_spec_documented_segment_override_keys() {
973        // `style` (style-string syntax) and `visible_if` (rhai plugin
974        // expressions) are spec'd but not yet implemented; tolerated
975        // so spec example configs parse cleanly.
976        let warnings = collect_warnings(
977            r#"
978                [segments.workspace]
979                priority = 16
980                width = { min = 10, max = 40 }
981                style = "role:info"
982                visible_if = "true"
983            "#,
984        );
985        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
986    }
987
988    #[test]
989    fn model_segment_allows_format_key_without_warning() {
990        let warnings = collect_warnings(
991            r#"
992                [segments.model]
993                format = "compact"
994            "#,
995        );
996        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
997
998        let warnings_full = collect_warnings(
999            r#"
1000                [segments.model]
1001                format = "full"
1002            "#,
1003        );
1004        assert!(
1005            warnings_full.is_empty(),
1006            "unexpected warnings: {warnings_full:?}"
1007        );
1008    }
1009
1010    #[test]
1011    fn workspace_segment_warns_when_format_key_set() {
1012        // `format` is a model-only key; the validator's per-id schema
1013        // split should reject it on `workspace` (and the rest of
1014        // `BUILT_IN_COMMON`) so silent typos don't slip through.
1015        let warnings = collect_warnings(
1016            r#"
1017                [segments.workspace]
1018                format = "compact"
1019            "#,
1020        );
1021        assert_eq!(warnings.len(), 1);
1022        assert!(warnings[0].contains("format"));
1023        assert!(warnings[0].contains("[segments.workspace]"));
1024    }
1025
1026    #[test]
1027    fn git_branch_allows_per_marker_hide_below_cells_without_warning() {
1028        // `[segments.git_branch.dirty]` and `.ahead_behind` are
1029        // pass-through sub-tables, so per-marker `hide_below_cells`
1030        // reaches `from_extras` instead of tripping the unknown-key
1031        // validator.
1032        let warnings = collect_warnings(
1033            r#"
1034                [segments.git_branch.dirty]
1035                hide_below_cells = 50
1036
1037                [segments.git_branch.ahead_behind]
1038                hide_below_cells = 80
1039            "#,
1040        );
1041        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1042    }
1043
1044    #[test]
1045    fn rate_limit_percent_segments_allow_format_and_invert_without_warning() {
1046        let warnings = collect_warnings(
1047            r#"
1048                [segments.rate_limit_5h]
1049                format = "progress"
1050                invert = true
1051                icon = "⏱"
1052                label = "5h"
1053                stale_marker = "~"
1054                progress_width = 20
1055
1056                [segments.rate_limit_7d]
1057                format = "percent"
1058                invert = false
1059            "#,
1060        );
1061        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1062    }
1063
1064    #[test]
1065    fn rate_limit_reset_segments_allow_compact_and_use_days_without_warning() {
1066        let warnings = collect_warnings(
1067            r#"
1068                [segments.rate_limit_5h_reset]
1069                format = "duration"
1070                compact = true
1071                use_days = false
1072
1073                [segments.rate_limit_7d_reset]
1074                format = "progress"
1075                use_days = true
1076            "#,
1077        );
1078        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1079    }
1080
1081    #[test]
1082    fn extra_usage_allows_currency_and_percent_format_without_warning() {
1083        let warnings = collect_warnings(
1084            r#"
1085                [segments.extra_usage]
1086                format = "currency"
1087                icon = ""
1088                label = "extra"
1089                stale_marker = "~"
1090            "#,
1091        );
1092        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1093    }
1094
1095    #[test]
1096    fn invert_warns_on_reset_segment_schema() {
1097        // `invert` is percent-family only; allow-list for reset
1098        // segments must reject it.
1099        let warnings = collect_warnings(
1100            r#"
1101                [segments.rate_limit_5h_reset]
1102                invert = true
1103            "#,
1104        );
1105        assert_eq!(warnings.len(), 1);
1106        assert!(
1107            warnings[0].contains("invert") && warnings[0].contains("rate_limit_5h_reset"),
1108            "{:?}",
1109            warnings[0]
1110        );
1111    }
1112
1113    #[test]
1114    fn use_days_warns_on_percent_segment_schema() {
1115        // `use_days` is reset-family only; allow-list for percent
1116        // segments must reject it.
1117        let warnings = collect_warnings(
1118            r#"
1119                [segments.rate_limit_5h]
1120                use_days = true
1121            "#,
1122        );
1123        assert_eq!(warnings.len(), 1);
1124        assert!(
1125            warnings[0].contains("use_days") && warnings[0].contains("rate_limit_5h"),
1126            "{:?}",
1127            warnings[0]
1128        );
1129    }
1130
1131    #[test]
1132    fn from_str_validated_returns_parse_error_for_malformed_toml() {
1133        let mut warnings = Vec::new();
1134        let err =
1135            Config::from_str_validated("[line\nsegments =", |msg| warnings.push(msg.to_string()))
1136                .unwrap_err();
1137        assert!(matches!(err, ConfigError::Parse { .. }));
1138    }
1139
1140    #[test]
1141    fn validated_and_silent_parse_yield_identical_config_on_clean_input() {
1142        // Locks the "validation is purely observational" contract:
1143        // from_str_validated must not mutate parse semantics.
1144        let src = r#"
1145            theme = "default"
1146            [line]
1147            segments = ["model", "workspace"]
1148            [segments.model]
1149            priority = 8
1150        "#;
1151        let silent = Config::from_str(src).expect("silent parse");
1152        let validated = Config::from_str_validated(src, |_| {}).expect("validated parse");
1153        assert_eq!(silent, validated);
1154    }
1155
1156    #[test]
1157    fn load_validated_file_path_surfaces_parse_error_with_path() {
1158        // The in-memory variant returns ConfigError::Parse { path: None };
1159        // the file variant must populate path for user-facing diagnostics.
1160        let dir = tempdir();
1161        let path = dir.path().join("config.toml");
1162        std::fs::write(&path, "[line\nsegments =").unwrap();
1163        let err = Config::load_validated(&path, |_| {}).unwrap_err();
1164        match err {
1165            ConfigError::Parse { path: Some(p), .. } => assert_eq!(p, path),
1166            other => panic!("expected Parse with Some(path), got {other:?}"),
1167        }
1168    }
1169
1170    #[test]
1171    fn load_validated_returns_none_for_missing_file() {
1172        let dir = tempdir();
1173        let path = dir.path().join("missing.toml");
1174        let mut warnings = Vec::new();
1175        let got = Config::load_validated(&path, |m| warnings.push(m.to_string())).expect("ok");
1176        assert!(got.is_none());
1177        assert!(warnings.is_empty());
1178    }
1179
1180    #[test]
1181    fn load_validated_surfaces_unknown_key_warnings() {
1182        let dir = tempdir();
1183        let path = dir.path().join("config.toml");
1184        std::fs::write(&path, "thme = \"bad\"\n").unwrap();
1185        let mut warnings = Vec::new();
1186        let _ = Config::load_validated(&path, |m| warnings.push(m.to_string())).unwrap();
1187        assert_eq!(warnings.len(), 1);
1188        assert!(warnings[0].contains("thme"));
1189    }
1190
1191    #[test]
1192    fn layout_options_defaults_populate_missing_keys() {
1193        // `[layout_options]` with no fields inside still parses; missing
1194        // color defaults to Auto, missing claude_padding defaults to 0.
1195        let c = Config::from_str("[layout_options]\n").expect("parse ok");
1196        let lo = c.layout_options.expect("layout_options present");
1197        assert_eq!(lo.color, ColorPolicy::Auto);
1198        assert_eq!(lo.claude_padding, 0);
1199    }
1200
1201    #[test]
1202    fn layout_options_rejects_unknown_color_variant() {
1203        let err = Config::from_str(
1204            r#"
1205                [layout_options]
1206                color = "bogus"
1207            "#,
1208        )
1209        .unwrap_err();
1210        assert!(matches!(err, ConfigError::Parse { .. }));
1211    }
1212
1213    #[test]
1214    fn layout_options_omitted_entirely_is_ok() {
1215        let c = Config::from_str("[line]\nsegments = [\"model\"]\n").expect("parse ok");
1216        assert!(c.layout_options.is_none());
1217    }
1218
1219    #[test]
1220    fn segment_override_width_parses_both_sides() {
1221        let c = Config::from_str(
1222            r#"
1223                [segments.workspace.width]
1224                min = 10
1225                max = 40
1226            "#,
1227        )
1228        .expect("parse ok");
1229        let w = c.segments["workspace"].width.expect("width present");
1230        assert_eq!(w.min, Some(10));
1231        assert_eq!(w.max, Some(40));
1232    }
1233
1234    #[test]
1235    fn unknown_top_level_key_is_forward_compatible() {
1236        // Config files from a newer linesmith must still parse on an
1237        // older binary; fields this version doesn't implement are
1238        // ignored rather than rejected.
1239        let c = Config::from_str(
1240            r#"
1241                theme = "catppuccin-mocha"
1242                layout = "single-line"
1243                [layout_options]
1244                separator = "powerline"
1245            "#,
1246        )
1247        .expect("parse ok");
1248        assert_eq!(c.line, None);
1249        assert!(c.segments.is_empty());
1250    }
1251
1252    #[test]
1253    fn malformed_toml_reports_parse_error() {
1254        let err = Config::from_str("[line").unwrap_err();
1255        assert!(matches!(err, ConfigError::Parse { .. }));
1256    }
1257
1258    #[test]
1259    fn io_error_carries_path_in_display() {
1260        use std::io::ErrorKind;
1261        let err = ConfigError::Io {
1262            path: PathBuf::from("/etc/linesmith/config.toml"),
1263            source: std::io::Error::new(ErrorKind::PermissionDenied, "denied"),
1264        };
1265        let rendered = err.to_string();
1266        assert!(rendered.contains("/etc/linesmith/config.toml"));
1267        assert!(rendered.contains("denied"));
1268    }
1269
1270    #[test]
1271    fn bom_prefixed_config_parses() {
1272        // Windows editors sometimes save configs with a leading UTF-8
1273        // BOM. The `toml` crate tolerates it, so no explicit strip is
1274        // needed; this test locks that behavior.
1275        let dir = tempdir();
1276        let path = dir.path().join("config.toml");
1277        std::fs::write(&path, "\u{FEFF}[line]\nsegments = [\"model\"]\n").unwrap();
1278        let c = Config::load(&path).expect("ok").expect("present");
1279        assert_eq!(c.line.expect("line").segments, vec!["model".to_string()]);
1280    }
1281
1282    #[test]
1283    fn load_returns_none_for_missing_file() {
1284        let dir = tempdir();
1285        let path = dir.path().join("nonexistent.toml");
1286        assert!(Config::load(&path).unwrap().is_none());
1287    }
1288
1289    // --- path resolution ---
1290
1291    fn resolved(
1292        cli: Option<&str>,
1293        env: Option<&str>,
1294        xdg: Option<&str>,
1295        home: Option<&str>,
1296    ) -> Option<ConfigPath> {
1297        resolve_config_path(cli.map(PathBuf::from), env, xdg, home)
1298    }
1299
1300    #[test]
1301    fn cli_override_wins_over_everything_and_is_explicit() {
1302        let got = resolved(
1303            Some("/explicit.toml"),
1304            Some("/env.toml"),
1305            Some("/xdg"),
1306            Some("/home"),
1307        )
1308        .expect("resolved");
1309        assert_eq!(got.path, PathBuf::from("/explicit.toml"));
1310        assert!(got.explicit);
1311    }
1312
1313    #[test]
1314    fn env_wins_over_xdg_and_home_and_is_explicit() {
1315        let got = resolved(None, Some("/env.toml"), Some("/xdg"), Some("/home")).expect("resolved");
1316        assert_eq!(got.path, PathBuf::from("/env.toml"));
1317        assert!(got.explicit);
1318    }
1319
1320    #[test]
1321    fn xdg_config_home_is_implicit() {
1322        let got = resolved(None, None, Some("/xdg"), Some("/home")).expect("resolved");
1323        assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
1324        assert!(!got.explicit);
1325    }
1326
1327    #[test]
1328    fn home_fallback_is_implicit() {
1329        let got = resolved(None, None, None, Some("/home")).expect("resolved");
1330        assert_eq!(
1331            got.path,
1332            PathBuf::from("/home/.config/linesmith/config.toml")
1333        );
1334        assert!(!got.explicit);
1335    }
1336
1337    #[test]
1338    fn returns_none_when_no_home_and_no_xdg() {
1339        assert_eq!(resolved(None, None, None, None), None);
1340    }
1341
1342    #[test]
1343    fn empty_env_values_are_ignored() {
1344        let got = resolved(None, Some(""), Some(""), Some("/home")).expect("resolved");
1345        assert_eq!(
1346            got.path,
1347            PathBuf::from("/home/.config/linesmith/config.toml")
1348        );
1349    }
1350
1351    #[test]
1352    fn empty_cli_override_does_not_count_as_explicit() {
1353        // A shell expansion like `--config "$MISSING_VAR"` can produce
1354        // an empty path; skip past it rather than silently treating it
1355        // as "load ''" which would NotFound-swallow.
1356        let got = resolved(Some(""), None, Some("/xdg"), None).expect("resolved");
1357        assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
1358        assert!(!got.explicit);
1359    }
1360
1361    // --- helpers ---
1362
1363    struct TempDir(PathBuf);
1364
1365    impl TempDir {
1366        fn path(&self) -> &Path {
1367            &self.0
1368        }
1369    }
1370
1371    impl Drop for TempDir {
1372        fn drop(&mut self) {
1373            let _ = std::fs::remove_dir_all(&self.0);
1374        }
1375    }
1376
1377    fn tempdir() -> TempDir {
1378        use std::sync::atomic::{AtomicU64, Ordering};
1379        static COUNTER: AtomicU64 = AtomicU64::new(0);
1380        let base = std::env::temp_dir().join(format!(
1381            "linesmith-config-test-{}-{}",
1382            std::time::SystemTime::now()
1383                .duration_since(std::time::UNIX_EPOCH)
1384                .expect("clock")
1385                .as_nanos(),
1386            COUNTER.fetch_add(1, Ordering::Relaxed),
1387        ));
1388        std::fs::create_dir_all(&base).expect("mkdir");
1389        TempDir(base)
1390    }
1391}