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    /// Custom deserializer routes each entry through `try_into` per-
115    /// item so a single malformed inline-table (e.g.
116    /// `{ type = 42 }`) doesn't abort the whole `Config::from_str`
117    /// — it surfaces as a kindless [`LineEntry::Item`] that the
118    /// builder warns and drops. Mirrors the per-item warn-and-drop
119    /// behavior the numbered-line path already had via
120    /// [`crate::segments::builder`]'s `extract_line_segments`. Without
121    /// this, single-line configs with one bad boundary override
122    /// fail to load entirely while multi-line configs degrade
123    /// gracefully — an asymmetry users hit when porting between
124    /// layouts.
125    #[serde(deserialize_with = "deserialize_line_entries")]
126    pub segments: Vec<LineEntry>,
127    /// Anything under `[line]` other than `segments`. Holds
128    /// `[line.N]` table values plus any forward-compat scalar keys
129    /// future versions may add. The builder routes table values
130    /// with positive-integer keys to multi-line rendering and warns
131    /// on the rest.
132    ///
133    /// Schema bypass: `toml::Value` has no `JsonSchema` impl, so
134    /// remap to `serde_json::Value`'s open-ended schema (any JSON
135    /// type) for the `additionalProperties` fallthrough.
136    #[serde(flatten)]
137    #[schemars(with = "serde_json::Value")]
138    pub numbered: BTreeMap<String, toml::Value>,
139}
140
141/// One entry in `[line].segments`. Per ADR-0024, the array is a
142/// mixed shape: bare strings (`"model"`) round-trip as
143/// [`LineEntry::Id`] for backward compatibility with the v0.x string-
144/// only schema; inline tables (`{ type = "separator", character = " | " }`)
145/// round-trip as [`LineEntry::Item`] and carry per-boundary settings.
146///
147/// Untagged because the strict-tagged form would reject the bare-string
148/// shorthand at parse time. Typo'd keys inside an inline table (e.g.
149/// `{ tpye = "separator" }`) land in [`LineEntryItem::extra`]
150/// rather than failing parse, preserving the spec's "unknown keys
151/// warn, never fail" contract. The runtime builder warns when a
152/// kindless inline table reaches it; per-key typo diagnostics
153/// inside `[line].segments` array entries are not yet surfaced
154/// at config-load time (the existing `validate_keys` pass walks
155/// only top-level / `[layout_options]` / `[segments.<id>]` shapes).
156#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
157#[serde(untagged)]
158pub enum LineEntry {
159    /// Bare string: `"model"` is equivalent to `{ type = "model" }`.
160    Id(String),
161    /// Inline table: `{ type = "...", ... }`. Carries the kind tag
162    /// plus optional per-entry knobs (separator glyph, merge flag,
163    /// future ccstatusline-parity fields under [`LineEntryItem::extra`]).
164    Item(LineEntryItem),
165}
166
167/// Inline-table form of [`LineEntry`]. Typed fields cover today's
168/// known knobs; everything else lands in [`extra`](Self::extra) so
169/// future fields parse without a schema bump.
170#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
171#[serde(default)]
172pub struct LineEntryItem {
173    /// `"separator"` or a segment id (`"model"`, `"git_branch"`, ...).
174    /// When absent, the builder warns and drops the entry.
175    #[serde(rename = "type")]
176    pub kind: Option<String>,
177    /// Separator glyph for `type = "separator"` entries. Ignored
178    /// (with warning) on non-separator entries. When `None` on a
179    /// separator entry, the builder falls back to
180    /// `[layout_options].separator`.
181    pub character: Option<String>,
182    /// When `true` on a segment entry, the boundary to its right
183    /// renders without a separator (suppresses the implicit
184    /// interleave AND any explicit [`LineEntry::Item`] separator at
185    /// that boundary). Ignored (with warning) on separator entries.
186    pub merge: Option<bool>,
187    /// Forward-compat bag: keys outside the typed fields land here
188    /// per the `toml::Value` flatten pattern. The builder
189    /// warn-and-drops unknown keys today; future ADRs may consume.
190    ///
191    /// Schema bypass: `toml::Value` has no `JsonSchema` impl, so
192    /// remap to `serde_json::Value`'s open-ended schema for the
193    /// `additionalProperties` fallthrough.
194    #[serde(flatten)]
195    #[schemars(with = "serde_json::Value")]
196    pub extra: BTreeMap<String, toml::Value>,
197}
198
199impl LineEntry {
200    /// The entry's `type` tag — segment id, `"separator"`, or `None`
201    /// for a malformed inline table missing `type`. The builder
202    /// warns and drops `None` entries.
203    #[must_use]
204    pub fn kind(&self) -> Option<&str> {
205        match self {
206            Self::Id(s) => Some(s.as_str()),
207            Self::Item(item) => item.kind.as_deref(),
208        }
209    }
210
211    /// `true` when the entry is `type = "separator"`. Bare strings
212    /// are never separators; an inline table without a `type` field
213    /// is also not classified as a separator (the builder drops it).
214    #[must_use]
215    pub fn is_separator(&self) -> bool {
216        self.kind() == Some("separator")
217    }
218
219    /// The segment id, or `None` for separators / kindless entries.
220    #[must_use]
221    pub fn segment_id(&self) -> Option<&str> {
222        match self.kind() {
223            Some("separator") | None => None,
224            Some(id) => Some(id),
225        }
226    }
227
228    /// The separator-glyph override on a `type = "separator"` entry,
229    /// or `None` when the entry uses the global default. Always
230    /// `None` for non-separator entries.
231    #[must_use]
232    pub fn separator_character(&self) -> Option<&str> {
233        match self {
234            Self::Item(item) if item.kind.as_deref() == Some("separator") => {
235                item.character.as_deref()
236            }
237            _ => None,
238        }
239    }
240
241    /// `true` when this entry sets `merge = true`. Always `false`
242    /// for separators and bare-string entries. Inline tables on
243    /// separators with a `merge` field warn at build time and the
244    /// flag is not honored here.
245    #[must_use]
246    pub fn merge(&self) -> bool {
247        match self {
248            Self::Item(item) if item.kind.as_deref() != Some("separator") => {
249                item.merge.unwrap_or(false)
250            }
251            _ => false,
252        }
253    }
254}
255
256impl From<&str> for LineEntry {
257    fn from(s: &str) -> Self {
258        Self::Id(s.to_string())
259    }
260}
261
262impl From<String> for LineEntry {
263    fn from(s: String) -> Self {
264        Self::Id(s)
265    }
266}
267
268/// Per-item-tolerant deserialization for `LineConfig.segments`.
269/// Reads the array as `Vec<toml::Value>` then converts each entry
270/// individually: a string becomes [`LineEntry::Id`], a well-formed
271/// inline-table becomes [`LineEntry::Item`], and any malformed item
272/// (wrong-typed `type`, non-string non-table value, table that
273/// fails the `LineEntryItem` shape) falls through to a kindless
274/// [`LineEntry::Item`] that the builder warns and drops.
275///
276/// Mirrors the per-item warn-and-drop behavior of the numbered-line
277/// path so single-line and multi-line configs treat malformed items
278/// identically: parse never aborts on one bad entry; the builder
279/// surfaces the diagnostic at render time.
280fn deserialize_line_entries<'de, D>(deserializer: D) -> Result<Vec<LineEntry>, D::Error>
281where
282    D: serde::Deserializer<'de>,
283{
284    let raw = Vec::<toml::Value>::deserialize(deserializer)?;
285    Ok(raw.into_iter().map(value_to_line_entry).collect())
286}
287
288fn value_to_line_entry(value: toml::Value) -> LineEntry {
289    if let toml::Value::String(s) = &value {
290        return LineEntry::Id(s.clone());
291    }
292    if let toml::Value::Table(_) = &value {
293        if let Ok(item) = value.clone().try_into::<LineEntryItem>() {
294            return LineEntry::Item(item);
295        }
296    }
297    // Malformed: capture in a kindless `LineEntryItem` so the entry
298    // survives parse + round-trips through the document but reaches
299    // the builder as a "no `type`" warn-and-drop. Tables preserve
300    // their keys in `extra` for forward-compat; bare scalars stash
301    // under a synthetic key so the value isn't silently dropped at
302    // load time.
303    let mut extra: BTreeMap<String, toml::Value> = BTreeMap::new();
304    if let toml::Value::Table(table) = value {
305        for (k, v) in table {
306            extra.insert(k, v);
307        }
308    } else {
309        extra.insert("__malformed__".to_string(), value);
310    }
311    LineEntry::Item(LineEntryItem {
312        kind: None,
313        character: None,
314        merge: None,
315        extra,
316    })
317}
318
319/// Top-level `layout = "..."` selector. Defaults to `SingleLine`
320/// (preserves pre-multi-line config behavior). `MultiLine` instructs
321/// the builder + render loop to consume `[line.N]` sub-tables.
322#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
323#[serde(rename_all = "kebab-case")]
324#[non_exhaustive]
325pub enum LayoutMode {
326    #[default]
327    SingleLine,
328    MultiLine,
329}
330
331/// `[segments.<id>]` override block. Each typed field, when `Some`,
332/// replaces the segment's built-in default. Any unrecognized keys land
333/// in [`extra`](Self::extra), which the segment builder forwards to
334/// plugin scripts as `ctx.config.<key>`. `style` is stored as a raw
335/// string; the segment builder parses it at build time
336/// so parse errors emit warnings through the same callback that
337/// handles unknown-ID and inverted-bounds diagnostics.
338///
339/// `Eq` isn't derived because [`toml::Value`] holds `f64` and so is
340/// `PartialEq` only — `extra` propagates that constraint.
341#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
342#[serde(default)]
343pub struct SegmentOverride {
344    pub priority: Option<u8>,
345    pub width: Option<WidthBoundsConfig>,
346    pub style: Option<String>,
347    /// Plugin-config bag: every TOML key under `[segments.<plugin-id>]`
348    /// not matched by a typed field. Surfaced to the rhai script as
349    /// `ctx.config.<key>` per `docs/specs/plugin-api.md` §ctx shape.
350    /// Built-in segments ignore this; the unknown-key validator still
351    /// warns when a built-in's table contains keys outside its schema.
352    ///
353    /// Schema bypass: `toml::Value` has no `JsonSchema` impl, so
354    /// remap to `serde_json::Value`'s open-ended schema for the
355    /// `additionalProperties` fallthrough.
356    #[serde(flatten)]
357    #[schemars(with = "serde_json::Value")]
358    pub extra: BTreeMap<String, toml::Value>,
359}
360
361/// URL for the published JSON Schema, pinned to `main`. Single
362/// canonical URL — same shape bacon, starship, and dprint ship.
363/// The schema evolves forward-compatibly (fields added, not
364/// removed); editors validate "config field is allowed by schema"
365/// rather than "binary supports field," so a schema slightly ahead
366/// of the installed binary loosens validation rather than tightens
367/// it. Versioned per-tag self-hosted URLs (biome's model) are the
368/// destination once `linesmith` has its own website plus
369/// schemastore.org coverage.
370pub const SCHEMA_URL: &str =
371    "https://raw.githubusercontent.com/oakoss/linesmith/main/config.schema.json";
372
373/// Prepend `#:schema <url>` directive (taplo / VS Code / Zed
374/// convention) to a freshly-generated config body so editors pick up
375/// the published schema without per-user setup.
376pub fn with_schema_directive(body: &str) -> String {
377    format!("#:schema {SCHEMA_URL}\n\n{body}")
378}
379
380/// Width-bounds override. Either side may be omitted; a missing side
381/// inherits from the segment's built-in default.
382#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
383#[schemars(extend("additionalProperties" = false))]
384pub struct WidthBoundsConfig {
385    pub min: Option<u16>,
386    pub max: Option<u16>,
387}
388
389/// Failure modes when loading a config. A missing file is not an error;
390/// callers treat it as "use defaults."
391#[derive(Debug)]
392#[non_exhaustive]
393pub enum ConfigError {
394    /// `fs::read` failed for a reason other than `NotFound`. Carries
395    /// the offending path so the stderr diagnostic is self-describing.
396    Io {
397        path: PathBuf,
398        source: std::io::Error,
399    },
400    /// Invalid TOML. `path` is `None` for in-memory parses.
401    Parse {
402        path: Option<PathBuf>,
403        source: toml::de::Error,
404    },
405}
406
407impl std::fmt::Display for ConfigError {
408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        match self {
410            Self::Io { path, source } => write!(f, "config I/O at {}: {source}", path.display()),
411            Self::Parse {
412                path: Some(p),
413                source,
414            } => write!(f, "config parse at {}: {source}", p.display()),
415            Self::Parse { path: None, source } => write!(f, "config parse: {source}"),
416        }
417    }
418}
419
420impl std::error::Error for ConfigError {
421    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
422        match self {
423            Self::Io { source, .. } => Some(source),
424            Self::Parse { source, .. } => Some(source),
425        }
426    }
427}
428
429impl FromStr for Config {
430    type Err = ConfigError;
431
432    fn from_str(s: &str) -> Result<Self, Self::Err> {
433        toml::from_str(s).map_err(|source| ConfigError::Parse { path: None, source })
434    }
435}
436
437impl Config {
438    /// Read and parse the file at `path`. Returns `Ok(None)` when the
439    /// file doesn't exist (normal case for first-run users); other I/O
440    /// errors propagate so callers can log them. Unknown keys are
441    /// silently ignored — callers that want typo warnings use
442    /// [`Config::load_validated`] instead.
443    pub fn load(path: &Path) -> Result<Option<Self>, ConfigError> {
444        let raw = match std::fs::read_to_string(path) {
445            Ok(s) => s,
446            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
447            Err(source) => {
448                return Err(ConfigError::Io {
449                    path: path.to_owned(),
450                    source,
451                })
452            }
453        };
454        toml::from_str(&raw)
455            .map(Some)
456            .map_err(|source| ConfigError::Parse {
457                path: Some(path.to_owned()),
458                source,
459            })
460    }
461
462    /// Same as [`Config::load`] but emits one warning per unknown key
463    /// encountered (top-level, `[layout_options]`, or `[segments.<id>]`).
464    /// The allow-list tolerates every spec-documented key (see
465    /// `KNOWN_TOP_LEVEL` and `KNOWN_LAYOUT_OPTIONS`), so forward-compat
466    /// configs stay silent while typos surface.
467    pub fn load_validated(
468        path: &Path,
469        warn: impl FnMut(&str),
470    ) -> Result<Option<Self>, ConfigError> {
471        let raw = match std::fs::read_to_string(path) {
472            Ok(s) => s,
473            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
474            Err(source) => {
475                return Err(ConfigError::Io {
476                    path: path.to_owned(),
477                    source,
478                })
479            }
480        };
481        Self::from_str_validated_impl(&raw, Some(path), warn).map(Some)
482    }
483
484    /// [`FromStr`]-equivalent with unknown-key warnings. The plain
485    /// `FromStr` impl remains the non-validating form; validation is
486    /// opt-in so callers that don't want the allow-list surface (unit
487    /// tests, programmatic config construction) bypass it.
488    pub fn from_str_validated(s: &str, warn: impl FnMut(&str)) -> Result<Self, ConfigError> {
489        Self::from_str_validated_impl(s, None, warn)
490    }
491
492    fn from_str_validated_impl(
493        s: &str,
494        path: Option<&Path>,
495        mut warn: impl FnMut(&str),
496    ) -> Result<Self, ConfigError> {
497        let raw: toml::Value = toml::from_str(s).map_err(|source| ConfigError::Parse {
498            path: path.map(Path::to_owned),
499            source,
500        })?;
501        validate_keys(&raw, &mut warn);
502        raw.try_into()
503            .map_err(|source: toml::de::Error| ConfigError::Parse {
504                path: path.map(Path::to_owned),
505                source,
506            })
507    }
508}
509
510/// Top-level config keys we recognize. Spec-documented keys (some
511/// runtime-consumed, some forward-compat parsed-and-ignored) plus
512/// `$schema` for editor tooling. Anything not in this list raises a
513/// per-key warning through `from_str_validated_impl`.
514const KNOWN_TOP_LEVEL: &[&str] = &[
515    "line",
516    "theme",
517    "layout_options",
518    "segments",
519    "plugin_dirs",
520    "preset",
521    "layout",
522    "plugins",
523    "$schema",
524];
525
526/// Fields under `[layout_options]`. `separator` is tolerated ahead
527/// of its implementation so forward-compat configs don't warn.
528const KNOWN_LAYOUT_OPTIONS: &[&str] = &["color", "claude_padding", "separator", "powerline_width"];
529
530/// Per-segment override schema. Returns `None` for segment ids we
531/// don't recognize so plugin segments (which own their own schema)
532/// bypass validation. Most built-ins share the universal allow-list;
533/// rate-limit segments extend it with per-family knobs that
534/// `segments::rate_limit::format` reads from the TOML extras bag.
535fn segment_override_schema(id: &str) -> Option<&'static [&'static str]> {
536    const BUILT_IN_COMMON: &[&str] = &["priority", "width", "style", "visible_if"];
537    const RATE_LIMIT_COMMON: &[&str] = &[
538        "priority",
539        "width",
540        "style",
541        "visible_if",
542        "icon",
543        "label",
544        "stale_marker",
545        "progress_width",
546        "format",
547    ];
548    const PERCENT_SEGMENT: &[&str] = &[
549        "priority",
550        "width",
551        "style",
552        "visible_if",
553        "icon",
554        "label",
555        "stale_marker",
556        "progress_width",
557        "format",
558        "invert",
559    ];
560    const RESET_SEGMENT: &[&str] = &[
561        "priority",
562        "width",
563        "style",
564        "visible_if",
565        "icon",
566        "label",
567        "stale_marker",
568        "progress_width",
569        "format",
570        "compact",
571        "use_days",
572        // Absolute-format knobs — consumed when `format = "absolute"`,
573        // ignored (without warning) under "duration" / "progress".
574        "timezone",
575        "hour_format",
576        "locale",
577    ];
578    // Nested tables like `dirty` are validated shallowly per
579    // `validate_segments_table` — their inner keys pass through
580    // without warning. Inner schemas live in the segment's
581    // `from_extras` validator.
582    const GIT_BRANCH_SEGMENT: &[&str] = &[
583        "priority",
584        "width",
585        "style",
586        "visible_if",
587        "icon",
588        "label",
589        "max_length",
590        "truncation_marker",
591        "short_sha_length",
592        "dirty",
593        "ahead_behind",
594    ];
595    const MODEL_SEGMENT: &[&str] = &["priority", "width", "style", "visible_if", "format"];
596    match id {
597        "model" => Some(MODEL_SEGMENT),
598        "workspace" | "cost" | "effort" | "context_window" => Some(BUILT_IN_COMMON),
599        "rate_limit_5h" | "rate_limit_7d" => Some(PERCENT_SEGMENT),
600        "rate_limit_5h_reset" | "rate_limit_7d_reset" => Some(RESET_SEGMENT),
601        "extra_usage" => Some(RATE_LIMIT_COMMON),
602        "git_branch" => Some(GIT_BRANCH_SEGMENT),
603        _ => None,
604    }
605}
606
607/// Walk `raw` and emit one warning per key outside the allow-list.
608/// Scope is intentionally shallow: top-level, `[layout_options]`
609/// fields, and fields directly under each `[segments.<id>]` table.
610/// Deeper nesting (plugin configs, per-line segments) stays silent
611/// until those features land with their own schemas.
612fn validate_keys(raw: &toml::Value, warn: &mut impl FnMut(&str)) {
613    let Some(top) = raw.as_table() else {
614        return;
615    };
616    for (key, value) in top {
617        if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
618            warn(&format!("unknown top-level config key '{key}'; ignoring"));
619            continue;
620        }
621        match key.as_str() {
622            "layout_options" => {
623                validate_flat_table(value, "layout_options", KNOWN_LAYOUT_OPTIONS, warn)
624            }
625            "segments" => validate_segments_table(value, warn),
626            _ => {}
627        }
628    }
629}
630
631fn validate_flat_table(
632    value: &toml::Value,
633    label: &str,
634    allowed: &[&str],
635    warn: &mut impl FnMut(&str),
636) {
637    let Some(table) = value.as_table() else {
638        return;
639    };
640    for key in table.keys() {
641        if !allowed.contains(&key.as_str()) {
642            warn(&format!("unknown key '{key}' in [{label}]; ignoring"));
643        }
644    }
645}
646
647fn validate_segments_table(value: &toml::Value, warn: &mut impl FnMut(&str)) {
648    let Some(segments) = value.as_table() else {
649        return;
650    };
651    for (id, block) in segments {
652        let Some(block_table) = block.as_table() else {
653            continue;
654        };
655        let Some(allowed) = segment_override_schema(id) else {
656            // Plugin or not-yet-shipped segment id; skip so plugin
657            // config keys pass through. Plugins own their schema via
658            // the plugin API when that lands.
659            continue;
660        };
661        for key in block_table.keys() {
662            if !allowed.contains(&key.as_str()) {
663                warn(&format!("unknown key '{key}' in [segments.{id}]; ignoring"));
664            }
665        }
666    }
667}
668
669/// Where linesmith found its config path and how. `explicit = true`
670/// means the user named a path directly (`--config` or
671/// `LINESMITH_CONFIG`); the run-time diagnostics use this to decide
672/// whether a missing file is worth warning about (explicit paths
673/// warn, implicit XDG fallbacks stay silent for first-run users).
674#[derive(Debug, Clone, PartialEq, Eq)]
675pub struct ConfigPath {
676    pub path: PathBuf,
677    pub explicit: bool,
678}
679
680/// Where linesmith looks for its config file, in precedence order.
681/// `OsStr`-typed env args so non-UTF-8 paths (`/srv/café-bin` in a
682/// non-UTF-8 locale) survive the cascade rather than collapse to
683/// `None` upstream.
684#[must_use]
685pub fn resolve_config_path(
686    cli_override: Option<PathBuf>,
687    env_override: Option<&std::ffi::OsStr>,
688    xdg_config_home: Option<&std::ffi::OsStr>,
689    home: Option<&std::ffi::OsStr>,
690) -> Option<ConfigPath> {
691    if let Some(p) = cli_override.filter(|p| !p.as_os_str().is_empty()) {
692        return Some(ConfigPath {
693            path: p,
694            explicit: true,
695        });
696    }
697    if let Some(p) = env_override.filter(|s| !s.is_empty()) {
698        return Some(ConfigPath {
699            path: PathBuf::from(p),
700            explicit: true,
701        });
702    }
703    if let Some(p) = xdg_config_home.filter(|s| !s.is_empty()) {
704        return Some(ConfigPath {
705            path: PathBuf::from(p).join("linesmith").join("config.toml"),
706            explicit: false,
707        });
708    }
709    home.filter(|s| !s.is_empty()).map(|h| ConfigPath {
710        path: PathBuf::from(h).join(".config/linesmith/config.toml"),
711        explicit: false,
712    })
713}
714
715/// Thin wrapper around [`resolve_config_path`] that reads the process
716/// env directly. Used at startup.
717#[must_use]
718pub fn detect_config_path(cli_override: Option<PathBuf>) -> Option<ConfigPath> {
719    let env_override = std::env::var_os("LINESMITH_CONFIG");
720    let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
721    let home = std::env::var_os("HOME");
722    resolve_config_path(
723        cli_override,
724        env_override.as_deref(),
725        xdg_config_home.as_deref(),
726        home.as_deref(),
727    )
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    // --- parse ---
735
736    #[test]
737    fn empty_config_parses() {
738        let c = Config::from_str("").expect("parse ok");
739        assert_eq!(c.line, None);
740        assert!(c.segments.is_empty());
741    }
742
743    #[test]
744    fn line_segments_parse_in_order() {
745        let c = Config::from_str(
746            r#"
747                [line]
748                segments = ["model", "workspace", "cost"]
749            "#,
750        )
751        .expect("parse ok");
752        let line = c.line.expect("line present");
753        assert_eq!(
754            entry_ids(&line.segments),
755            vec!["model", "workspace", "cost"]
756        );
757        assert!(line.numbered.is_empty(), "no numbered tables expected");
758    }
759
760    #[test]
761    fn layout_field_defaults_to_single_line_when_omitted() {
762        let c = Config::from_str("").expect("parse ok");
763        assert_eq!(c.layout, LayoutMode::SingleLine);
764    }
765
766    #[test]
767    fn layout_field_parses_kebab_case_variants() {
768        let c = Config::from_str(r#"layout = "single-line""#).expect("parse ok");
769        assert_eq!(c.layout, LayoutMode::SingleLine);
770        let c = Config::from_str(r#"layout = "multi-line""#).expect("parse ok");
771        assert_eq!(c.layout, LayoutMode::MultiLine);
772    }
773
774    /// Pull the `segments` array out of a `[line.N]` raw value. The
775    /// flatten map carries `toml::Value`, so test helpers do the
776    /// same shape-walk the builder's `extract_line_segments` does
777    /// without depending on the production helper directly.
778    fn numbered_segments(value: &toml::Value) -> Vec<String> {
779        let table = value.as_table().expect("expected table value");
780        let array = table["segments"]
781            .as_array()
782            .expect("expected segments array");
783        array
784            .iter()
785            .map(|v| v.as_str().expect("expected string").to_string())
786            .collect()
787    }
788
789    /// Convenience accessor: project a `Vec<LineEntry>` to the
790    /// segment-id sequence (separators filtered out, kindless
791    /// entries filtered out). Tests that don't care about the
792    /// inline-table form use this to keep assertions readable as
793    /// `vec!["model", "git_branch"]`.
794    fn entry_ids(entries: &[LineEntry]) -> Vec<&str> {
795        entries.iter().filter_map(LineEntry::segment_id).collect()
796    }
797
798    #[test]
799    fn line_numbered_only_parses() {
800        // Multi-line shape without a sibling `segments`: every key
801        // under `[line]` is a numbered child table.
802        let c = Config::from_str(
803            r#"
804                [line.1]
805                segments = ["model"]
806                [line.2]
807                segments = ["workspace", "cost"]
808            "#,
809        )
810        .expect("parse ok");
811        let line = c.line.expect("line present");
812        assert!(
813            line.segments.is_empty(),
814            "no top-level segments key expected"
815        );
816        assert_eq!(line.numbered.len(), 2);
817        assert_eq!(numbered_segments(&line.numbered["1"]), vec!["model"]);
818        assert_eq!(
819            numbered_segments(&line.numbered["2"]),
820            vec!["workspace", "cost"]
821        );
822    }
823
824    #[test]
825    fn line_with_segments_and_numbered_children_coexist() {
826        // The serde flatten + sibling field combination must accept
827        // both shapes simultaneously: `[line].segments` parses to the
828        // typed field, `[line.N]` sub-tables flatten into the
829        // numbered map. Edge case #3 from spec §Edge cases.
830        let c = Config::from_str(
831            r#"
832                [line]
833                segments = ["fallback"]
834                [line.1]
835                segments = ["a", "b"]
836                [line.2]
837                segments = ["c"]
838            "#,
839        )
840        .expect("parse ok");
841        let line = c.line.expect("line present");
842        assert_eq!(entry_ids(&line.segments), vec!["fallback"]);
843        assert_eq!(line.numbered.len(), 2);
844        assert_eq!(numbered_segments(&line.numbered["1"]), vec!["a", "b"]);
845        assert_eq!(numbered_segments(&line.numbered["2"]), vec!["c"]);
846    }
847
848    #[test]
849    fn line_numbered_keys_preserved_verbatim_for_builder_validation() {
850        // The parser doesn't validate that numbered keys are positive
851        // integers — that's the builder's job (with a warning). Pin
852        // that contract so a future "smart" parser doesn't silently
853        // start dropping `[line.foo]` and break the warn-and-skip
854        // edge-case path.
855        let c = Config::from_str(
856            r#"
857                [line.foo]
858                segments = ["bogus"]
859                [line.10]
860                segments = ["valid"]
861            "#,
862        )
863        .expect("parse ok");
864        let line = c.line.expect("line present");
865        assert_eq!(line.numbered.len(), 2);
866        assert!(line.numbered.contains_key("foo"));
867        assert!(line.numbered.contains_key("10"));
868    }
869
870    #[test]
871    fn line_unknown_scalar_key_does_not_fail_parse_forward_compat() {
872        // CX-2-A regression guard: a typo'd or future-version scalar
873        // key under `[line]` (e.g. `[line] segmnts = [...]` or
874        // `[line] separator = "..."`) must NOT fail config load.
875        // The flatten map captures it as a raw `toml::Value`; the
876        // builder's `extract_line_segments` will warn-and-drop at
877        // render time. Without this contract, the spec's "unknown
878        // keys are warnings" forward-compat rule would silently
879        // regress for everything under `[line]`.
880        let c = Config::from_str(
881            r#"
882                [line]
883                segments = ["model"]
884                segmnts = ["typo"]              # scalar / array
885                future_separator = " | "        # scalar string
886                [line.1]
887                segments = ["valid"]
888            "#,
889        )
890        .expect("parse ok despite unknown sibling keys");
891        let line = c.line.expect("line present");
892        assert_eq!(entry_ids(&line.segments), vec!["model"]);
893        // Unknown siblings show up in the flatten map; the [line.1]
894        // table sits next to them.
895        assert!(line.numbered.contains_key("segmnts"));
896        assert!(line.numbered.contains_key("future_separator"));
897        assert!(line.numbered.contains_key("1"));
898    }
899
900    #[test]
901    fn segment_override_priority_parses() {
902        let c = Config::from_str(
903            r#"
904                [segments.model]
905                priority = 16
906            "#,
907        )
908        .expect("parse ok");
909        assert_eq!(c.segments["model"].priority, Some(16));
910        assert_eq!(c.segments["model"].width, None);
911    }
912
913    #[test]
914    fn layout_options_color_and_padding_parse() {
915        let c = Config::from_str(
916            r#"
917                [layout_options]
918                color = "always"
919                claude_padding = 3
920            "#,
921        )
922        .expect("parse ok");
923        let lo = c.layout_options.expect("layout_options present");
924        assert_eq!(lo.color, ColorPolicy::Always);
925        assert_eq!(lo.claude_padding, 3);
926    }
927
928    #[test]
929    fn layout_options_color_accepts_all_three_variants() {
930        for (toml_val, expected) in [
931            ("auto", ColorPolicy::Auto),
932            ("always", ColorPolicy::Always),
933            ("never", ColorPolicy::Never),
934        ] {
935            let src = format!("[layout_options]\ncolor = \"{toml_val}\"\n");
936            let c = Config::from_str(&src).expect("parse ok");
937            assert_eq!(c.layout_options.map(|l| l.color), Some(expected));
938        }
939    }
940
941    // --- unknown-key validation ---
942
943    fn collect_warnings(src: &str) -> Vec<String> {
944        let mut warnings = Vec::new();
945        let _ = Config::from_str_validated(src, |msg| warnings.push(msg.to_string()));
946        warnings
947    }
948
949    #[test]
950    fn plugin_dirs_deserializes_from_toml_as_path_list() {
951        // Lock in the serde contract: `plugin_dirs = [...]` → Vec<PathBuf>
952        // with each entry preserved as written. This is the public
953        // entry point from user config into plugin discovery; a
954        // renamed field or lost `#[serde(default)]` would silently
955        // stop discovery from seeing user-declared dirs.
956        let cfg: Config = Config::from_str(
957            r#"
958                plugin_dirs = ["/etc/linesmith/segments", "./vendor/plugins"]
959                [line]
960                segments = ["model"]
961            "#,
962        )
963        .expect("parse");
964        assert_eq!(
965            cfg.plugin_dirs,
966            vec![
967                PathBuf::from("/etc/linesmith/segments"),
968                PathBuf::from("./vendor/plugins"),
969            ]
970        );
971    }
972
973    #[test]
974    fn plugin_dirs_defaults_to_empty_when_absent() {
975        let cfg: Config = Config::from_str("theme = \"default\"\n").expect("parse");
976        assert!(cfg.plugin_dirs.is_empty());
977    }
978
979    #[test]
980    fn from_str_validated_warns_on_unknown_top_level_key() {
981        let warnings = collect_warnings("thme = \"oops\"\n[line]\nsegments = []\n");
982        assert_eq!(warnings.len(), 1);
983        assert!(warnings[0].contains("thme"));
984        assert!(warnings[0].contains("top-level"));
985    }
986
987    #[test]
988    fn from_str_validated_allows_implemented_and_forward_compat_top_level_keys() {
989        // Spec-listed keys parse cleanly. Forward-compat keys
990        // (`preset`, `plugins`, `$schema`) populate their fields so
991        // a future `#[serde(skip_deserializing)]` regression or a
992        // dropped `rename = "$schema"` shows up here, not silently.
993        // `"$schema"` is quoted because TOML rejects `$` in bare keys.
994        let toml = r#"
995            "$schema" = "https://example.invalid/schema.json"
996            theme = "default"
997            preset = "developer"
998            layout = "single-line"
999            [line]
1000            segments = ["model"]
1001            [layout_options]
1002            color = "auto"
1003            [plugins.example]
1004            foo = "bar"
1005        "#;
1006        let warnings = collect_warnings(toml);
1007        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1008        let cfg = Config::from_str(toml).expect("parses");
1009        assert_eq!(cfg.preset.as_deref(), Some("developer"));
1010        assert_eq!(
1011            cfg.schema_url.as_deref(),
1012            Some("https://example.invalid/schema.json")
1013        );
1014        let plugins = cfg.plugins.expect("plugins table populated");
1015        assert!(plugins.contains_key("example"));
1016    }
1017
1018    #[test]
1019    fn schema_for_config_round_trips_as_valid_json() {
1020        // The drift check catches *changes* in generator output but
1021        // not whether the output is well-formed JSON Schema in the
1022        // first place. A future schemars-API typo could produce
1023        // unserializable output; CI would only catch it after the
1024        // committed schema bit-rotted. This pins basic validity.
1025        let schema = schemars::schema_for!(Config);
1026        let json = serde_json::to_string(&schema).expect("schema serializes as JSON");
1027        let parsed: serde_json::Value =
1028            serde_json::from_str(&json).expect("schema round-trips as JSON");
1029        let obj = parsed.as_object().expect("schema root is an object");
1030        assert_eq!(
1031            obj.get("$schema").and_then(|v| v.as_str()),
1032            Some("https://json-schema.org/draft/2020-12/schema"),
1033            "schema must declare its meta-schema URI"
1034        );
1035        assert_eq!(
1036            obj.get("title").and_then(|v| v.as_str()),
1037            Some("Config"),
1038            "schema must title the root type"
1039        );
1040        // Pin that the round-7→round-8 forward-compat fields
1041        // actually materialized into the schema. A future
1042        // `#[serde(skip)]` slipping onto one of them would still
1043        // round-trip cleanly through the asserts above; this
1044        // catches the materialization gap directly.
1045        let properties = obj
1046            .get("properties")
1047            .and_then(|v| v.as_object())
1048            .expect("schema declares properties");
1049        for key in ["preset", "plugins", "$schema"] {
1050            assert!(
1051                properties.contains_key(key),
1052                "schema must expose {key:?} as a top-level property"
1053            );
1054        }
1055    }
1056
1057    #[test]
1058    fn schema_directive_wrapped_body_round_trips_as_toml() {
1059        // `with_schema_directive` prepends `#:schema URL\n\n` ahead
1060        // of the preset body. A future regression that drops the
1061        // separator (yielding `#:schema URL[body-first-line]`) would
1062        // pass the position-pin tests in driver.rs but corrupt TOML
1063        // parsing on bodies that start with `#` comments. Pin both
1064        // the structural separator and the round-trip here.
1065        let body = "[line]\nsegments = [\"model\"]\n";
1066        let wrapped = with_schema_directive(body);
1067        assert!(
1068            wrapped.starts_with("#:schema https://"),
1069            "directive at byte 0"
1070        );
1071        assert!(
1072            wrapped.contains("\n\n["),
1073            "blank-line separator before first table"
1074        );
1075        let parsed: Config = wrapped.parse().expect("wrapped body parses as Config");
1076        assert_eq!(
1077            entry_ids(&parsed.line.expect("line").segments),
1078            vec!["model"]
1079        );
1080    }
1081
1082    #[test]
1083    fn from_str_validated_warns_on_unknown_layout_options_key() {
1084        let warnings = collect_warnings(
1085            r#"
1086                [layout_options]
1087                separatr = "powerline"
1088            "#,
1089        );
1090        assert_eq!(warnings.len(), 1);
1091        assert!(warnings[0].contains("separatr"));
1092        assert!(warnings[0].contains("[layout_options]"));
1093    }
1094
1095    #[test]
1096    fn from_str_validated_allows_separator_and_other_known_layout_options_keys() {
1097        // The known-keys allow-list lets `separator` through without
1098        // an unknown-key warning; the segment builder parses the
1099        // string and emits its own warnings (unknown values, v0.2+
1100        // stubs).
1101        let warnings = collect_warnings(
1102            r#"
1103                [layout_options]
1104                color = "always"
1105                claude_padding = 2
1106                separator = "powerline"
1107            "#,
1108        );
1109        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1110    }
1111
1112    #[test]
1113    fn from_str_validated_warns_on_unknown_segment_override_key() {
1114        let warnings = collect_warnings(
1115            r#"
1116                [segments.model]
1117                priorty = 16
1118            "#,
1119        );
1120        assert_eq!(warnings.len(), 1);
1121        assert!(warnings[0].contains("priorty"));
1122        assert!(warnings[0].contains("[segments.model]"));
1123    }
1124
1125    #[test]
1126    fn from_str_validated_names_the_segment_id_in_warnings() {
1127        // Each segment block gets its own warnings namespaced by id so
1128        // users with many segments can find which one has the typo.
1129        let warnings = collect_warnings(
1130            r#"
1131                [segments.workspace]
1132                bogus = "x"
1133                [segments.cost]
1134                alsobogus = 1
1135            "#,
1136        );
1137        assert_eq!(warnings.len(), 2);
1138        assert!(warnings
1139            .iter()
1140            .any(|w| w.contains("[segments.workspace]") && w.contains("bogus")));
1141        assert!(warnings
1142            .iter()
1143            .any(|w| w.contains("[segments.cost]") && w.contains("alsobogus")));
1144    }
1145
1146    #[test]
1147    fn from_str_validated_skips_unknown_segment_ids_because_plugins_own_their_schema() {
1148        // A segment id not in the built-in registry is either a future
1149        // built-in or a plugin segment; plugins declare their own
1150        // override keys, so we can't know what's valid. Skip rather
1151        // than emit false positives.
1152        let warnings = collect_warnings(
1153            r#"
1154                [segments.my_plugin]
1155                foo = "bar"
1156                baz = 42
1157
1158                [segments.another_plugin]
1159                show_ahead_behind = true
1160                show_dirty = true
1161            "#,
1162        );
1163        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1164    }
1165
1166    #[test]
1167    fn from_str_validated_rejects_segment_specific_keys_on_wrong_built_in() {
1168        // `show_dirty` is a git_branch concept; putting it on `model`
1169        // is a user mistake the validator should catch.
1170        let warnings = collect_warnings(
1171            r#"
1172                [segments.model]
1173                show_dirty = true
1174            "#,
1175        );
1176        assert_eq!(warnings.len(), 1);
1177        assert!(warnings[0].contains("show_dirty"));
1178        assert!(warnings[0].contains("[segments.model]"));
1179    }
1180
1181    #[test]
1182    fn from_str_validated_allows_spec_documented_segment_override_keys() {
1183        // `style` (style-string syntax) and `visible_if` (rhai plugin
1184        // expressions) are spec'd but not yet implemented; tolerated
1185        // so spec example configs parse cleanly.
1186        let warnings = collect_warnings(
1187            r#"
1188                [segments.workspace]
1189                priority = 16
1190                width = { min = 10, max = 40 }
1191                style = "role:info"
1192                visible_if = "true"
1193            "#,
1194        );
1195        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1196    }
1197
1198    #[test]
1199    fn reset_segment_allows_absolute_format_keys_without_warning() {
1200        let warnings = collect_warnings(
1201            r#"
1202                [segments.rate_limit_5h_reset]
1203                format = "absolute"
1204                timezone = "America/Los_Angeles"
1205                hour_format = "12h"
1206                locale = "en-US"
1207
1208                [segments.rate_limit_7d_reset]
1209                format = "absolute"
1210                timezone = "Europe/London"
1211                hour_format = "24h"
1212            "#,
1213        );
1214        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1215    }
1216
1217    #[test]
1218    fn model_segment_allows_format_key_without_warning() {
1219        let warnings = collect_warnings(
1220            r#"
1221                [segments.model]
1222                format = "compact"
1223            "#,
1224        );
1225        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1226
1227        let warnings_full = collect_warnings(
1228            r#"
1229                [segments.model]
1230                format = "full"
1231            "#,
1232        );
1233        assert!(
1234            warnings_full.is_empty(),
1235            "unexpected warnings: {warnings_full:?}"
1236        );
1237    }
1238
1239    #[test]
1240    fn workspace_segment_warns_when_format_key_set() {
1241        // `format` is a model-only key; the validator's per-id schema
1242        // split should reject it on `workspace` (and the rest of
1243        // `BUILT_IN_COMMON`) so silent typos don't slip through.
1244        let warnings = collect_warnings(
1245            r#"
1246                [segments.workspace]
1247                format = "compact"
1248            "#,
1249        );
1250        assert_eq!(warnings.len(), 1);
1251        assert!(warnings[0].contains("format"));
1252        assert!(warnings[0].contains("[segments.workspace]"));
1253    }
1254
1255    #[test]
1256    fn git_branch_allows_per_marker_hide_below_cells_without_warning() {
1257        // `[segments.git_branch.dirty]` and `.ahead_behind` are
1258        // pass-through sub-tables, so per-marker `hide_below_cells`
1259        // reaches `from_extras` instead of tripping the unknown-key
1260        // validator.
1261        let warnings = collect_warnings(
1262            r#"
1263                [segments.git_branch.dirty]
1264                hide_below_cells = 50
1265
1266                [segments.git_branch.ahead_behind]
1267                hide_below_cells = 80
1268            "#,
1269        );
1270        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1271    }
1272
1273    #[test]
1274    fn rate_limit_percent_segments_allow_format_and_invert_without_warning() {
1275        let warnings = collect_warnings(
1276            r#"
1277                [segments.rate_limit_5h]
1278                format = "progress"
1279                invert = true
1280                icon = "⏱"
1281                label = "5h"
1282                stale_marker = "~"
1283                progress_width = 20
1284
1285                [segments.rate_limit_7d]
1286                format = "percent"
1287                invert = false
1288            "#,
1289        );
1290        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1291    }
1292
1293    #[test]
1294    fn rate_limit_reset_segments_allow_compact_and_use_days_without_warning() {
1295        let warnings = collect_warnings(
1296            r#"
1297                [segments.rate_limit_5h_reset]
1298                format = "duration"
1299                compact = true
1300                use_days = false
1301
1302                [segments.rate_limit_7d_reset]
1303                format = "progress"
1304                use_days = true
1305            "#,
1306        );
1307        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1308    }
1309
1310    #[test]
1311    fn extra_usage_allows_currency_and_percent_format_without_warning() {
1312        let warnings = collect_warnings(
1313            r#"
1314                [segments.extra_usage]
1315                format = "currency"
1316                icon = ""
1317                label = "extra"
1318                stale_marker = "~"
1319            "#,
1320        );
1321        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1322    }
1323
1324    #[test]
1325    fn invert_warns_on_reset_segment_schema() {
1326        // `invert` is percent-family only; allow-list for reset
1327        // segments must reject it.
1328        let warnings = collect_warnings(
1329            r#"
1330                [segments.rate_limit_5h_reset]
1331                invert = true
1332            "#,
1333        );
1334        assert_eq!(warnings.len(), 1);
1335        assert!(
1336            warnings[0].contains("invert") && warnings[0].contains("rate_limit_5h_reset"),
1337            "{:?}",
1338            warnings[0]
1339        );
1340    }
1341
1342    #[test]
1343    fn use_days_warns_on_percent_segment_schema() {
1344        // `use_days` is reset-family only; allow-list for percent
1345        // segments must reject it.
1346        let warnings = collect_warnings(
1347            r#"
1348                [segments.rate_limit_5h]
1349                use_days = true
1350            "#,
1351        );
1352        assert_eq!(warnings.len(), 1);
1353        assert!(
1354            warnings[0].contains("use_days") && warnings[0].contains("rate_limit_5h"),
1355            "{:?}",
1356            warnings[0]
1357        );
1358    }
1359
1360    #[test]
1361    fn from_str_validated_returns_parse_error_for_malformed_toml() {
1362        let mut warnings = Vec::new();
1363        let err =
1364            Config::from_str_validated("[line\nsegments =", |msg| warnings.push(msg.to_string()))
1365                .unwrap_err();
1366        assert!(matches!(err, ConfigError::Parse { .. }));
1367    }
1368
1369    #[test]
1370    fn validated_and_silent_parse_yield_identical_config_on_clean_input() {
1371        // Locks the "validation is purely observational" contract:
1372        // from_str_validated must not mutate parse semantics.
1373        let src = r#"
1374            theme = "default"
1375            [line]
1376            segments = ["model", "workspace"]
1377            [segments.model]
1378            priority = 8
1379        "#;
1380        let silent = Config::from_str(src).expect("silent parse");
1381        let validated = Config::from_str_validated(src, |_| {}).expect("validated parse");
1382        assert_eq!(silent, validated);
1383    }
1384
1385    #[test]
1386    fn load_validated_file_path_surfaces_parse_error_with_path() {
1387        // The in-memory variant returns ConfigError::Parse { path: None };
1388        // the file variant must populate path for user-facing diagnostics.
1389        let dir = tempdir();
1390        let path = dir.path().join("config.toml");
1391        std::fs::write(&path, "[line\nsegments =").unwrap();
1392        let err = Config::load_validated(&path, |_| {}).unwrap_err();
1393        match err {
1394            ConfigError::Parse { path: Some(p), .. } => assert_eq!(p, path),
1395            other => panic!("expected Parse with Some(path), got {other:?}"),
1396        }
1397    }
1398
1399    #[test]
1400    fn load_validated_returns_none_for_missing_file() {
1401        let dir = tempdir();
1402        let path = dir.path().join("missing.toml");
1403        let mut warnings = Vec::new();
1404        let got = Config::load_validated(&path, |m| warnings.push(m.to_string())).expect("ok");
1405        assert!(got.is_none());
1406        assert!(warnings.is_empty());
1407    }
1408
1409    #[test]
1410    fn load_validated_surfaces_unknown_key_warnings() {
1411        let dir = tempdir();
1412        let path = dir.path().join("config.toml");
1413        std::fs::write(&path, "thme = \"bad\"\n").unwrap();
1414        let mut warnings = Vec::new();
1415        let _ = Config::load_validated(&path, |m| warnings.push(m.to_string())).unwrap();
1416        assert_eq!(warnings.len(), 1);
1417        assert!(warnings[0].contains("thme"));
1418    }
1419
1420    #[test]
1421    fn layout_options_defaults_populate_missing_keys() {
1422        // `[layout_options]` with no fields inside still parses; missing
1423        // color defaults to Auto, missing claude_padding defaults to 0.
1424        let c = Config::from_str("[layout_options]\n").expect("parse ok");
1425        let lo = c.layout_options.expect("layout_options present");
1426        assert_eq!(lo.color, ColorPolicy::Auto);
1427        assert_eq!(lo.claude_padding, 0);
1428    }
1429
1430    #[test]
1431    fn layout_options_rejects_unknown_color_variant() {
1432        let err = Config::from_str(
1433            r#"
1434                [layout_options]
1435                color = "bogus"
1436            "#,
1437        )
1438        .unwrap_err();
1439        assert!(matches!(err, ConfigError::Parse { .. }));
1440    }
1441
1442    #[test]
1443    fn layout_options_omitted_entirely_is_ok() {
1444        let c = Config::from_str("[line]\nsegments = [\"model\"]\n").expect("parse ok");
1445        assert!(c.layout_options.is_none());
1446    }
1447
1448    #[test]
1449    fn segment_override_width_parses_both_sides() {
1450        let c = Config::from_str(
1451            r#"
1452                [segments.workspace.width]
1453                min = 10
1454                max = 40
1455            "#,
1456        )
1457        .expect("parse ok");
1458        let w = c.segments["workspace"].width.expect("width present");
1459        assert_eq!(w.min, Some(10));
1460        assert_eq!(w.max, Some(40));
1461    }
1462
1463    #[test]
1464    fn unknown_top_level_key_is_forward_compatible() {
1465        // Config files from a newer linesmith must still parse on an
1466        // older binary; fields this version doesn't implement are
1467        // ignored rather than rejected.
1468        let c = Config::from_str(
1469            r#"
1470                theme = "catppuccin-mocha"
1471                layout = "single-line"
1472                [layout_options]
1473                separator = "powerline"
1474            "#,
1475        )
1476        .expect("parse ok");
1477        assert_eq!(c.line, None);
1478        assert!(c.segments.is_empty());
1479    }
1480
1481    #[test]
1482    fn malformed_toml_reports_parse_error() {
1483        let err = Config::from_str("[line").unwrap_err();
1484        assert!(matches!(err, ConfigError::Parse { .. }));
1485    }
1486
1487    #[test]
1488    fn io_error_carries_path_in_display() {
1489        use std::io::ErrorKind;
1490        let err = ConfigError::Io {
1491            path: PathBuf::from("/etc/linesmith/config.toml"),
1492            source: std::io::Error::new(ErrorKind::PermissionDenied, "denied"),
1493        };
1494        let rendered = err.to_string();
1495        assert!(rendered.contains("/etc/linesmith/config.toml"));
1496        assert!(rendered.contains("denied"));
1497    }
1498
1499    #[test]
1500    fn bom_prefixed_config_parses() {
1501        // Windows editors sometimes save configs with a leading UTF-8
1502        // BOM. The `toml` crate tolerates it, so no explicit strip is
1503        // needed; this test locks that behavior.
1504        let dir = tempdir();
1505        let path = dir.path().join("config.toml");
1506        std::fs::write(&path, "\u{FEFF}[line]\nsegments = [\"model\"]\n").unwrap();
1507        let c = Config::load(&path).expect("ok").expect("present");
1508        assert_eq!(entry_ids(&c.line.expect("line").segments), vec!["model"]);
1509    }
1510
1511    #[test]
1512    fn load_returns_none_for_missing_file() {
1513        let dir = tempdir();
1514        let path = dir.path().join("nonexistent.toml");
1515        assert!(Config::load(&path).unwrap().is_none());
1516    }
1517
1518    // --- path resolution ---
1519
1520    fn resolved(
1521        cli: Option<&str>,
1522        env: Option<&str>,
1523        xdg: Option<&str>,
1524        home: Option<&str>,
1525    ) -> Option<ConfigPath> {
1526        resolve_config_path(
1527            cli.map(PathBuf::from),
1528            env.map(std::ffi::OsStr::new),
1529            xdg.map(std::ffi::OsStr::new),
1530            home.map(std::ffi::OsStr::new),
1531        )
1532    }
1533
1534    #[test]
1535    fn cli_override_wins_over_everything_and_is_explicit() {
1536        let got = resolved(
1537            Some("/explicit.toml"),
1538            Some("/env.toml"),
1539            Some("/xdg"),
1540            Some("/home"),
1541        )
1542        .expect("resolved");
1543        assert_eq!(got.path, PathBuf::from("/explicit.toml"));
1544        assert!(got.explicit);
1545    }
1546
1547    #[test]
1548    fn env_wins_over_xdg_and_home_and_is_explicit() {
1549        let got = resolved(None, Some("/env.toml"), Some("/xdg"), Some("/home")).expect("resolved");
1550        assert_eq!(got.path, PathBuf::from("/env.toml"));
1551        assert!(got.explicit);
1552    }
1553
1554    #[test]
1555    fn xdg_config_home_is_implicit() {
1556        let got = resolved(None, None, Some("/xdg"), Some("/home")).expect("resolved");
1557        assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
1558        assert!(!got.explicit);
1559    }
1560
1561    #[test]
1562    fn home_fallback_is_implicit() {
1563        let got = resolved(None, None, None, Some("/home")).expect("resolved");
1564        assert_eq!(
1565            got.path,
1566            PathBuf::from("/home/.config/linesmith/config.toml")
1567        );
1568        assert!(!got.explicit);
1569    }
1570
1571    #[test]
1572    fn returns_none_when_no_home_and_no_xdg() {
1573        assert_eq!(resolved(None, None, None, None), None);
1574    }
1575
1576    #[test]
1577    fn empty_env_values_are_ignored() {
1578        let got = resolved(None, Some(""), Some(""), Some("/home")).expect("resolved");
1579        assert_eq!(
1580            got.path,
1581            PathBuf::from("/home/.config/linesmith/config.toml")
1582        );
1583    }
1584
1585    #[test]
1586    fn empty_cli_override_does_not_count_as_explicit() {
1587        // A shell expansion like `--config "$MISSING_VAR"` can produce
1588        // an empty path; skip past it rather than silently treating it
1589        // as "load ''" which would NotFound-swallow.
1590        let got = resolved(Some(""), None, Some("/xdg"), None).expect("resolved");
1591        assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
1592        assert!(!got.explicit);
1593    }
1594
1595    // --- helpers ---
1596
1597    struct TempDir(PathBuf);
1598
1599    impl TempDir {
1600        fn path(&self) -> &Path {
1601            &self.0
1602        }
1603    }
1604
1605    impl Drop for TempDir {
1606        fn drop(&mut self) {
1607            let _ = std::fs::remove_dir_all(&self.0);
1608        }
1609    }
1610
1611    fn tempdir() -> TempDir {
1612        use std::sync::atomic::{AtomicU64, Ordering};
1613        static COUNTER: AtomicU64 = AtomicU64::new(0);
1614        let base = std::env::temp_dir().join(format!(
1615            "linesmith-config-test-{}-{}",
1616            std::time::SystemTime::now()
1617                .duration_since(std::time::UNIX_EPOCH)
1618                .expect("clock")
1619                .as_nanos(),
1620            COUNTER.fetch_add(1, Ordering::Relaxed),
1621        ));
1622        std::fs::create_dir_all(&base).expect("mkdir");
1623        TempDir(base)
1624    }
1625}