Skip to main content

linesmith_core/segments/
builder.rs

1//! `Config` → `Vec<Box<dyn Segment>>` with validation. Hides built-in
2//! registry lookup, duplicate handling, unknown-ID warnings,
3//! per-segment override merging, and plugin-registry consultation.
4
5use std::collections::{BTreeMap, HashMap};
6use std::sync::Arc;
7
8use rhai::{Array, Dynamic, Engine, Map};
9
10use super::{
11    built_in_by_id, OverriddenSegment, PowerlineWidth, Segment, Separator, WidthBounds,
12    DEFAULT_SEGMENT_IDS,
13};
14use crate::config;
15use crate::plugins::{CompiledPlugin, PluginRegistry, RhaiSegment};
16use crate::theme;
17
18/// Build the default segment list: every built-in in canonical order,
19/// no overrides applied.
20#[must_use]
21pub fn build_default_segments() -> Vec<Box<dyn Segment>> {
22    DEFAULT_SEGMENT_IDS
23        .iter()
24        .filter_map(|id| built_in_by_id(id, None, &mut |_| {}))
25        .collect()
26}
27
28/// Build a segment list from an optional [`Config`](config::Config).
29/// `None` or a config without a `[line]` section uses the default
30/// order. `warn` receives a one-line diagnostic for each validation
31/// rule triggered (pass `|_| {}` to discard).
32///
33/// `plugins` carries the discovered [`PluginRegistry`] plus its
34/// shared engine. Built-in ids win on collision (the registry already
35/// rejects plugins shadowing built-ins at load time, so a plugin
36/// reaching this function can only collide with another plugin or
37/// stand alone).
38///
39/// Implements the validation rules in `docs/specs/config.md`
40/// §Validation rules: unknown ids skip with a warning, duplicates
41/// keep the first, an explicit `segments = []` warns, inverted width
42/// bounds drop the override with a warning.
43///
44/// Single-line entry; multi-line callers should use [`build_lines`].
45/// When this function is invoked against a `layout = "multi-line"`
46/// config, it returns line 1 (sorted by numbered key) and warns,
47/// rather than reading the empty `[line].segments` field and
48/// returning nothing. Without that fallback, embedders loading the
49/// multi-line `power-user` preset would silently render a blank
50/// status line.
51pub fn build_segments(
52    config: Option<&config::Config>,
53    plugins: Option<(PluginRegistry, Arc<Engine>)>,
54    mut warn: impl FnMut(&str),
55) -> Vec<Box<dyn Segment>> {
56    let configured_line = config.and_then(|c| c.line.as_ref());
57    let layout_mode = config.map(|c| c.layout).unwrap_or_default();
58
59    // If the caller is on the legacy single-line API but the config
60    // declares multi-line, hand them line 1 with a warning. Without
61    // the fallback, `[line].segments` would be empty and the
62    // embedder would silently render a blank status line.
63    if matches!(layout_mode, config::LayoutMode::MultiLine) {
64        if let Some(first) = validated_numbered_lines(configured_line, &mut warn)
65            .and_then(|mut v| (!v.is_empty()).then(|| v.remove(0)))
66        {
67            warn("layout = \"multi-line\" passed to build_segments (the single-line API); rendering line 1 only. Call build_lines to render every [line.N] sub-table.");
68            let layout_separator = resolve_layout_separator(config, &mut warn);
69            let ids: Vec<&str> = first.iter().map(String::as_str).collect();
70            let mut plugin_bundle = bundle_plugins(plugins);
71            let mut consumed = std::collections::HashSet::new();
72            return build_one_line(
73                &ids,
74                config,
75                &mut plugin_bundle,
76                &mut consumed,
77                &layout_separator,
78                &mut warn,
79            );
80        }
81        // Multi-line declared but no usable [line.N]; fall through to
82        // the single-line path which already warns on empty segments.
83    }
84
85    if let Some(line) = configured_line {
86        if line.segments.is_empty() {
87            warn("[line].segments is empty; no segments will render");
88        }
89    }
90
91    let layout_separator = resolve_layout_separator(config, &mut warn);
92
93    let ids: Vec<&str> = match configured_line {
94        Some(l) => l.segments.iter().map(String::as_str).collect(),
95        None => DEFAULT_SEGMENT_IDS.to_vec(),
96    };
97
98    let mut plugin_bundle = bundle_plugins(plugins);
99    let mut consumed = std::collections::HashSet::new();
100    build_one_line(
101        &ids,
102        config,
103        &mut plugin_bundle,
104        &mut consumed,
105        &layout_separator,
106        &mut warn,
107    )
108}
109
110/// Build a list of segment lists, one per rendered line. Single-line
111/// configs return a vec of length 1; multi-line configs return one
112/// inner vec per `[line.N]` sub-table sorted by the parsed integer
113/// key. Edge cases per `docs/specs/config.md` §Edge cases:
114///
115/// - `layout = "multi-line"` without usable `[line.N]` sub-tables
116///   warns and falls back to single-line using `[line].segments`.
117/// - `layout = "single-line"` (or unset) with `[line.N]` tables
118///   present warns and ignores the numbered tables.
119/// - Numbered keys that don't parse as positive integers (e.g.
120///   `[line.foo]`) warn and drop.
121/// - In multi-line mode with both `[line].segments` and `[line.N]`
122///   present, the numbered tables win and `[line].segments` is
123///   ignored. The spec's edge-case table doesn't enumerate this
124///   combination; this is a builder-level precedence choice
125///   matching the principle that single-line callers should go
126///   through [`build_segments`].
127///
128/// Plugin segments can appear in at most one line per render: the
129/// shared plugin lookup is consumed on first use. A plugin id
130/// referenced again in a later line surfaces as "plugin '<id>' was
131/// rendered on an earlier line" so the user knows the cause is
132/// reuse, not a typo. v0.1 limitation; lifting it (Arc-shared or
133/// cloneable `CompiledPlugin`) is tracked separately.
134pub fn build_lines(
135    config: Option<&config::Config>,
136    plugins: Option<(PluginRegistry, Arc<Engine>)>,
137    mut warn: impl FnMut(&str),
138) -> Vec<Vec<Box<dyn Segment>>> {
139    let mode = config.map(|c| c.layout).unwrap_or_default();
140    let line_cfg = config.and_then(|c| c.line.as_ref());
141
142    let line_id_lists: Vec<Vec<String>> = match mode {
143        config::LayoutMode::SingleLine => {
144            // Two single-line + numbered combinations: if `segments`
145            // is populated, the user picked single-line on purpose
146            // and the numbered tables are noise — warn and ignore.
147            // If `segments` is empty AND numbered is populated, the
148            // user almost certainly meant multi-line and forgot the
149            // `layout = "multi-line"` line; auto-promote with a hint
150            // rather than silently rendering nothing.
151            let has_numbered = line_cfg.is_some_and(|l| !l.numbered.is_empty());
152            let has_segments = line_cfg.is_some_and(|l| !l.segments.is_empty());
153            if has_numbered && !has_segments {
154                if let Some(promoted) = validated_numbered_lines(line_cfg, &mut warn) {
155                    warn("[line.N] sub-tables present but no top-level `layout` field; treating as multi-line. Add `layout = \"multi-line\"` to silence this warning.");
156                    promoted
157                } else {
158                    warn("[line.N] sub-tables present but none are usable, and [line].segments is empty; nothing will render");
159                    single_line_ids(line_cfg, &mut warn)
160                }
161            } else {
162                if has_numbered {
163                    warn("layout is single-line but [line.N] sub-tables are present; ignoring numbered tables and rendering [line].segments");
164                }
165                single_line_ids(line_cfg, &mut warn)
166            }
167        }
168        config::LayoutMode::MultiLine => match validated_numbered_lines(line_cfg, &mut warn) {
169            Some(lines) => lines,
170            None => {
171                warn("layout = \"multi-line\" but no usable [line.N] sub-tables; falling back to single-line using [line].segments");
172                single_line_ids(line_cfg, &mut warn)
173            }
174        },
175    };
176
177    let layout_separator = resolve_layout_separator(config, &mut warn);
178    let mut plugin_bundle = bundle_plugins(plugins);
179    let mut consumed_plugins = std::collections::HashSet::<String>::new();
180
181    line_id_lists
182        .into_iter()
183        .map(|owned_ids| {
184            let ids: Vec<&str> = owned_ids.iter().map(String::as_str).collect();
185            build_one_line(
186                &ids,
187                config,
188                &mut plugin_bundle,
189                &mut consumed_plugins,
190                &layout_separator,
191                &mut warn,
192            )
193        })
194        .collect()
195}
196
197/// Resolve the single-line id list and warn on an explicitly empty
198/// `[line].segments`. Returns one vec wrapped in an outer vec for
199/// uniform treatment by [`build_lines`].
200fn single_line_ids(
201    line_cfg: Option<&config::LineConfig>,
202    warn: &mut impl FnMut(&str),
203) -> Vec<Vec<String>> {
204    if let Some(line) = line_cfg {
205        if line.segments.is_empty() {
206            warn("[line].segments is empty; no segments will render");
207        }
208    }
209    let ids: Vec<String> = match line_cfg {
210        Some(l) => l.segments.clone(),
211        None => DEFAULT_SEGMENT_IDS.iter().map(|&s| s.to_string()).collect(),
212    };
213    vec![ids]
214}
215
216/// Validate `[line.N]` sub-tables for multi-line mode. The flatten
217/// map carries raw [`toml::Value`]s so the parser can preserve the
218/// "unknown keys are warnings" forward-compat contract; this
219/// function does the per-key validation: numeric key, positive,
220/// pointing at a table with a `segments` array of strings.
221/// Anything else gets a warning and is dropped. Returns `None` if
222/// no usable lines remain — caller falls back to single-line. Sorts
223/// by parsed integer so `[line.10]` follows `[line.2]` rather than
224/// coming before it lexicographically.
225fn validated_numbered_lines(
226    line_cfg: Option<&config::LineConfig>,
227    warn: &mut impl FnMut(&str),
228) -> Option<Vec<Vec<String>>> {
229    let line = line_cfg?;
230    if line.numbered.is_empty() {
231        return None;
232    }
233    let mut valid: Vec<(u32, Vec<String>)> = line
234        .numbered
235        .iter()
236        .filter_map(|(key, value)| {
237            // Distinguish "non-table value under [line]" (a typo'd or
238            // forward-compat scalar like `[line] segmnts = [...]`)
239            // from "table-shaped sub-table with a non-integer key"
240            // (`[line.foo]`). Both warn and drop, but the wording
241            // points the user at the right fix.
242            if !matches!(value, toml::Value::Table(_)) {
243                warn(&format!(
244                    "[line] has unknown key '{key}' ({}); expected `[line.N]` sub-tables only. Skipping.",
245                    describe_toml_value(value)
246                ));
247                return None;
248            }
249            let n = match key.parse::<u32>() {
250                Ok(n) if n > 0 => n,
251                _ => {
252                    warn(&format!(
253                        "[line.{key}] is not a positive integer key; skipping"
254                    ));
255                    return None;
256                }
257            };
258            extract_line_segments(key, value, warn).map(|segs| (n, segs))
259        })
260        .collect();
261    if valid.is_empty() {
262        return None;
263    }
264    valid.sort_by_key(|(n, _)| *n);
265    for (n, segs) in &valid {
266        if segs.is_empty() {
267            warn(&format!(
268                "[line.{n}].segments is empty; that line will render nothing"
269            ));
270        }
271    }
272    Some(valid.into_iter().map(|(_, segs)| segs).collect())
273}
274
275/// Pull `segments: Vec<String>` out of one `[line.N]` value. Returns
276/// `None` on every shape mismatch (non-table, missing/wrong-typed
277/// `segments`, non-string entries) with a targeted diagnostic. The
278/// only production caller pre-filters non-table values, but the
279/// non-table branch is kept as defense in depth so a future caller
280/// can't reach a panic by passing the wrong shape.
281fn extract_line_segments(
282    key: &str,
283    value: &toml::Value,
284    warn: &mut impl FnMut(&str),
285) -> Option<Vec<String>> {
286    let table = match value {
287        toml::Value::Table(t) => t,
288        other => {
289            warn(&format!(
290                "[line] key '{key}' is a {} (expected a sub-table with `segments = [...]`); skipping",
291                describe_toml_value(other)
292            ));
293            return None;
294        }
295    };
296    let segments_value = match table.get("segments") {
297        Some(v) => v,
298        None => {
299            warn(&format!("[line.{key}] has no `segments` array; skipping"));
300            return None;
301        }
302    };
303    let array = match segments_value {
304        toml::Value::Array(a) => a,
305        other => {
306            warn(&format!(
307                "[line.{key}].segments is a {} (expected an array of strings); skipping",
308                describe_toml_value(other)
309            ));
310            return None;
311        }
312    };
313    let mut segs = Vec::with_capacity(array.len());
314    for (i, item) in array.iter().enumerate() {
315        match item {
316            toml::Value::String(s) => segs.push(s.clone()),
317            other => {
318                warn(&format!(
319                    "[line.{key}].segments[{i}] is a {} (expected a string); skipping that item",
320                    describe_toml_value(other)
321                ));
322            }
323        }
324    }
325    Some(segs)
326}
327
328fn describe_toml_value(v: &toml::Value) -> &'static str {
329    match v {
330        toml::Value::String(_) => "string",
331        toml::Value::Integer(_) => "integer",
332        toml::Value::Float(_) => "float",
333        toml::Value::Boolean(_) => "boolean",
334        toml::Value::Datetime(_) => "datetime",
335        toml::Value::Array(_) => "array",
336        toml::Value::Table(_) => "table",
337    }
338}
339
340/// Resolve the `[layout_options].separator` once for the whole
341/// config. Shared by both single- and multi-line builds so per-line
342/// caches don't repeat the same warning.
343fn resolve_layout_separator(
344    config: Option<&config::Config>,
345    warn: &mut impl FnMut(&str),
346) -> Separator {
347    let powerline_width = config
348        .and_then(|c| c.layout_options.as_ref())
349        .and_then(|lo| lo.powerline_width)
350        .map(|w| validate_powerline_width(w, warn))
351        .unwrap_or_default();
352    config
353        .and_then(|c| c.layout_options.as_ref())
354        .and_then(|lo| lo.separator.as_deref())
355        .map(|s| parse_layout_separator(s, powerline_width, warn))
356        .unwrap_or(Separator::Space)
357}
358
359/// Bundle the lookup table with its engine so the borrow checker
360/// (rather than a runtime invariant + `expect`) enforces that
361/// plugin renders never reach for a missing engine. Called once per
362/// `build_segments` / `build_lines` invocation; the resulting bundle
363/// threads through `build_one_line` and is consumed across whichever
364/// lines reference plugin ids.
365fn bundle_plugins(
366    plugins: Option<(PluginRegistry, Arc<Engine>)>,
367) -> Option<(HashMap<String, CompiledPlugin>, Arc<Engine>)> {
368    plugins.map(|(registry, engine)| {
369        let lookup: HashMap<String, CompiledPlugin> = registry
370            .into_plugins()
371            .into_iter()
372            .map(|p| (p.id().to_string(), p))
373            .collect();
374        (lookup, engine)
375    })
376}
377
378/// Inner segment-building loop, shared by single-line `build_segments`
379/// and per-line `build_lines`. Dedupes within a single call (so
380/// duplicates within one line warn) but not across calls — multi-line
381/// configs that list the same built-in id in two different lines
382/// produce two independent segment instances, which is the right
383/// behavior for stateless built-ins.
384///
385/// `consumed_plugins` tracks plugin ids removed from the shared
386/// lookup by earlier `build_one_line` calls. The lookup itself can't
387/// distinguish "never existed" from "consumed" after a `remove`, so
388/// we shadow consumption here. A lookup miss combined with a
389/// consumed-set hit produces the specific "rendered on an earlier
390/// line" warning; otherwise the generic "unknown segment id"
391/// diagnostic fires.
392fn build_one_line(
393    ids: &[&str],
394    config: Option<&config::Config>,
395    plugin_bundle: &mut Option<(HashMap<String, CompiledPlugin>, Arc<Engine>)>,
396    consumed_plugins: &mut std::collections::HashSet<String>,
397    layout_separator: &Separator,
398    warn: &mut impl FnMut(&str),
399) -> Vec<Box<dyn Segment>> {
400    let mut seen = std::collections::HashSet::<String>::new();
401    ids.iter()
402        .filter_map(|&id| {
403            if !seen.insert(id.to_string()) {
404                warn(&format!(
405                    "segment '{id}' listed more than once; keeping first occurrence"
406                ));
407                return None;
408            }
409            let cfg_override = config.and_then(|c| c.segments.get(id));
410            let extras = cfg_override.map(|ov| &ov.extra);
411            let inner = if let Some(b) = built_in_by_id(id, extras, warn) {
412                Some(b)
413            } else if let Some((lookup, engine)) = plugin_bundle.as_mut() {
414                lookup.remove(id).map(|plugin| {
415                    consumed_plugins.insert(id.to_string());
416                    // Always pass a Map (possibly empty) rather than
417                    // `()` so plugins can probe `ctx.config.foo`
418                    // without first checking the parent for unit.
419                    let plugin_config = cfg_override.map_or_else(
420                        || Dynamic::from_map(Map::new()),
421                        |ov| toml_table_to_dynamic(&ov.extra),
422                    );
423                    Box::new(RhaiSegment::from_compiled(
424                        plugin,
425                        engine.clone(),
426                        plugin_config,
427                    )) as Box<dyn Segment>
428                })
429            } else {
430                None
431            };
432            let inner = inner.or_else(|| {
433                if consumed_plugins.contains(id) {
434                    warn(&format!(
435                        "plugin '{id}' was rendered on an earlier line; v0.1 supports each plugin on at most one line per render — skipping"
436                    ));
437                } else {
438                    warn(&format!("unknown segment id '{id}' — skipping"));
439                }
440                None
441            })?;
442            let with_per_segment = apply_override(id, inner, cfg_override, warn);
443            Some(apply_layout_separator(with_per_segment, layout_separator))
444        })
445        .collect()
446}
447
448/// Parse a `[layout_options].separator` string into a [`Separator`].
449///
450/// - `"space"` → [`Separator::Space`]
451/// - `"powerline"` → [`Separator::Powerline`] (`powerline_width`
452///   controls the chevron cell count, 1 or 2)
453/// - `"capsule"` / `"flex"` — reserved for v0.2+; warn + fall back to
454///   `Space` so today's configs migrate cleanly when the v0.2
455///   renderers land
456/// - `""` (truly empty) → [`Separator::None`]
457/// - anything else → [`Separator::Literal`] verbatim, e.g.
458///   `separator = " | "` for ccstatusline-parity
459///
460/// Reserved-keyword matching is case- and whitespace-insensitive
461/// against the trimmed input. Typos do not warn — `"powereline"`
462/// renders as the literal word, since "anything not a reserved
463/// keyword is a literal" is the contract.
464fn parse_layout_separator(
465    value: &str,
466    powerline_width: PowerlineWidth,
467    warn: &mut impl FnMut(&str),
468) -> Separator {
469    // Empty (truly zero-length) is "no separator." Whitespace-only is
470    // a deliberate literal — `separator = "   "` means "three spaces
471    // between segments," not "no separator." Distinguishing these
472    // here prevents the `value.trim()` keyword pre-pass from eating
473    // user-meaningful whitespace literals.
474    if value.is_empty() {
475        return Separator::None;
476    }
477    let normalized = value.trim().to_ascii_lowercase();
478    match normalized.as_str() {
479        "space" => Separator::Space,
480        "powerline" => Separator::Powerline {
481            width: powerline_width,
482        },
483        "capsule" | "flex" => {
484            warn(&format!(
485                "[layout_options].separator '{value}' is reserved for v0.2+; rendering as 'space'"
486            ));
487            Separator::Space
488        }
489        _ => Separator::Literal(std::borrow::Cow::Owned(value.to_string())),
490    }
491}
492
493/// Validate `[layout_options].powerline_width`. Only `1` and `2` are
494/// meaningful — most Nerd Fonts render U+E0B0 as 1 cell, some
495/// fonts/sizes render it as 2. Any other value warns and falls back
496/// to `1` so a typo doesn't silently desync the layout math.
497fn validate_powerline_width(width: u16, warn: &mut impl FnMut(&str)) -> PowerlineWidth {
498    match width {
499        1 => PowerlineWidth::One,
500        2 => PowerlineWidth::Two,
501        other => {
502            warn(&format!(
503                "[layout_options].powerline_width = {other} is not 1 or 2; using 1"
504            ));
505            PowerlineWidth::One
506        }
507    }
508}
509
510/// Wrap the segment in [`OverriddenSegment`] so the configured
511/// separator replaces its `Space`/`Theme` default. No-op when `sep`
512/// is `Space` (the implicit default — nothing to override) or when
513/// the segment's default is anything else (`Literal`, `None`,
514/// `Powerline`) — that's an explicit segment-side choice we leave
515/// alone.
516fn apply_layout_separator(segment: Box<dyn Segment>, sep: &Separator) -> Box<dyn Segment> {
517    if matches!(sep, Separator::Space) {
518        return segment;
519    }
520    match segment.defaults().default_separator {
521        Separator::Space | Separator::Theme => {
522            Box::new(OverriddenSegment::new(segment).with_default_separator(sep.clone()))
523        }
524        _ => segment,
525    }
526}
527
528/// Convert the `extra` bag of a `[segments.<plugin-id>]` block into
529/// the `rhai::Map` a plugin sees as `ctx.config`.
530fn toml_table_to_dynamic(table: &BTreeMap<String, toml::Value>) -> Dynamic {
531    let mut map = Map::new();
532    for (k, v) in table {
533        map.insert(k.as_str().into(), toml_value_to_dynamic(v));
534    }
535    Dynamic::from_map(map)
536}
537
538fn toml_value_to_dynamic(value: &toml::Value) -> Dynamic {
539    match value {
540        toml::Value::String(s) => Dynamic::from(s.clone()),
541        toml::Value::Integer(i) => Dynamic::from(*i),
542        toml::Value::Float(f) => Dynamic::from(*f),
543        toml::Value::Boolean(b) => Dynamic::from(*b),
544        // toml::Datetime has no native rhai equivalent; surface as the
545        // RFC 3339 string the spec already uses for time fields.
546        toml::Value::Datetime(dt) => Dynamic::from(dt.to_string()),
547        toml::Value::Array(items) => {
548            let arr: Array = items.iter().map(toml_value_to_dynamic).collect();
549            Dynamic::from_array(arr)
550        }
551        toml::Value::Table(t) => {
552            let mut m = Map::new();
553            for (k, v) in t {
554                m.insert(k.as_str().into(), toml_value_to_dynamic(v));
555            }
556            Dynamic::from_map(m)
557        }
558    }
559}
560
561fn apply_override(
562    id: &str,
563    inner: Box<dyn Segment>,
564    ov: Option<&config::SegmentOverride>,
565    warn: &mut impl FnMut(&str),
566) -> Box<dyn Segment> {
567    let Some(ov) = ov else { return inner };
568    let base_width = inner.defaults().width;
569    let mut wrapped = OverriddenSegment::new(inner);
570    if let Some(p) = ov.priority {
571        wrapped = wrapped.with_priority(p);
572    }
573    if let Some(w) = ov.width {
574        // Half-specified widths inherit the missing side from the
575        // segment's built-in default; 0 / u16::MAX are the open-ended
576        // fallback only when the segment itself has no default.
577        let min = w.min.or_else(|| base_width.map(|b| b.min())).unwrap_or(0);
578        let max = w
579            .max
580            .or_else(|| base_width.map(|b| b.max()))
581            .unwrap_or(u16::MAX);
582        match WidthBounds::new(min, max) {
583            Some(bounds) => wrapped = wrapped.with_width(bounds),
584            None => warn(&format!(
585                "segments.{id}.width: min ({min}) > max ({max}); ignoring override"
586            )),
587        }
588    }
589    // `style = ""` is a no-op — an empty string almost never means
590    // "strip my declared role"; require an explicit token to override.
591    if let Some(style_str) = ov.style.as_deref().filter(|s| !s.trim().is_empty()) {
592        match theme::parse_style(style_str) {
593            Ok(style) => wrapped = wrapped.with_user_style(style),
594            Err(e) => warn(&format!("segments.{id}.style: {e}; ignoring override")),
595        }
596    }
597    Box::new(wrapped)
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use crate::input;
604    use crate::segments::{self, BUILT_IN_SEGMENT_IDS};
605    use std::str::FromStr;
606
607    fn built(cfg: Option<&config::Config>) -> Vec<Box<dyn Segment>> {
608        build_segments(cfg, None, |_| {})
609    }
610
611    fn built_with_warns(cfg: Option<&config::Config>) -> (Vec<Box<dyn Segment>>, Vec<String>) {
612        let mut warns = Vec::new();
613        let segs = build_segments(cfg, None, |m| warns.push(m.to_string()));
614        (segs, warns)
615    }
616
617    #[test]
618    fn build_segments_uses_default_order_when_config_missing() {
619        assert_eq!(built(None).len(), DEFAULT_SEGMENT_IDS.len());
620    }
621
622    #[test]
623    fn layout_separator_powerline_swaps_default_separator() {
624        // With `separator = "powerline"` configured, every segment
625        // whose built-in default is `Space` or `Theme` reports
626        // `Powerline` in `defaults().default_separator`. Pins the
627        // wholesale-swap behavior the layout engine relies on to
628        // emit chevrons between segments.
629        let cfg = config::Config::from_str(
630            r#"
631                [line]
632                segments = ["model", "workspace"]
633                [layout_options]
634                separator = "powerline"
635            "#,
636        )
637        .expect("parse");
638        let segs = built(Some(&cfg));
639        for seg in &segs {
640            assert_eq!(
641                seg.defaults().default_separator,
642                Separator::powerline(),
643                "segment didn't pick up powerline separator"
644            );
645        }
646    }
647
648    #[test]
649    fn layout_separator_space_is_passthrough() {
650        // Default `separator = "space"` (or absent) leaves segments
651        // unwrapped — no extra OverriddenSegment layer for the
652        // common case.
653        let cfg = config::Config::from_str(
654            r#"
655                [line]
656                segments = ["model"]
657                [layout_options]
658                separator = "space"
659            "#,
660        )
661        .expect("parse");
662        let segs = built(Some(&cfg));
663        assert_eq!(segs[0].defaults().default_separator, Separator::Space);
664    }
665
666    #[test]
667    fn layout_separator_capsule_warns_and_falls_back_to_space() {
668        // Capsule + flex are spec'd for v0.2+; a config file written
669        // today must not error on them. Warn loudly and treat as
670        // space until the v0.2 renderers land.
671        let cfg = config::Config::from_str(
672            r#"
673                [line]
674                segments = ["model"]
675                [layout_options]
676                separator = "capsule"
677            "#,
678        )
679        .expect("parse");
680        let (segs, warns) = built_with_warns(Some(&cfg));
681        assert_eq!(segs[0].defaults().default_separator, Separator::Space);
682        assert!(
683            warns
684                .iter()
685                .any(|m| m.contains("capsule") && m.contains("v0.2+")),
686            "missing capsule deferral warning: {warns:?}"
687        );
688    }
689
690    #[test]
691    fn layout_separator_arbitrary_string_renders_as_literal() {
692        // ccstatusline parity: configs like `separator = " | "` or
693        // `separator = " · "` set the literal text emitted between
694        // segments. Anything other than the reserved keywords falls
695        // through to Separator::Literal preserving user input verbatim
696        // (whitespace included).
697        let cfg = config::Config::from_str(
698            r#"
699                [line]
700                segments = ["model"]
701                [layout_options]
702                separator = " | "
703            "#,
704        )
705        .expect("parse");
706        let (segs, warns) = built_with_warns(Some(&cfg));
707        assert_eq!(
708            segs[0].defaults().default_separator,
709            Separator::Literal(std::borrow::Cow::Owned(" | ".to_string()))
710        );
711        assert!(warns.is_empty(), "no warnings on literal: {warns:?}");
712    }
713
714    #[test]
715    fn layout_separator_empty_string_yields_none() {
716        // Explicit `separator = ""` is the user saying "no separator";
717        // emit nothing between segments. Distinct from absence of the
718        // key (which falls through to the default Space).
719        let cfg = config::Config::from_str(
720            r#"
721                [line]
722                segments = ["model"]
723                [layout_options]
724                separator = ""
725            "#,
726        )
727        .expect("parse");
728        let (segs, warns) = built_with_warns(Some(&cfg));
729        assert_eq!(segs[0].defaults().default_separator, Separator::None);
730        assert!(
731            warns.is_empty(),
732            "empty string is a valid choice: {warns:?}"
733        );
734    }
735
736    #[test]
737    fn build_segments_empty_config_falls_back_to_defaults() {
738        let cfg = config::Config::default();
739        assert_eq!(built(Some(&cfg)).len(), DEFAULT_SEGMENT_IDS.len());
740    }
741
742    #[test]
743    fn layout_separator_preserves_segment_literal_default() {
744        // Forward-compat pin: a segment whose built-in default is
745        // `Literal(...)` keeps its declared boundary even under
746        // `[layout_options].separator = "powerline"`. No segment uses
747        // Literal today; this protects the contract for ones that will.
748        struct PipeSeg;
749        impl segments::Segment for PipeSeg {
750            fn render(
751                &self,
752                _: &crate::data_context::DataContext,
753                _: &segments::RenderContext,
754            ) -> segments::RenderResult {
755                Ok(Some(segments::RenderedSegment::new("x")))
756            }
757            fn defaults(&self) -> segments::SegmentDefaults {
758                segments::SegmentDefaults::with_priority(0)
759                    .with_default_separator(Separator::Literal(std::borrow::Cow::Borrowed(" | ")))
760            }
761        }
762        let wrapped = apply_layout_separator(Box::new(PipeSeg), &Separator::powerline());
763        assert_eq!(
764            wrapped.defaults().default_separator,
765            Separator::Literal(std::borrow::Cow::Borrowed(" | ")),
766        );
767    }
768
769    #[test]
770    fn layout_separator_preserves_segment_none_default() {
771        // Same forward-compat pin for `Separator::None` — a segment
772        // that explicitly suppresses its right-edge separator must
773        // keep that suppression even when the user configures
774        // powerline.
775        struct NoSepSeg;
776        impl segments::Segment for NoSepSeg {
777            fn render(
778                &self,
779                _: &crate::data_context::DataContext,
780                _: &segments::RenderContext,
781            ) -> segments::RenderResult {
782                Ok(Some(segments::RenderedSegment::new("x")))
783            }
784            fn defaults(&self) -> segments::SegmentDefaults {
785                segments::SegmentDefaults::with_priority(0).with_default_separator(Separator::None)
786            }
787        }
788        let wrapped = apply_layout_separator(Box::new(NoSepSeg), &Separator::powerline());
789        assert_eq!(wrapped.defaults().default_separator, Separator::None);
790    }
791
792    #[test]
793    fn layout_separator_does_not_double_wrap_when_default_already_powerline() {
794        // A segment whose built-in default is already `Powerline`
795        // falls through the `_` arm of the match — no wrap layer
796        // added. Pins the contract for any future segment that
797        // declares `Powerline` directly.
798        struct PowerlineSeg;
799        impl segments::Segment for PowerlineSeg {
800            fn render(
801                &self,
802                _: &crate::data_context::DataContext,
803                _: &segments::RenderContext,
804            ) -> segments::RenderResult {
805                Ok(Some(segments::RenderedSegment::new("x")))
806            }
807            fn defaults(&self) -> segments::SegmentDefaults {
808                segments::SegmentDefaults::with_priority(0)
809                    .with_default_separator(Separator::powerline())
810            }
811        }
812        let wrapped = apply_layout_separator(Box::new(PowerlineSeg), &Separator::powerline());
813        assert_eq!(wrapped.defaults().default_separator, Separator::powerline());
814    }
815
816    #[test]
817    fn layout_separator_handles_mixed_case_and_whitespace() {
818        // TOML doesn't normalize string values; users typing
819        // `"Powerline"` or `" powerline "` shouldn't fall into the
820        // unknown-value warn path. The keyword match runs against
821        // `trim().to_ascii_lowercase()` of the (non-empty) input.
822        let mut warns = Vec::new();
823        let mut warn = |m: &str| warns.push(m.to_string());
824        assert_eq!(
825            parse_layout_separator("Powerline", PowerlineWidth::One, &mut warn),
826            Separator::powerline()
827        );
828        assert_eq!(
829            parse_layout_separator("  POWERLINE  ", PowerlineWidth::One, &mut warn),
830            Separator::powerline()
831        );
832        assert_eq!(
833            parse_layout_separator(" Space ", PowerlineWidth::One, &mut warn),
834            Separator::Space
835        );
836        assert!(
837            warns.is_empty(),
838            "no warnings on case/whitespace: {warns:?}"
839        );
840    }
841
842    #[test]
843    fn layout_separator_whitespace_only_renders_as_literal() {
844        // `value.trim()` would otherwise eat user-meaningful whitespace.
845        // `separator = "   "` should produce a 3-space literal between
846        // segments, not `Separator::None`.
847        let mut warns = Vec::new();
848        let mut warn = |m: &str| warns.push(m.to_string());
849        assert_eq!(
850            parse_layout_separator("   ", PowerlineWidth::One, &mut warn),
851            Separator::Literal(std::borrow::Cow::Owned("   ".to_string()))
852        );
853        // Truly empty stays None.
854        assert_eq!(
855            parse_layout_separator("", PowerlineWidth::One, &mut warn),
856            Separator::None
857        );
858        assert!(
859            warns.is_empty(),
860            "no warns on whitespace literal: {warns:?}"
861        );
862    }
863
864    #[test]
865    fn layout_separator_typo_renders_as_literal_not_warn() {
866        // Pin the documented "typos don't warn" contract: the parser
867        // doc explicitly promises `"powereline"` becomes a literal,
868        // not a typo-detection warn. A future contributor adding
869        // "did you mean?" detection would silently invert this; the
870        // test forces a deliberate review.
871        let mut warns = Vec::new();
872        let mut warn = |m: &str| warns.push(m.to_string());
873        assert_eq!(
874            parse_layout_separator("powereline", PowerlineWidth::One, &mut warn),
875            Separator::Literal(std::borrow::Cow::Owned("powereline".to_string()))
876        );
877        assert!(warns.is_empty(), "typos don't warn: {warns:?}");
878    }
879
880    #[test]
881    fn layout_separator_powerline_overrides_runtime_right_separator() {
882        // `apply_layout_separator` only swaps `default_separator`, but
883        // `effective_separator()` prefers a per-render `right_separator`
884        // set via `RenderedSegment::with_separator`. Plugin segments
885        // that return `right_separator: Some(Space)` would otherwise
886        // bypass the global layout-options separator; `OverriddenSegment`
887        // therefore rewrites `right_separator` on render output too.
888        struct RuntimeSpaceSeg;
889        impl segments::Segment for RuntimeSpaceSeg {
890            fn render(
891                &self,
892                _: &crate::data_context::DataContext,
893                _: &segments::RenderContext,
894            ) -> segments::RenderResult {
895                Ok(Some(segments::RenderedSegment::with_separator(
896                    "x",
897                    Separator::Space,
898                )))
899            }
900            fn defaults(&self) -> segments::SegmentDefaults {
901                segments::SegmentDefaults::with_priority(0)
902            }
903        }
904        let layout_sep = parse_layout_separator("powerline", PowerlineWidth::One, &mut |_| {});
905        let wrapped = apply_layout_separator(Box::new(RuntimeSpaceSeg), &layout_sep);
906        let rendered = wrapped
907            .render(&stub_ctx(), &stub_rc())
908            .unwrap()
909            .expect("rendered");
910        assert_eq!(
911            rendered.right_separator(),
912            Some(&Separator::powerline()),
913            "layout-options separator must override runtime Space"
914        );
915    }
916
917    #[test]
918    fn plugin_runtime_space_emits_chevron_through_render_with_warn() {
919        // End-to-end pin for the original Codex regression. The
920        // struct-level override test above proves the rendered struct
921        // carries the right separator; this test follows that through
922        // the full layout + emit pipeline. A future refactor that
923        // bypasses `OverriddenSegment::render` (e.g., a fast path
924        // pulling `defaults().default_separator` directly) would still
925        // pass the struct-level test but emit a Space here.
926        struct RuntimeSpaceSeg(&'static str);
927        impl segments::Segment for RuntimeSpaceSeg {
928            fn render(
929                &self,
930                _: &crate::data_context::DataContext,
931                _: &segments::RenderContext,
932            ) -> segments::RenderResult {
933                Ok(Some(segments::RenderedSegment::with_separator(
934                    self.0,
935                    Separator::Space,
936                )))
937            }
938            fn defaults(&self) -> segments::SegmentDefaults {
939                segments::SegmentDefaults::with_priority(0)
940            }
941        }
942        let layout_sep = parse_layout_separator("powerline", PowerlineWidth::One, &mut |_| {});
943        let segs: Vec<Box<dyn segments::Segment>> = vec![
944            apply_layout_separator(Box::new(RuntimeSpaceSeg("a")), &layout_sep),
945            apply_layout_separator(Box::new(RuntimeSpaceSeg("b")), &layout_sep),
946        ];
947        let line = crate::layout::render_with_warn(
948            &segs,
949            &stub_ctx(),
950            100,
951            &mut |_| {},
952            theme::default_theme(),
953            theme::Capability::None,
954            false,
955        );
956        assert!(line.contains(" \u{E0B0} "), "chevron in output: {line:?}");
957        assert!(
958            !line.contains("a b"),
959            "Space should not survive between a and b: {line:?}"
960        );
961    }
962
963    #[test]
964    fn layout_separator_powerline_preserves_runtime_literal_right_separator() {
965        // Companion pin to the override test above: a per-render
966        // Literal `right_separator` is the segment saying "I picked
967        // this exactly" — layout-options separator must NOT clobber.
968        // Same Literal/None preservation as default_separator.
969        struct RuntimePipeSeg;
970        impl segments::Segment for RuntimePipeSeg {
971            fn render(
972                &self,
973                _: &crate::data_context::DataContext,
974                _: &segments::RenderContext,
975            ) -> segments::RenderResult {
976                Ok(Some(segments::RenderedSegment::with_separator(
977                    "x",
978                    Separator::Literal(std::borrow::Cow::Borrowed(" | ")),
979                )))
980            }
981            fn defaults(&self) -> segments::SegmentDefaults {
982                segments::SegmentDefaults::with_priority(0)
983            }
984        }
985        let layout_sep = parse_layout_separator("powerline", PowerlineWidth::One, &mut |_| {});
986        let wrapped = apply_layout_separator(Box::new(RuntimePipeSeg), &layout_sep);
987        let rendered = wrapped
988            .render(&stub_ctx(), &stub_rc())
989            .unwrap()
990            .expect("rendered");
991        assert_eq!(
992            rendered.right_separator(),
993            Some(&Separator::Literal(std::borrow::Cow::Borrowed(" | ")))
994        );
995    }
996
997    fn stub_ctx() -> crate::data_context::DataContext {
998        use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
999        use std::path::PathBuf;
1000        use std::sync::Arc;
1001        crate::data_context::DataContext::new(StatusContext {
1002            tool: Tool::ClaudeCode,
1003            model: Some(ModelInfo {
1004                display_name: "X".into(),
1005            }),
1006            workspace: Some(WorkspaceInfo {
1007                project_dir: PathBuf::from("/r"),
1008                git_worktree: None,
1009            }),
1010            context_window: None,
1011            cost: None,
1012            effort: None,
1013            vim: None,
1014            output_style: None,
1015            agent_name: None,
1016            version: None,
1017            raw: Arc::new(serde_json::Value::Null),
1018        })
1019    }
1020
1021    fn stub_rc() -> segments::RenderContext {
1022        segments::RenderContext::new(80)
1023    }
1024
1025    #[test]
1026    fn layout_separator_pipe_literal_no_warning() {
1027        // Direct parser-level test for the `|` shorthand ccstatusline
1028        // users reach for. Pinned alongside the config-driven test
1029        // above so a regression that forces literal values through
1030        // the warn-path is caught at both layers.
1031        let mut warns = Vec::new();
1032        let mut warn = |m: &str| warns.push(m.to_string());
1033        assert_eq!(
1034            parse_layout_separator("|", PowerlineWidth::One, &mut warn),
1035            Separator::Literal(std::borrow::Cow::Owned("|".to_string()))
1036        );
1037        assert!(warns.is_empty(), "no warning on literal: {warns:?}");
1038    }
1039
1040    #[test]
1041    fn layout_separator_single_space_renders_as_literal_not_keyword() {
1042        // `separator = " "` (one literal space, not the keyword) is a
1043        // user-visible distinction: the bypass at the top of the
1044        // parser short-circuits truly-empty inputs *before* trim, so
1045        // a single space falls into the literal arm. `Literal(" ")`
1046        // and `Space` render identically but flow through different
1047        // wrap policies in apply_layout_separator (Literal preserves
1048        // segment-side defaults; Space is a no-op).
1049        let mut warns = Vec::new();
1050        let mut warn = |m: &str| warns.push(m.to_string());
1051        assert_eq!(
1052            parse_layout_separator(" ", PowerlineWidth::One, &mut warn),
1053            Separator::Literal(std::borrow::Cow::Owned(" ".to_string()))
1054        );
1055        assert!(
1056            warns.is_empty(),
1057            "no warning on single-space literal: {warns:?}"
1058        );
1059    }
1060
1061    #[test]
1062    fn apply_layout_separator_wraps_when_configured_literal_replaces_space_default() {
1063        // Configured `Literal(" | ")` against a segment whose default
1064        // is `Space` must wrap so the literal reaches the layout engine.
1065        struct SpaceDefaultSeg;
1066        impl segments::Segment for SpaceDefaultSeg {
1067            fn render(
1068                &self,
1069                _: &crate::data_context::DataContext,
1070                _: &segments::RenderContext,
1071            ) -> segments::RenderResult {
1072                Ok(Some(segments::RenderedSegment::new("x")))
1073            }
1074            fn defaults(&self) -> segments::SegmentDefaults {
1075                segments::SegmentDefaults::with_priority(0)
1076            }
1077        }
1078        let sep = Separator::Literal(std::borrow::Cow::Owned(" | ".to_string()));
1079        let wrapped = apply_layout_separator(Box::new(SpaceDefaultSeg), &sep);
1080        assert_eq!(wrapped.defaults().default_separator, sep);
1081    }
1082
1083    #[test]
1084    fn apply_layout_separator_wraps_when_configured_none_replaces_space_default() {
1085        // Configured `None` (user typed `separator = ""`) against a
1086        // Space-default segment must wrap so the layout engine emits
1087        // no separator. The `if matches!(sep, Space)` early-return
1088        // guard at the top of apply_layout_separator must NOT
1089        // accidentally include None.
1090        struct SpaceDefaultSeg;
1091        impl segments::Segment for SpaceDefaultSeg {
1092            fn render(
1093                &self,
1094                _: &crate::data_context::DataContext,
1095                _: &segments::RenderContext,
1096            ) -> segments::RenderResult {
1097                Ok(Some(segments::RenderedSegment::new("x")))
1098            }
1099            fn defaults(&self) -> segments::SegmentDefaults {
1100                segments::SegmentDefaults::with_priority(0)
1101            }
1102        }
1103        let wrapped = apply_layout_separator(Box::new(SpaceDefaultSeg), &Separator::None);
1104        assert_eq!(wrapped.defaults().default_separator, Separator::None);
1105    }
1106
1107    #[test]
1108    fn powerline_width_2_propagates_to_separator_variant() {
1109        // Pin the Codex-flagged correctness path: users on
1110        // 2-cell-rendering Nerd Fonts set
1111        // `[layout_options].powerline_width = 2`, and that width
1112        // reaches `Separator::Powerline { width }` so total_width()
1113        // charges 2 cells per chevron instead of undercounting by 1.
1114        let cfg = config::Config::from_str(
1115            r#"
1116                [line]
1117                segments = ["model"]
1118                [layout_options]
1119                separator = "powerline"
1120                powerline_width = 2
1121            "#,
1122        )
1123        .expect("parse");
1124        let segs = built(Some(&cfg));
1125        assert_eq!(
1126            segs[0].defaults().default_separator,
1127            Separator::Powerline {
1128                width: PowerlineWidth::Two,
1129            }
1130        );
1131    }
1132
1133    #[test]
1134    fn powerline_width_default_is_1_when_unset() {
1135        // Absent `powerline_width` means 1 — the most-common Nerd Font
1136        // size + standard terminal combination.
1137        let cfg = config::Config::from_str(
1138            r#"
1139                [line]
1140                segments = ["model"]
1141                [layout_options]
1142                separator = "powerline"
1143            "#,
1144        )
1145        .expect("parse");
1146        let segs = built(Some(&cfg));
1147        assert_eq!(segs[0].defaults().default_separator, Separator::powerline(),);
1148    }
1149
1150    #[test]
1151    fn powerline_width_invalid_warns_and_falls_back_to_1() {
1152        // A typo'd `powerline_width = 3` falls back to 1 with a
1153        // visible warning. Pins the validate-and-warn contract so a
1154        // future change can't silently accept arbitrary values.
1155        let cfg = config::Config::from_str(
1156            r#"
1157                [line]
1158                segments = ["model"]
1159                [layout_options]
1160                separator = "powerline"
1161                powerline_width = 3
1162            "#,
1163        )
1164        .expect("parse");
1165        let (segs, warns) = built_with_warns(Some(&cfg));
1166        assert_eq!(segs[0].defaults().default_separator, Separator::powerline());
1167        assert!(
1168            warns
1169                .iter()
1170                .any(|m| m.contains("powerline_width") && m.contains("3")),
1171            "missing invalid-width warning: {warns:?}"
1172        );
1173    }
1174
1175    #[test]
1176    fn powerline_width_zero_warns_and_falls_back_to_1() {
1177        // Boundary: 0 is invalid. The `PowerlineWidth` enum makes this
1178        // unrepresentable downstream; the validator is the single
1179        // boundary that maps `u16` config inputs to the typed value.
1180        let mut warns = Vec::new();
1181        let mut warn = |m: &str| warns.push(m.to_string());
1182        assert_eq!(validate_powerline_width(0, &mut warn), PowerlineWidth::One);
1183        assert!(
1184            warns
1185                .iter()
1186                .any(|m| m.contains("powerline_width") && m.contains('0')),
1187            "missing zero warning: {warns:?}"
1188        );
1189    }
1190
1191    #[test]
1192    fn powerline_width_max_warns_and_falls_back_to_1() {
1193        // Boundary: u16::MAX is invalid. The `PowerlineWidth` enum
1194        // means downstream layout math can't see this value at all —
1195        // the validator is the only thing standing between user input
1196        // and a typed cell count.
1197        let mut warns = Vec::new();
1198        let mut warn = |m: &str| warns.push(m.to_string());
1199        assert_eq!(
1200            validate_powerline_width(u16::MAX, &mut warn),
1201            PowerlineWidth::One
1202        );
1203        assert!(
1204            warns.iter().any(|m| m.contains("powerline_width")),
1205            "missing max-width warning: {warns:?}"
1206        );
1207    }
1208
1209    #[test]
1210    fn layout_separator_absent_section_resolves_to_space() {
1211        // Config present but no `[layout_options]` section at all.
1212        // Threads through the `unwrap_or(Separator::Space)` chain in
1213        // build_segments — the failure mode would be a panic on the
1214        // `and_then` / `as_deref` hops.
1215        let cfg = config::Config::from_str(
1216            r#"
1217                [line]
1218                segments = ["model"]
1219            "#,
1220        )
1221        .expect("parse");
1222        let segs = built(Some(&cfg));
1223        assert_eq!(segs[0].defaults().default_separator, Separator::Space);
1224    }
1225
1226    #[test]
1227    fn build_segments_uses_configured_line_order() {
1228        let cfg = config::Config::from_str(
1229            r#"
1230                [line]
1231                segments = ["workspace", "model"]
1232            "#,
1233        )
1234        .expect("parse");
1235        let got = built(Some(&cfg));
1236        // Compare by default priority since we can't name-check dyn
1237        // Segments directly.
1238        assert_eq!(got.len(), 2);
1239        assert_eq!(got[0].defaults().priority, 16); // workspace
1240        assert_eq!(got[1].defaults().priority, 64); // model
1241    }
1242
1243    #[test]
1244    fn build_segments_applies_priority_override() {
1245        let cfg = config::Config::from_str(
1246            r#"
1247                [line]
1248                segments = ["model"]
1249                [segments.model]
1250                priority = 0
1251            "#,
1252        )
1253        .expect("parse");
1254        let got = built(Some(&cfg));
1255        assert_eq!(got[0].defaults().priority, 0);
1256    }
1257
1258    #[test]
1259    fn build_segments_applies_width_override() {
1260        let cfg = config::Config::from_str(
1261            r#"
1262                [line]
1263                segments = ["workspace"]
1264                [segments.workspace.width]
1265                min = 5
1266                max = 30
1267            "#,
1268        )
1269        .expect("parse");
1270        let got = built(Some(&cfg));
1271        let bounds = got[0].defaults().width.expect("width set");
1272        assert_eq!(bounds.min(), 5);
1273        assert_eq!(bounds.max(), 30);
1274    }
1275
1276    #[test]
1277    fn build_segments_skips_unknown_ids_and_warns() {
1278        let cfg = config::Config::from_str(
1279            r#"
1280                [line]
1281                segments = ["model", "does_not_exist", "workspace"]
1282            "#,
1283        )
1284        .expect("parse");
1285        let mut warnings = Vec::new();
1286        let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
1287        assert_eq!(got.len(), 2);
1288        assert_eq!(warnings.len(), 1);
1289        assert!(warnings[0].contains("does_not_exist"));
1290    }
1291
1292    #[test]
1293    fn build_segments_dedupes_duplicates_with_warning() {
1294        let cfg = config::Config::from_str(
1295            r#"
1296                [line]
1297                segments = ["model", "model", "workspace"]
1298            "#,
1299        )
1300        .expect("parse");
1301        let mut warnings = Vec::new();
1302        let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
1303        assert_eq!(got.len(), 2); // one model, one workspace
1304        assert_eq!(warnings.len(), 1);
1305        assert!(warnings[0].contains("model"));
1306        assert!(warnings[0].contains("more than once"));
1307    }
1308
1309    #[test]
1310    fn build_segments_warns_on_explicitly_empty_segment_list() {
1311        let cfg = config::Config::from_str(
1312            r#"
1313                [line]
1314                segments = []
1315            "#,
1316        )
1317        .expect("parse");
1318        let mut warnings = Vec::new();
1319        let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
1320        assert!(got.is_empty());
1321        assert_eq!(warnings.len(), 1);
1322        assert!(warnings[0].contains("empty"));
1323    }
1324
1325    #[test]
1326    fn build_segments_warns_on_inverted_width_bounds() {
1327        let cfg = config::Config::from_str(
1328            r#"
1329                [line]
1330                segments = ["workspace"]
1331                [segments.workspace.width]
1332                min = 40
1333                max = 10
1334            "#,
1335        )
1336        .expect("parse");
1337        let mut warnings = Vec::new();
1338        let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
1339        assert_eq!(got.len(), 1);
1340        assert_eq!(got[0].defaults().width, None);
1341        assert_eq!(warnings.len(), 1);
1342        assert!(warnings[0].contains("min"));
1343        assert!(warnings[0].contains("max"));
1344    }
1345
1346    // Stub with a baseline `width` default pins `apply_override`'s
1347    // inherit-from-inner branches. No built-in segment exercises
1348    // these today, so the contract needs an explicit guard.
1349    struct StubWithWidth;
1350
1351    impl Segment for StubWithWidth {
1352        fn render(
1353            &self,
1354            _: &crate::data_context::DataContext,
1355            _: &segments::RenderContext,
1356        ) -> segments::RenderResult {
1357            Ok(Some(segments::RenderedSegment::new("x")))
1358        }
1359        fn defaults(&self) -> segments::SegmentDefaults {
1360            segments::SegmentDefaults::with_priority(128)
1361                .with_width(WidthBounds::new(10, 50).expect("valid"))
1362        }
1363    }
1364
1365    fn merge_width(min: Option<u16>, max: Option<u16>) -> WidthBounds {
1366        let ov = config::SegmentOverride {
1367            priority: None,
1368            width: Some(config::WidthBoundsConfig { min, max }),
1369            style: None,
1370            extra: BTreeMap::new(),
1371        };
1372        let wrapped = apply_override("stub", Box::new(StubWithWidth), Some(&ov), &mut |_| {});
1373        wrapped.defaults().width.expect("width preserved")
1374    }
1375
1376    #[test]
1377    fn width_merge_min_only_inherits_max_from_inner_default() {
1378        let got = merge_width(Some(5), None);
1379        assert_eq!(got.min(), 5);
1380        assert_eq!(got.max(), 50);
1381    }
1382
1383    #[test]
1384    fn width_merge_max_only_inherits_min_from_inner_default() {
1385        let got = merge_width(None, Some(80));
1386        assert_eq!(got.min(), 10);
1387        assert_eq!(got.max(), 80);
1388    }
1389
1390    #[test]
1391    fn width_merge_both_sides_override_inner_default() {
1392        let got = merge_width(Some(3), Some(40));
1393        assert_eq!(got.min(), 3);
1394        assert_eq!(got.max(), 40);
1395    }
1396
1397    #[test]
1398    fn width_merge_empty_override_keeps_inner_default() {
1399        // An empty [segments.<id>.width] table still appears as
1400        // `Some(WidthBoundsConfig { min: None, max: None })`; the
1401        // merged bounds must equal the inner's default.
1402        let got = merge_width(None, None);
1403        assert_eq!(got.min(), 10);
1404        assert_eq!(got.max(), 50);
1405    }
1406
1407    fn rc() -> crate::segments::RenderContext {
1408        crate::segments::RenderContext::new(80)
1409    }
1410
1411    fn model_ctx(display_name: &str) -> crate::data_context::DataContext {
1412        use crate::input::{ModelInfo, Tool, WorkspaceInfo};
1413        use std::path::PathBuf;
1414        use std::sync::Arc;
1415        crate::data_context::DataContext::new(input::StatusContext {
1416            tool: Tool::ClaudeCode,
1417            model: Some(ModelInfo {
1418                display_name: display_name.into(),
1419            }),
1420            workspace: Some(WorkspaceInfo {
1421                project_dir: PathBuf::from("/repo"),
1422                git_worktree: None,
1423            }),
1424            context_window: None,
1425            cost: None,
1426            effort: None,
1427            vim: None,
1428            output_style: None,
1429            agent_name: None,
1430            version: None,
1431            raw: Arc::new(serde_json::Value::Null),
1432        })
1433    }
1434
1435    #[test]
1436    fn style_override_replaces_segment_declared_style_at_render_time() {
1437        use crate::theme::{Color, Role};
1438        let cfg = config::Config::from_str(
1439            r#"
1440                [line]
1441                segments = ["model"]
1442                [segments.model]
1443                style = "role:accent bold italic"
1444            "#,
1445        )
1446        .expect("parse");
1447        let built = build_segments(Some(&cfg), None, |_| {});
1448        let rendered = built[0]
1449            .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1450            .expect("render ok")
1451            .expect("visible");
1452        assert_eq!(rendered.style.role, Some(Role::Accent));
1453        assert_eq!(rendered.style.fg, None::<Color>);
1454        assert!(rendered.style.bold);
1455        assert!(rendered.style.italic);
1456        assert!(!rendered.style.underline);
1457        assert!(!rendered.style.dim);
1458    }
1459
1460    #[test]
1461    fn style_override_with_explicit_fg_populates_fg_slot() {
1462        use crate::theme::Color;
1463        let cfg = config::Config::from_str(
1464            r#"
1465                [line]
1466                segments = ["model"]
1467                [segments.model]
1468                style = "fg:#ff0000 underline"
1469            "#,
1470        )
1471        .expect("parse");
1472        let built = build_segments(Some(&cfg), None, |_| {});
1473        let rendered = built[0]
1474            .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1475            .expect("render ok")
1476            .expect("visible");
1477        assert_eq!(
1478            rendered.style.fg,
1479            Some(Color::TrueColor { r: 255, g: 0, b: 0 })
1480        );
1481        assert!(rendered.style.underline);
1482    }
1483
1484    #[test]
1485    fn invalid_style_string_warns_and_leaves_segment_style_unchanged() {
1486        use crate::theme::Role;
1487        let cfg = config::Config::from_str(
1488            r#"
1489                [line]
1490                segments = ["model"]
1491                [segments.model]
1492                style = "role:mauve"
1493            "#,
1494        )
1495        .expect("parse");
1496        let mut warnings = Vec::new();
1497        let built = build_segments(Some(&cfg), None, |m| warnings.push(m.to_string()));
1498        let rendered = built[0]
1499            .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1500            .expect("render ok")
1501            .expect("visible");
1502        assert_eq!(rendered.style.role, Some(Role::Primary));
1503        assert_eq!(warnings.len(), 1);
1504        assert!(warnings[0].contains("segments.model.style"));
1505        assert!(warnings[0].contains("mauve"));
1506        assert!(warnings[0].contains("ignoring"));
1507    }
1508
1509    #[test]
1510    fn empty_style_string_is_noop_and_preserves_segment_declared_style() {
1511        use crate::theme::Role;
1512        let cfg = config::Config::from_str(
1513            r#"
1514                [line]
1515                segments = ["model"]
1516                [segments.model]
1517                style = ""
1518            "#,
1519        )
1520        .expect("parse");
1521        let built = build_segments(Some(&cfg), None, |_| {});
1522        let rendered = built[0]
1523            .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1524            .expect("render ok")
1525            .expect("visible");
1526        assert_eq!(rendered.style.role, Some(Role::Primary));
1527    }
1528
1529    #[test]
1530    fn whitespace_only_style_string_is_noop_and_preserves_segment_declared_style() {
1531        use crate::theme::Role;
1532        let cfg = config::Config::from_str(
1533            r#"
1534                [line]
1535                segments = ["model"]
1536                [segments.model]
1537                style = "   "
1538            "#,
1539        )
1540        .expect("parse");
1541        let built = build_segments(Some(&cfg), None, |_| {});
1542        let rendered = built[0]
1543            .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1544            .expect("render ok")
1545            .expect("visible");
1546        assert_eq!(rendered.style.role, Some(Role::Primary));
1547    }
1548
1549    // --- plugin integration ----------------------------------------
1550
1551    fn write_plugin(dir: &std::path::Path, name: &str, src: &str) -> std::path::PathBuf {
1552        let p = dir.join(name);
1553        std::fs::write(&p, src).expect("write plugin");
1554        p
1555    }
1556
1557    #[test]
1558    fn plugin_id_resolves_through_build_segments() {
1559        let tmp = tempfile::TempDir::new().expect("tempdir");
1560        write_plugin(
1561            tmp.path(),
1562            "p.rhai",
1563            r#"
1564            const ID = "my_plugin";
1565            fn render(ctx) { #{ runs: [#{ text: "from-plugin" }] } }
1566            "#,
1567        );
1568        let engine = crate::plugins::build_engine();
1569        let registry = crate::plugins::PluginRegistry::load_with_xdg(
1570            &[tmp.path().to_path_buf()],
1571            None,
1572            &engine,
1573            BUILT_IN_SEGMENT_IDS,
1574        );
1575        assert!(
1576            registry.load_errors().is_empty(),
1577            "load errors: {:?}",
1578            registry.load_errors()
1579        );
1580
1581        let cfg = config::Config::from_str(
1582            r#"
1583                [line]
1584                segments = ["model", "my_plugin"]
1585            "#,
1586        )
1587        .expect("parse");
1588        let built = build_segments(Some(&cfg), Some((registry, engine)), |_| {});
1589        assert_eq!(built.len(), 2);
1590        // Order matches `[line].segments`: built-in `model` first,
1591        // plugin `my_plugin` second. `model` defaults to priority 64;
1592        // a plugin with no override defaults to the trait's 128.
1593        assert_eq!(built[0].defaults().priority, 64);
1594        assert_eq!(built[1].defaults().priority, 128);
1595        // The plugin's render emits a known string — pin it so a
1596        // wiring regression that swaps slots fails loudly.
1597        let dc = model_ctx("Sonnet");
1598        let plugin_render = built[1]
1599            .render(&dc, &rc())
1600            .expect("plugin render ok")
1601            .expect("visible");
1602        assert_eq!(plugin_render.text(), "from-plugin");
1603    }
1604
1605    #[test]
1606    fn build_segments_falls_back_to_first_line_for_multi_line_configs() {
1607        // Embedders on the single-line API calling `build_segments`
1608        // against a `layout = "multi-line"` config (e.g. the
1609        // `power-user` preset) need to render something rather than
1610        // a blank status line. Pin both the rendered segments and
1611        // the warning text so the fallback doesn't silently regress.
1612        let cfg = config::Config::from_str(
1613            r#"
1614                layout = "multi-line"
1615                [line.1]
1616                segments = ["model", "workspace"]
1617                [line.2]
1618                segments = ["context_window", "cost"]
1619            "#,
1620        )
1621        .expect("parse");
1622        let (segs, warns) = built_with_warns(Some(&cfg));
1623        assert_eq!(
1624            segs.len(),
1625            2,
1626            "expected line 1's two segments, got {} segs",
1627            segs.len()
1628        );
1629        let actual: Vec<u8> = segs.iter().map(|s| s.defaults().priority).collect();
1630        assert_eq!(actual, priorities_for(&["model", "workspace"]));
1631        assert!(
1632            warns
1633                .iter()
1634                .any(|w| w.contains("multi-line") && w.contains("build_lines")),
1635            "expected migration hint pointing at build_lines, got: {warns:?}"
1636        );
1637    }
1638
1639    #[test]
1640    fn build_lines_plugin_referenced_in_two_lines_warns_specifically_on_second() {
1641        // v0.1 limitation: the shared plugin lookup is consumed on
1642        // first use, so a plugin id repeated on a later line can't
1643        // render. The diagnostic must say "rendered on an earlier
1644        // line" (not the generic "unknown segment id") so users
1645        // understand the cause is reuse, not a typo. Pin the
1646        // specific text so wording drift is caught here rather than
1647        // by a confused user.
1648        let tmp = tempfile::TempDir::new().expect("tempdir");
1649        write_plugin(
1650            tmp.path(),
1651            "p.rhai",
1652            r#"
1653            const ID = "my_plugin";
1654            fn render(ctx) { #{ runs: [#{ text: "from-plugin" }] } }
1655            "#,
1656        );
1657        let engine = crate::plugins::build_engine();
1658        let registry = crate::plugins::PluginRegistry::load_with_xdg(
1659            &[tmp.path().to_path_buf()],
1660            None,
1661            &engine,
1662            BUILT_IN_SEGMENT_IDS,
1663        );
1664        assert!(registry.load_errors().is_empty());
1665
1666        let cfg = config::Config::from_str(
1667            r#"
1668                layout = "multi-line"
1669                [line.1]
1670                segments = ["my_plugin", "model"]
1671                [line.2]
1672                segments = ["my_plugin", "workspace"]
1673            "#,
1674        )
1675        .expect("parse");
1676        let mut warns: Vec<String> = Vec::new();
1677        let lines = build_lines(Some(&cfg), Some((registry, engine)), |m| {
1678            warns.push(m.to_string())
1679        });
1680
1681        // Line 1 has plugin + model; line 2 only workspace (plugin
1682        // reuse skipped).
1683        assert_eq!(lines.len(), 2);
1684        assert_eq!(lines[0].len(), 2, "line 1 keeps plugin + model");
1685        assert_eq!(
1686            lines[1].len(),
1687            1,
1688            "line 2 drops the reused plugin, keeps workspace"
1689        );
1690        assert!(
1691            warns
1692                .iter()
1693                .any(|w| w.contains("'my_plugin'") && w.contains("rendered on an earlier line")),
1694            "expected specific cross-line plugin warning, got: {warns:?}"
1695        );
1696        assert!(
1697            !warns
1698                .iter()
1699                .any(|w| w.contains("unknown segment id 'my_plugin'")),
1700            "should NOT use the generic 'unknown segment id' text for cross-line reuse, got: {warns:?}"
1701        );
1702    }
1703
1704    #[test]
1705    fn unknown_id_with_plugin_registry_still_warns() {
1706        let tmp = tempfile::TempDir::new().expect("tempdir");
1707        write_plugin(
1708            tmp.path(),
1709            "p.rhai",
1710            r#"
1711            const ID = "loaded";
1712            fn render(ctx) { () }
1713            "#,
1714        );
1715        let engine = crate::plugins::build_engine();
1716        let registry = crate::plugins::PluginRegistry::load_with_xdg(
1717            &[tmp.path().to_path_buf()],
1718            None,
1719            &engine,
1720            BUILT_IN_SEGMENT_IDS,
1721        );
1722
1723        let cfg = config::Config::from_str(
1724            r#"
1725                [line]
1726                segments = ["loaded", "missing_plugin"]
1727            "#,
1728        )
1729        .expect("parse");
1730        let mut warnings = Vec::new();
1731        let built = build_segments(Some(&cfg), Some((registry, engine)), |m| {
1732            warnings.push(m.to_string())
1733        });
1734        assert_eq!(built.len(), 1);
1735        assert_eq!(warnings.len(), 1);
1736        assert!(warnings[0].contains("missing_plugin"));
1737    }
1738
1739    #[test]
1740    fn plugin_receives_extra_keys_from_segments_table_as_ctx_config() {
1741        // Pins the TOML → SegmentOverride.extra → toml_table_to_dynamic
1742        // → ctx.config round-trip. Without it, the wiring could
1743        // silently regress to passing Dynamic::UNIT and a plugin
1744        // reading `ctx.config.foo` would crash at render time.
1745        let tmp = tempfile::TempDir::new().expect("tempdir");
1746        write_plugin(
1747            tmp.path(),
1748            "labelled.rhai",
1749            r#"
1750            const ID = "labelled";
1751            fn render(ctx) {
1752                #{ runs: [#{ text: ctx.config.label }] }
1753            }
1754            "#,
1755        );
1756        let engine = crate::plugins::build_engine();
1757        let registry = crate::plugins::PluginRegistry::load_with_xdg(
1758            &[tmp.path().to_path_buf()],
1759            None,
1760            &engine,
1761            BUILT_IN_SEGMENT_IDS,
1762        );
1763
1764        let cfg = config::Config::from_str(
1765            r#"
1766                [line]
1767                segments = ["labelled"]
1768                [segments.labelled]
1769                label = "from-toml"
1770            "#,
1771        )
1772        .expect("parse");
1773        let built = build_segments(Some(&cfg), Some((registry, engine)), |_| {});
1774        assert_eq!(built.len(), 1);
1775        let dc = model_ctx("Sonnet");
1776        let rendered = built[0]
1777            .render(&dc, &rc())
1778            .expect("render ok")
1779            .expect("visible");
1780        assert_eq!(rendered.text(), "from-toml");
1781    }
1782
1783    #[test]
1784    fn built_in_id_wins_over_plugin_with_same_id() {
1785        // The registry's load-time check normally rejects a plugin
1786        // whose `const ID` shadows a built-in. This test smuggles
1787        // such a plugin past the registry (empty built-in list at
1788        // load time) and configures the colliding id in `[line]`,
1789        // then asserts `build_segments` still picks the built-in.
1790        // Locks the belt-and-suspenders precedence in case the
1791        // registry-level check ever regresses.
1792        let tmp = tempfile::TempDir::new().expect("tempdir");
1793        write_plugin(
1794            tmp.path(),
1795            "ghost.rhai",
1796            r#"
1797            const ID = "model";
1798            fn render(_) { #{ runs: [#{ text: "from-plugin" }] } }
1799            "#,
1800        );
1801        let engine = crate::plugins::build_engine();
1802        let registry = crate::plugins::PluginRegistry::load_with_xdg(
1803            &[tmp.path().to_path_buf()],
1804            None,
1805            &engine,
1806            &[],
1807        );
1808
1809        let cfg = config::Config::from_str(
1810            r#"
1811                [line]
1812                segments = ["model"]
1813            "#,
1814        )
1815        .expect("parse");
1816        let built = build_segments(Some(&cfg), Some((registry, engine)), |_| {});
1817        // The built-in model segment uses display_name from ctx; a
1818        // text comparison wouldn't be stable across changes there.
1819        // Priority 64 belongs to the built-in; the plugin would have
1820        // the trait default of 128.
1821        assert_eq!(built.len(), 1);
1822        assert_eq!(built[0].defaults().priority, 64);
1823    }
1824
1825    #[test]
1826    fn build_segments_forward_compat_keys_dont_break_parsing() {
1827        let cfg = config::Config::from_str(
1828            r#"
1829                theme = "catppuccin-mocha"
1830                preset = "developer"
1831                layout = "single-line"
1832                [line]
1833                segments = ["model"]
1834                [layout_options]
1835                separator = "powerline"
1836            "#,
1837        )
1838        .expect("parse");
1839        assert_eq!(built(Some(&cfg)).len(), 1);
1840    }
1841
1842    // --- build_lines (multi-line layout) ---
1843
1844    fn lines(cfg: Option<&config::Config>) -> Vec<Vec<Box<dyn Segment>>> {
1845        build_lines(cfg, None, |_| {})
1846    }
1847
1848    fn lines_with_warns(cfg: Option<&config::Config>) -> (Vec<Vec<Box<dyn Segment>>>, Vec<String>) {
1849        let mut warns = Vec::new();
1850        let result = build_lines(cfg, None, |m| warns.push(m.to_string()));
1851        (result, warns)
1852    }
1853
1854    /// Compare ids-per-line by mapping each id to its built-in's
1855    /// declared priority. Segments don't expose `id()` through the
1856    /// trait, so the existing tests use `defaults().priority` as the
1857    /// identity signal. Caller passes the expected id list per line;
1858    /// helper looks up the priority for each and returns that as the
1859    /// comparable shape.
1860    fn priorities_for(ids: &[&str]) -> Vec<u8> {
1861        ids.iter()
1862            .map(|id| {
1863                built_in_by_id(id, None, &mut |_| {})
1864                    .unwrap_or_else(|| panic!("unknown built-in id in test fixture: {id}"))
1865                    .defaults()
1866                    .priority
1867            })
1868            .collect()
1869    }
1870
1871    fn priorities_per_line(built: &[Vec<Box<dyn Segment>>]) -> Vec<Vec<u8>> {
1872        built
1873            .iter()
1874            .map(|line| line.iter().map(|s| s.defaults().priority).collect())
1875            .collect()
1876    }
1877
1878    #[test]
1879    fn build_lines_single_line_default_returns_one_line_with_default_segments() {
1880        // No config = implicit single-line with default segment list.
1881        let result = lines(None);
1882        assert_eq!(result.len(), 1);
1883        assert_eq!(result[0].len(), DEFAULT_SEGMENT_IDS.len());
1884    }
1885
1886    #[test]
1887    fn build_lines_explicit_single_line_returns_one_line_from_segments() {
1888        let cfg = config::Config::from_str(
1889            r#"
1890                layout = "single-line"
1891                [line]
1892                segments = ["model", "workspace"]
1893            "#,
1894        )
1895        .expect("parse");
1896        let result = lines(Some(&cfg));
1897        assert_eq!(
1898            priorities_per_line(&result),
1899            vec![priorities_for(&["model", "workspace"])]
1900        );
1901    }
1902
1903    #[test]
1904    fn build_lines_multi_line_returns_one_inner_vec_per_numbered_table() {
1905        let cfg = config::Config::from_str(
1906            r#"
1907                layout = "multi-line"
1908                [line.1]
1909                segments = ["model", "context_window"]
1910                [line.2]
1911                segments = ["workspace", "cost"]
1912            "#,
1913        )
1914        .expect("parse");
1915        let result = lines(Some(&cfg));
1916        assert_eq!(
1917            priorities_per_line(&result),
1918            vec![
1919                priorities_for(&["model", "context_window"]),
1920                priorities_for(&["workspace", "cost"]),
1921            ]
1922        );
1923    }
1924
1925    #[test]
1926    fn build_lines_multi_line_sorts_by_parsed_integer_not_lexicographic() {
1927        // BTreeMap key order is lexicographic on strings, so "10" sorts
1928        // before "2". The builder must parse keys as u32 and sort
1929        // numerically; otherwise [line.10] would render before
1930        // [line.2] and quietly break user expectations.
1931        let cfg = config::Config::from_str(
1932            r#"
1933                layout = "multi-line"
1934                [line.2]
1935                segments = ["workspace"]
1936                [line.10]
1937                segments = ["context_window"]
1938                [line.1]
1939                segments = ["model"]
1940            "#,
1941        )
1942        .expect("parse");
1943        let result = lines(Some(&cfg));
1944        assert_eq!(
1945            priorities_per_line(&result),
1946            vec![
1947                priorities_for(&["model"]),
1948                priorities_for(&["workspace"]),
1949                priorities_for(&["context_window"]),
1950            ]
1951        );
1952    }
1953
1954    #[test]
1955    fn build_lines_multi_line_with_no_numbered_tables_falls_back_to_single_line() {
1956        // Spec edge case: layout="multi-line" without [line.N] warns
1957        // and uses [line].segments. Without this fallback a typo in
1958        // `layout` would silently render nothing.
1959        let cfg = config::Config::from_str(
1960            r#"
1961                layout = "multi-line"
1962                [line]
1963                segments = ["model", "workspace"]
1964            "#,
1965        )
1966        .expect("parse");
1967        let (result, warns) = lines_with_warns(Some(&cfg));
1968        assert_eq!(
1969            priorities_per_line(&result),
1970            vec![priorities_for(&["model", "workspace"])]
1971        );
1972        assert!(
1973            warns.iter().any(|w| w.contains("no usable [line.N]")),
1974            "expected fallback warning, got: {warns:?}"
1975        );
1976    }
1977
1978    #[test]
1979    fn build_lines_single_line_with_numbered_tables_warns_and_ignores_them() {
1980        // Spec edge case: layout="single-line" + [line.N] present
1981        // logs and uses [line].segments. Numbered tables are dropped
1982        // (not silently rendered), so a user mid-migration sees the
1983        // warning before `linesmith --check-config` would.
1984        let cfg = config::Config::from_str(
1985            r#"
1986                layout = "single-line"
1987                [line]
1988                segments = ["model"]
1989                [line.1]
1990                segments = ["workspace"]
1991            "#,
1992        )
1993        .expect("parse");
1994        let (result, warns) = lines_with_warns(Some(&cfg));
1995        assert_eq!(
1996            priorities_per_line(&result),
1997            vec![priorities_for(&["model"])]
1998        );
1999        assert!(
2000            warns
2001                .iter()
2002                .any(|w| w.contains("single-line") && w.contains("[line.N]")),
2003            "expected mode-mismatch warning, got: {warns:?}"
2004        );
2005    }
2006
2007    #[test]
2008    fn build_lines_default_layout_with_numbered_tables_warns_and_ignores_them() {
2009        // No `layout =` field defaults to single-line; numbered tables
2010        // present should be flagged the same way as explicit single-line.
2011        let cfg = config::Config::from_str(
2012            r#"
2013                [line]
2014                segments = ["model"]
2015                [line.1]
2016                segments = ["workspace"]
2017            "#,
2018        )
2019        .expect("parse");
2020        let (result, warns) = lines_with_warns(Some(&cfg));
2021        assert_eq!(
2022            priorities_per_line(&result),
2023            vec![priorities_for(&["model"])]
2024        );
2025        assert!(
2026            warns.iter().any(|w| w.contains("[line.N]")),
2027            "expected mode-mismatch warning, got: {warns:?}"
2028        );
2029    }
2030
2031    #[test]
2032    fn build_lines_promotes_to_multi_line_when_layout_unset_and_segments_empty() {
2033        // CX-2-B: a user who defines [line.1]/[line.2] but forgets
2034        // `layout = "multi-line"` AND leaves [line].segments empty
2035        // clearly meant multi-line. Auto-promote with a hint to add
2036        // the missing key, rather than silently rendering blank.
2037        let cfg = config::Config::from_str(
2038            r#"
2039                [line.1]
2040                segments = ["model"]
2041                [line.2]
2042                segments = ["workspace"]
2043            "#,
2044        )
2045        .expect("parse");
2046        let (result, warns) = lines_with_warns(Some(&cfg));
2047        assert_eq!(
2048            priorities_per_line(&result),
2049            vec![priorities_for(&["model"]), priorities_for(&["workspace"]),],
2050            "must render both numbered lines, not a blank single-line"
2051        );
2052        assert!(
2053            warns
2054                .iter()
2055                .any(|w| w.contains("treating as multi-line") && w.contains("layout")),
2056            "expected auto-promote hint, got: {warns:?}"
2057        );
2058    }
2059
2060    #[test]
2061    fn build_lines_does_not_promote_when_segments_populated() {
2062        // The auto-promote only fires when [line].segments is EMPTY.
2063        // A populated segments list signals "the user picked single-
2064        // line on purpose"; numbered tables are noise to be warned
2065        // about and dropped (existing behavior, must not regress).
2066        let cfg = config::Config::from_str(
2067            r#"
2068                [line]
2069                segments = ["model"]
2070                [line.1]
2071                segments = ["workspace"]
2072            "#,
2073        )
2074        .expect("parse");
2075        let (result, warns) = lines_with_warns(Some(&cfg));
2076        assert_eq!(
2077            priorities_per_line(&result),
2078            vec![priorities_for(&["model"])],
2079            "must render single-line `[line].segments`, not promote"
2080        );
2081        assert!(
2082            warns.iter().any(|w| w.contains("ignoring numbered tables")),
2083            "expected the existing 'ignoring' warning, not the promote hint, got: {warns:?}"
2084        );
2085        assert!(
2086            !warns.iter().any(|w| w.contains("treating as multi-line")),
2087            "must NOT auto-promote when segments is populated, got: {warns:?}"
2088        );
2089    }
2090
2091    #[test]
2092    fn build_lines_unknown_scalar_key_under_line_warns_and_drops() {
2093        // CX-2-A part 2 (validation side): a typo'd scalar key like
2094        // [line] segmnts = [...] flows through the flatten map as a
2095        // toml::Value::Array. The builder must warn and drop rather
2096        // than crash.
2097        let cfg = config::Config::from_str(
2098            r#"
2099                layout = "multi-line"
2100                [line]
2101                segmnts = ["model"]
2102                [line.1]
2103                segments = ["workspace"]
2104            "#,
2105        )
2106        .expect("parse");
2107        let (result, warns) = lines_with_warns(Some(&cfg));
2108        assert_eq!(
2109            priorities_per_line(&result),
2110            vec![priorities_for(&["workspace"])]
2111        );
2112        assert!(
2113            warns
2114                .iter()
2115                .any(|w| w.contains("unknown key 'segmnts'") && w.contains("array")),
2116            "expected unknown-key warning naming the key + type, got: {warns:?}"
2117        );
2118    }
2119
2120    #[test]
2121    fn build_lines_consumed_plugins_threads_across_three_or_more_lines() {
2122        // pr-test-analyzer pass-2: the 2-line cross-line plugin test
2123        // doesn't catch a regression that resets the consumed set
2124        // after line 2. Three lines, plugin reused on every line:
2125        // line 1 renders, lines 2 AND 3 emit the specific "rendered
2126        // on an earlier line" warning. Workspace fills the rest.
2127        let tmp = tempfile::TempDir::new().expect("tempdir");
2128        write_plugin(
2129            tmp.path(),
2130            "p.rhai",
2131            r#"
2132            const ID = "my_plugin";
2133            fn render(ctx) { #{ runs: [#{ text: "from-plugin" }] } }
2134            "#,
2135        );
2136        let engine = crate::plugins::build_engine();
2137        let registry = crate::plugins::PluginRegistry::load_with_xdg(
2138            &[tmp.path().to_path_buf()],
2139            None,
2140            &engine,
2141            BUILT_IN_SEGMENT_IDS,
2142        );
2143
2144        let cfg = config::Config::from_str(
2145            r#"
2146                layout = "multi-line"
2147                [line.1]
2148                segments = ["my_plugin", "model"]
2149                [line.2]
2150                segments = ["my_plugin", "workspace"]
2151                [line.3]
2152                segments = ["my_plugin", "context_window"]
2153            "#,
2154        )
2155        .expect("parse");
2156        let mut warns: Vec<String> = Vec::new();
2157        let lines = build_lines(Some(&cfg), Some((registry, engine)), |m| {
2158            warns.push(m.to_string())
2159        });
2160
2161        assert_eq!(lines.len(), 3);
2162        assert_eq!(lines[0].len(), 2, "line 1: plugin + model");
2163        assert_eq!(lines[1].len(), 1, "line 2: plugin dropped, only workspace");
2164        assert_eq!(
2165            lines[2].len(),
2166            1,
2167            "line 3: plugin dropped, only context_window"
2168        );
2169        let cross_line_warns = warns
2170            .iter()
2171            .filter(|w| w.contains("rendered on an earlier line"))
2172            .count();
2173        assert_eq!(
2174            cross_line_warns, 2,
2175            "expected exactly two cross-line warnings (lines 2 + 3), got {cross_line_warns}: {warns:?}"
2176        );
2177    }
2178
2179    #[test]
2180    fn build_segments_falls_back_to_line_one_even_when_top_segments_populated() {
2181        // pr-test-analyzer pass-2: the multi-line fallback for the
2182        // single-line API must use [line.1].segments, not [line]
2183        // .segments, even when both are populated. The numbered
2184        // tables' precedence-wins rule applies here too; without
2185        // this test, a future "merge" refactor could silently flip
2186        // the precedence and embedders would render the wrong line.
2187        let cfg = config::Config::from_str(
2188            r#"
2189                layout = "multi-line"
2190                [line]
2191                segments = ["cost"]
2192                [line.1]
2193                segments = ["model"]
2194            "#,
2195        )
2196        .expect("parse");
2197        let (segs, _warns) = built_with_warns(Some(&cfg));
2198        let actual: Vec<u8> = segs.iter().map(|s| s.defaults().priority).collect();
2199        assert_eq!(
2200            actual,
2201            priorities_for(&["model"]),
2202            "fallback must use [line.1].segments, not the top-level [line].segments"
2203        );
2204    }
2205
2206    #[test]
2207    fn build_segments_multi_line_with_only_invalid_numbered_keys_falls_through_to_single_line() {
2208        // pr-test-analyzer pass-2: when the multi-line branch finds
2209        // no usable [line.N], `validated_numbered_lines` returns
2210        // None and we fall through to the single-line render path
2211        // (which warns on empty segments). Pin both warnings so a
2212        // refactor that swaps the None/empty handling order doesn't
2213        // silently swallow one.
2214        let cfg = config::Config::from_str(
2215            r#"
2216                layout = "multi-line"
2217                [line.foo]
2218                segments = ["bogus"]
2219            "#,
2220        )
2221        .expect("parse");
2222        let (segs, warns) = built_with_warns(Some(&cfg));
2223        assert!(segs.is_empty(), "no usable line means no segments rendered");
2224        assert!(
2225            warns
2226                .iter()
2227                .any(|w| w.contains("[line.foo]") && w.contains("not a positive integer")),
2228            "must warn about the dropped non-numeric key, got: {warns:?}"
2229        );
2230        assert!(
2231            warns.iter().any(|w| w.contains("[line].segments is empty")),
2232            "must warn that the fallback finds nothing to render, got: {warns:?}"
2233        );
2234    }
2235
2236    #[test]
2237    fn build_lines_multi_line_drops_non_numeric_keys_with_warning() {
2238        // [line.foo] is structurally valid TOML (parser accepts it)
2239        // but semantically junk for multi-line ordering. Drop with a
2240        // warning rather than silently sorting it somewhere arbitrary.
2241        let cfg = config::Config::from_str(
2242            r#"
2243                layout = "multi-line"
2244                [line.1]
2245                segments = ["model"]
2246                [line.foo]
2247                segments = ["bogus"]
2248                [line.2]
2249                segments = ["workspace"]
2250            "#,
2251        )
2252        .expect("parse");
2253        let (result, warns) = lines_with_warns(Some(&cfg));
2254        assert_eq!(
2255            priorities_per_line(&result),
2256            vec![priorities_for(&["model"]), priorities_for(&["workspace"])]
2257        );
2258        assert!(
2259            warns
2260                .iter()
2261                .any(|w| w.contains("[line.foo]") && w.contains("not a positive integer")),
2262            "expected non-numeric-key warning, got: {warns:?}"
2263        );
2264    }
2265
2266    #[test]
2267    fn build_lines_multi_line_drops_zero_and_negative_keys() {
2268        // Positive integers only: `0` fails the `n > 0` guard and
2269        // `-1` fails the `u32::from_str` parse. Pin both so the
2270        // predicate doesn't drift to "any u32 including 0."
2271        let cfg = config::Config::from_str(
2272            r#"
2273                layout = "multi-line"
2274                [line.0]
2275                segments = ["context_window"]
2276                [line.1]
2277                segments = ["model"]
2278                [line."-1"]
2279                segments = ["cost"]
2280            "#,
2281        )
2282        .expect("parse");
2283        let (result, warns) = lines_with_warns(Some(&cfg));
2284        assert_eq!(
2285            priorities_per_line(&result),
2286            vec![priorities_for(&["model"])]
2287        );
2288        assert!(warns.iter().any(|w| w.contains("[line.0]")));
2289        assert!(warns.iter().any(|w| w.contains("[line.-1]")));
2290    }
2291
2292    #[test]
2293    fn build_lines_multi_line_with_only_invalid_keys_falls_back_to_single_line() {
2294        // If every numbered key is invalid, the validator returns None
2295        // and the multi-line path falls back to single-line rendering
2296        // of [line].segments. Two warnings: one per invalid key, plus
2297        // the "no usable [line.N]" fallback notice.
2298        let cfg = config::Config::from_str(
2299            r#"
2300                layout = "multi-line"
2301                [line]
2302                segments = ["model"]
2303                [line.foo]
2304                segments = ["bogus"]
2305            "#,
2306        )
2307        .expect("parse");
2308        let (result, warns) = lines_with_warns(Some(&cfg));
2309        assert_eq!(
2310            priorities_per_line(&result),
2311            vec![priorities_for(&["model"])]
2312        );
2313        assert!(warns.iter().any(|w| w.contains("[line.foo]")));
2314        assert!(warns.iter().any(|w| w.contains("no usable [line.N]")));
2315    }
2316
2317    #[test]
2318    fn build_lines_multi_line_warns_per_empty_numbered_segments() {
2319        let cfg = config::Config::from_str(
2320            r#"
2321                layout = "multi-line"
2322                [line.1]
2323                segments = ["model"]
2324                [line.2]
2325                segments = []
2326            "#,
2327        )
2328        .expect("parse");
2329        let (result, warns) = lines_with_warns(Some(&cfg));
2330        assert_eq!(
2331            priorities_per_line(&result),
2332            vec![priorities_for(&["model"]), Vec::<u8>::new()]
2333        );
2334        assert!(
2335            warns
2336                .iter()
2337                .any(|w| w.contains("[line.2].segments is empty")),
2338            "expected empty-segments warning for line 2, got: {warns:?}"
2339        );
2340    }
2341
2342    #[test]
2343    fn build_lines_multi_line_ignores_top_level_segments_when_numbered_present() {
2344        // Spec precedence (edge case #3): in multi-line mode, when
2345        // both [line].segments and [line.N] exist, the numbered tables
2346        // win. Single-line callers go through `build_segments`
2347        // directly; build_lines doesn't double-render the fallback.
2348        let cfg = config::Config::from_str(
2349            r#"
2350                layout = "multi-line"
2351                [line]
2352                segments = ["workspace"]
2353                [line.1]
2354                segments = ["model"]
2355            "#,
2356        )
2357        .expect("parse");
2358        let result = lines(Some(&cfg));
2359        assert_eq!(
2360            priorities_per_line(&result),
2361            vec![priorities_for(&["model"])]
2362        );
2363    }
2364
2365    #[test]
2366    fn build_lines_multi_line_dedupes_within_line_but_not_across_lines() {
2367        // The dedup rule (warn + skip) applies within a single line.
2368        // The same built-in id can appear in multiple lines because
2369        // each render call is independent state.
2370        let cfg = config::Config::from_str(
2371            r#"
2372                layout = "multi-line"
2373                [line.1]
2374                segments = ["model", "model", "workspace"]
2375                [line.2]
2376                segments = ["model"]
2377            "#,
2378        )
2379        .expect("parse");
2380        let (result, warns) = lines_with_warns(Some(&cfg));
2381        assert_eq!(
2382            priorities_per_line(&result),
2383            vec![
2384                priorities_for(&["model", "workspace"]),
2385                priorities_for(&["model"]),
2386            ]
2387        );
2388        // Exactly one dedup warning (from line 1), not two.
2389        let dedup_warns: Vec<_> = warns
2390            .iter()
2391            .filter(|w| w.contains("listed more than once"))
2392            .collect();
2393        assert_eq!(
2394            dedup_warns.len(),
2395            1,
2396            "expected one dedup warning, got: {warns:?}"
2397        );
2398    }
2399}