Skip to main content

linesmith_core/segments/
mod.rs

1//! Segment trait and layout-intent types. Full contract lives in
2//! `docs/specs/segment-system.md`; this module carries the subset the
3//! layout engine uses today: visibility, cell width, priority,
4//! separator preference, and theme role.
5
6use crate::data_context::{DataContext, DataDep};
7use crate::theme::{Role, Style};
8use std::borrow::Cow;
9use unicode_width::UnicodeWidthStr;
10
11pub mod agent;
12pub mod builder;
13pub mod context_bar;
14pub mod context_window;
15pub mod cost;
16pub mod effort;
17pub mod extra_usage;
18pub mod extras;
19pub mod git_branch;
20pub mod model;
21pub mod output_style;
22pub mod rate_limit;
23pub mod session_duration;
24pub mod tokens;
25pub mod version;
26pub mod vim;
27pub mod workspace;
28
29/// Output of a successful segment render.
30///
31/// Fields are `pub(crate)` so the engine can read them directly;
32/// external callers go through the constructors and accessors so the
33/// `width == text_width(text)` invariant can't desync via a mutable
34/// `text`. `#[non_exhaustive]` keeps future additions SemVer-safe.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct RenderedSegment {
38    pub(crate) text: String,
39    pub(crate) width: u16,
40    pub(crate) right_separator: Option<Separator>,
41    pub(crate) style: Style,
42}
43
44impl RenderedSegment {
45    /// Build a rendered segment from `text`, auto-computing its cell
46    /// width. Use [`Self::with_separator`] when the segment wants to
47    /// override its default right-separator for this boundary, and
48    /// [`Self::with_role`] / [`Self::with_style`] to attach a theme
49    /// role or full style.
50    #[must_use]
51    pub fn new(text: impl Into<String>) -> Self {
52        let text = sanitize_control_chars(text.into());
53        let width = text_width(&text);
54        Self {
55            text,
56            width,
57            right_separator: None,
58            style: Style::default(),
59        }
60    }
61
62    #[must_use]
63    pub fn with_separator(text: impl Into<String>, separator: Separator) -> Self {
64        let text = sanitize_control_chars(text.into());
65        let width = text_width(&text);
66        Self {
67            text,
68            width,
69            right_separator: Some(separator),
70            style: Style::default(),
71        }
72    }
73
74    /// Chainable setter for the segment's theme role. The layout
75    /// engine resolves the role against the active theme + terminal
76    /// capability at render time; no ANSI bytes land in `text`.
77    ///
78    /// Preserves any decorations previously set by [`Self::with_style`].
79    /// Pair with `with_style` carefully: `.with_style(s).with_role(r)`
80    /// keeps `s`'s bold/fg/etc. and swaps role, whereas
81    /// `.with_role(r).with_style(s)` wholesale-replaces everything.
82    #[must_use]
83    pub fn with_role(mut self, role: Role) -> Self {
84        self.style.role = Some(role);
85        self
86    }
87
88    /// Chainable setter for the full style (role + decorations).
89    /// Wholesale-replaces the current style; use [`Self::with_role`]
90    /// when you want to preserve decorations and swap only the role.
91    #[must_use]
92    pub fn with_style(mut self, style: Style) -> Self {
93        self.style = style;
94        self
95    }
96
97    /// Style this segment wants applied when the layout emits it.
98    #[must_use]
99    pub fn style(&self) -> &Style {
100        &self.style
101    }
102
103    /// The rendered text.
104    #[must_use]
105    pub fn text(&self) -> &str {
106        &self.text
107    }
108
109    /// Cell width of the rendered text.
110    #[must_use]
111    pub fn width(&self) -> u16 {
112        self.width
113    }
114
115    /// Separator this render prefers on its right edge, if any. `None`
116    /// means "fall back to the segment's default separator."
117    #[must_use]
118    pub fn right_separator(&self) -> Option<&Separator> {
119        self.right_separator.as_ref()
120    }
121
122    /// Trusted crate-internal constructor that accepts an explicit
123    /// `width` and `style`. Reserved for [`crate::layout::truncate_to`];
124    /// every other caller goes through [`Self::new`] so the width stays
125    /// a function of the text.
126    #[must_use]
127    pub(crate) fn from_parts(
128        text: String,
129        width: u16,
130        right_separator: Option<Separator>,
131        style: Style,
132    ) -> Self {
133        Self {
134            text,
135            width,
136            right_separator,
137            style,
138        }
139    }
140}
141
142/// Cell count of `s` on a standard terminal, saturating at `u16::MAX`.
143#[must_use]
144pub(crate) fn text_width(s: &str) -> u16 {
145    u16::try_from(UnicodeWidthStr::width(s)).unwrap_or(u16::MAX)
146}
147
148/// Strip Unicode control characters from `s`.
149///
150/// Segment text often comes from untrusted input (a project dir
151/// basename, a worktree name). `UnicodeWidthChar::width` reports
152/// control chars as 0 cells, but terminals interpret them as
153/// cursor-movement, screen-clear, or OSC payloads: a worktree named
154/// `evil\x1b[2J` would blank the terminal on every statusline render.
155/// Stripping at the `RenderedSegment` boundary protects every segment
156/// that funnels user data through it.
157///
158/// Returns the input unchanged when it has no control chars.
159pub(crate) fn sanitize_control_chars(s: String) -> String {
160    if !s.chars().any(char::is_control) {
161        return s;
162    }
163    s.chars().filter(|c| !c.is_control()).collect()
164}
165
166/// Separator between adjacent segments. Chosen by the segment to its
167/// left; themes and user config can override.
168///
169/// `Theme` is reserved for theme-provided padding and renders as a
170/// single space when no theme is configured. `Literal` carries a
171/// `Cow<'static, str>` so built-ins stay zero-alloc while user config
172/// allocates once. `Powerline { width }` emits the Nerd Font
173/// right-arrow chevron (U+E0B0) flanked by single-space padding;
174/// `width` is the chevron's own cell count (1 or 2 — see
175/// `[layout_options].powerline_width`), and the reported [`width()`]
176/// includes the 2 padding cells. Chevron styling lives in
177/// [`crate::layout::separator_style`].
178///
179/// [`width()`]: Separator::width
180#[derive(Debug, Clone, PartialEq, Eq)]
181#[non_exhaustive]
182pub enum Separator {
183    Space,
184    Theme,
185    Literal(Cow<'static, str>),
186    Powerline { width: PowerlineWidth },
187    None,
188}
189
190/// Cell-count for the Nerd Font powerline chevron (U+E0B0). Most
191/// modern fonts at standard sizes render the chevron as a single cell;
192/// some larger sizes / older Nerd Font builds render it as two. The
193/// type makes any other value unrepresentable so layout-width math
194/// can't drift into invalid territory.
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
196pub enum PowerlineWidth {
197    #[default]
198    One,
199    Two,
200}
201
202impl PowerlineWidth {
203    /// Cell count this width represents (1 or 2).
204    #[must_use]
205    pub const fn cells(self) -> u16 {
206        match self {
207            Self::One => 1,
208            Self::Two => 2,
209        }
210    }
211}
212
213/// Nerd Font right-arrow chevron (U+E0B0) with single-space padding
214/// on each side.
215const POWERLINE_CHEVRON_PADDED: &str = " \u{E0B0} ";
216
217impl Separator {
218    /// Default 1-cell powerline chevron. Use this for the common case
219    /// (most modern Nerd Fonts render U+E0B0 as 1 cell at standard
220    /// sizes); pass `Powerline { width: PowerlineWidth::Two }` for
221    /// fonts/sizes that render 2 cells.
222    #[must_use]
223    pub const fn powerline() -> Self {
224        Self::Powerline {
225            width: PowerlineWidth::One,
226        }
227    }
228
229    #[must_use]
230    pub fn text(&self) -> &str {
231        match self {
232            Self::Space | Self::Theme => " ",
233            Self::Literal(s) => s,
234            Self::Powerline { .. } => POWERLINE_CHEVRON_PADDED,
235            Self::None => "",
236        }
237    }
238
239    #[must_use]
240    pub fn width(&self) -> u16 {
241        match self {
242            Self::Space | Self::Theme => 1,
243            Self::Literal(s) => text_width(s),
244            Self::Powerline { width } => width.cells() + 2,
245            Self::None => 0,
246        }
247    }
248}
249
250/// Width bounds in cells with `min <= max` enforced at construction.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub struct WidthBounds {
253    min: u16,
254    max: u16,
255}
256
257impl WidthBounds {
258    /// Returns `None` when `min > max`.
259    #[must_use]
260    pub fn new(min: u16, max: u16) -> Option<Self> {
261        (min <= max).then_some(Self { min, max })
262    }
263
264    #[must_use]
265    pub fn min(self) -> u16 {
266        self.min
267    }
268
269    #[must_use]
270    pub fn max(self) -> u16 {
271        self.max
272    }
273}
274
275/// Layout intent declared by a segment; user config may override each
276/// field.
277///
278/// Under width pressure the engine drops segments in descending
279/// `priority` order: `255` drops first, `0` never drops. Default `128`.
280/// Ties break by position: the right-most segment drops first.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282#[non_exhaustive]
283pub struct SegmentDefaults {
284    pub priority: u8,
285    pub width: Option<WidthBounds>,
286    /// May the layout engine shrink this segment under width pressure
287    /// before dropping it? Default `false` — only prose-like segments
288    /// (workspace name, branch name) opt in. Numeric or structured
289    /// segments leave this `false`: a half-cut percentage reads as
290    /// the wrong number, which is worse than no number.
291    /// See `docs/specs/segment-system.md` §Layout algorithm.
292    pub truncatable: bool,
293}
294
295impl SegmentDefaults {
296    /// Constructor shorthand for the common case of "default layout
297    /// intent with a specific priority." Chainable with
298    /// [`Self::with_width`] and [`Self::with_truncatable`].
299    #[must_use]
300    pub fn with_priority(priority: u8) -> Self {
301        Self {
302            priority,
303            ..Self::default()
304        }
305    }
306
307    /// Chainable setter for width bounds.
308    #[must_use]
309    pub fn with_width(mut self, bounds: WidthBounds) -> Self {
310        self.width = Some(bounds);
311        self
312    }
313
314    /// Chainable setter for the truncate-before-drop opt-in.
315    #[must_use]
316    pub fn with_truncatable(mut self, truncatable: bool) -> Self {
317        self.truncatable = truncatable;
318        self
319    }
320}
321
322impl Default for SegmentDefaults {
323    fn default() -> Self {
324        Self {
325            priority: 128,
326            width: None,
327            truncatable: false,
328        }
329    }
330}
331
332/// Shorthand for [`Segment::render`]'s return type.
333///
334/// Three states:
335/// - `Ok(Some(r))`: the segment renders `r`.
336/// - `Ok(None)`: the segment has no content this invocation and should
337///   be hidden (intentional, e.g. rate-limit segment on an API-tier
338///   user).
339/// - `Err(e)`: the segment attempted to render but failed. The layout
340///   engine logs `e` to stderr and hides the segment — same visual
341///   result as `Ok(None)`, but the diagnostic distinguishes failure
342///   from intentional absence.
343pub type RenderResult = Result<Option<RenderedSegment>, SegmentError>;
344
345/// Runtime failure from a segment's [`Segment::render`]. Built-in
346/// segments return `Ok(...)` today; this surface is primarily for
347/// plugin-authored segments (rhai script errors, unexpected input,
348/// propagated I/O).
349#[derive(Debug)]
350#[non_exhaustive]
351pub struct SegmentError {
352    pub message: String,
353    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
354}
355
356impl SegmentError {
357    #[must_use]
358    pub fn new(message: impl Into<String>) -> Self {
359        Self {
360            message: message.into(),
361            source: None,
362        }
363    }
364
365    #[must_use]
366    pub fn with_source(
367        message: impl Into<String>,
368        source: Box<dyn std::error::Error + Send + Sync>,
369    ) -> Self {
370        Self {
371            message: message.into(),
372            source: Some(source),
373        }
374    }
375}
376
377impl std::fmt::Display for SegmentError {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        f.write_str(&self.message)?;
380        if let Some(src) = &self.source {
381            write!(f, ": {src}")?;
382        }
383        Ok(())
384    }
385}
386
387impl std::error::Error for SegmentError {
388    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
389        self.source.as_deref().map(|e| e as &dyn std::error::Error)
390    }
391}
392
393/// Per-render layout state the engine builds once per call and threads
394/// into every [`Segment::render`]. Distinct from [`DataContext`], which
395/// is the data layer (one instance per process invocation, shared
396/// across segments). `RenderContext` is the layout layer: terminal
397/// width today, room for line index / capability / neighbor info as
398/// dynamic-segment work lands.
399///
400/// `#[non_exhaustive]` keeps future additions SemVer-safe; segments
401/// that don't read the field accept it as `_rc: &RenderContext` and
402/// pay nothing.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404#[non_exhaustive]
405pub struct RenderContext {
406    /// Total cells available to this line. Sourced from the terminal,
407    /// or the schema-defined fallback (200) when stdout is detached.
408    pub terminal_width: u16,
409}
410
411impl RenderContext {
412    #[must_use]
413    pub fn new(terminal_width: u16) -> Self {
414        Self { terminal_width }
415    }
416}
417
418pub trait Segment: Send {
419    /// Render this segment for the given context.
420    ///
421    /// Returns `Ok(None)` to hide, `Ok(Some(_))` to render, or `Err` on
422    /// a runtime failure that the layout engine logs and treats as
423    /// hidden. See [`RenderResult`]. `ctx` owns the parsed stdin
424    /// payload (`ctx.status`) plus lazy accessors for other sources
425    /// (`ctx.usage()`, `ctx.git()`, etc.) declared in
426    /// [`data_deps`](Self::data_deps). `rc` is per-render layout
427    /// state — terminal width today — for segments that pick their
428    /// own shape based on available room.
429    fn render(&self, ctx: &DataContext, rc: &RenderContext) -> RenderResult;
430
431    /// Layout-pressure-aware compaction hook. The reflow loop calls
432    /// this on any segment under width pressure (truncatable or
433    /// not), asking whether it can produce a render at most `target`
434    /// cells wide. It runs before `truncatable` end-ellipsis
435    /// truncation, so segment-side intelligence beats generic
436    /// string clipping when both apply. Default returns `None` (no
437    /// compact form available; engine falls through to truncatable
438    /// or drop). Segments with structured tail content override to
439    /// shed decoration while keeping the signal-bearing prefix.
440    ///
441    /// The returned render must lie in `[width.min, target]` cells:
442    /// wider violates the layout-fit invariant (engine rejects and
443    /// warns), narrower violates the user's `width.min` contract
444    /// (engine rejects silently, same as `apply_width_bounds`).
445    /// Implementations should return `None` rather than emit a
446    /// render outside this range.
447    ///
448    /// See `docs/specs/segment-system.md` §Layout algorithm for
449    /// the reflow loop's full ordering and target derivation.
450    #[allow(unused_variables)]
451    #[must_use]
452    fn shrink_to_fit(
453        &self,
454        ctx: &DataContext,
455        rc: &RenderContext,
456        target: u16,
457    ) -> Option<RenderedSegment> {
458        None
459    }
460
461    /// Declare which data sources this segment reads. The runtime
462    /// computes the union across all enabled segments and lazy-fetches
463    /// only those sources. Defaults to the stdin payload only; segments
464    /// that read other sources must override. See
465    /// `docs/specs/data-fetching.md` §Segment dependency declaration.
466    ///
467    /// The `&'static` lifetime is deliberate: built-in segments return
468    /// a `const &[DataDep]` at zero cost, and runtime-loaded plugin
469    /// segments (e.g. `RhaiSegment`) promote their parsed
470    /// `Vec<DataDep>` via `Vec::leak` once at plugin-load time. The
471    /// plugin registry is built once per process and lives until exit,
472    /// so the leak is bounded. If plugin hot-reload arrives (deferred
473    /// feature), swap to an arena allocator or `Arc<[DataDep]>`.
474    #[must_use]
475    fn data_deps(&self) -> &'static [DataDep] {
476        &[DataDep::Status]
477    }
478
479    /// Layout defaults (priority, width bounds, truncatable opt-in).
480    /// User config may override each field via [`OverriddenSegment`].
481    /// Implementations must be O(1), do no I/O, and avoid allocation:
482    /// the layout engine snapshots this at collect time and the
483    /// [`LineItem::Debug`] impl reads it for `dbg!` / panic-backtrace
484    /// formatting.
485    #[must_use]
486    fn defaults(&self) -> SegmentDefaults {
487        SegmentDefaults::default()
488    }
489}
490
491// --- Built-in registry + config-driven override wrapper ----------------
492
493/// Default segment order when no config supplies one. No rate-limit
494/// segments are in the default line: a first-run user without any
495/// config shouldn't trigger a macOS Keychain prompt or a network
496/// request just to render the statusline. Users opt in by listing
497/// the rate-limit segments explicitly in `[line.segments]`.
498pub const DEFAULT_SEGMENT_IDS: &[&str] = &[
499    "model",
500    "context_window",
501    "cost",
502    "effort",
503    "git_branch",
504    "workspace",
505];
506
507/// Every built-in segment id. Used by [`PluginRegistry`] to reject
508/// plugins whose `const ID` shadows a built-in. Add new built-ins
509/// here AND to [`built_in_by_id`].
510///
511/// Also used by `resolve_segment_id` (O(n) per segment at build time,
512/// not render time) to pin built-in ids to `Cow::Borrowed` per ADR-0026.
513/// If the list grows past ~50 entries, swap to a `phf::Set`.
514///
515/// [`PluginRegistry`]: linesmith_plugin::PluginRegistry
516pub const BUILT_IN_SEGMENT_IDS: &[&str] = &[
517    "model",
518    "context_window",
519    "context_bar",
520    "workspace",
521    "cost",
522    "effort",
523    "output_style",
524    "vim",
525    "agent",
526    "git_branch",
527    "rate_limit_5h",
528    "rate_limit_7d",
529    "rate_limit_5h_reset",
530    "rate_limit_7d_reset",
531    "extra_usage",
532    "session_duration",
533    "tokens_input",
534    "tokens_output",
535    "tokens_cached",
536    "tokens_total",
537    "version",
538];
539
540/// Construct a built-in segment by its config id. Unknown ids return
541/// `None` so config loaders can warn and skip. `extras` carries the
542/// `[segments.<id>]` TOML bag; rate-limit segments parse their knobs
543/// from it (`format`, `invert`, `compact`, `use_days`, `icon`,
544/// `label`, `stale_marker`, `progress_width`). Other built-ins
545/// currently ignore `extras`.
546///
547/// Every arm in this `match` must have a corresponding entry in
548/// [`BUILT_IN_SEGMENT_IDS`] and vice versa. The forward direction is
549/// covered by `built_in_by_id_resolves_every_id_in_built_in_segment_ids`;
550/// a match arm missing from the const would silently let a plugin shadow
551/// the built-in and degrade its `Cow::Borrowed` short-circuit to
552/// `Cow::Owned`. Add new built-ins to both lists together.
553#[must_use]
554pub fn built_in_by_id(
555    id: &str,
556    extras: Option<&std::collections::BTreeMap<String, toml::Value>>,
557    warn: &mut impl FnMut(&str),
558) -> Option<Box<dyn Segment>> {
559    let empty: std::collections::BTreeMap<String, toml::Value> = std::collections::BTreeMap::new();
560    let e = extras.unwrap_or(&empty);
561    match id {
562        "model" => Some(Box::new(model::ModelSegment::from_extras(e, warn))),
563        "context_window" => Some(Box::new(context_window::ContextWindowSegment)),
564        "context_bar" => Some(Box::new(context_bar::ContextBarSegment::from_extras(
565            e, warn,
566        ))),
567        "workspace" => Some(Box::new(workspace::WorkspaceSegment)),
568        "cost" => Some(Box::new(cost::CostSegment)),
569        "effort" => Some(Box::new(effort::EffortSegment)),
570        "output_style" => Some(Box::new(output_style::OutputStyleSegment)),
571        "vim" => Some(Box::new(vim::VimSegment)),
572        "agent" => Some(Box::new(agent::AgentSegment)),
573        "git_branch" => Some(Box::new(git_branch::GitBranchSegment::from_extras(e, warn))),
574        "rate_limit_5h" => Some(Box::new(
575            rate_limit::five_hour::RateLimit5hSegment::from_extras(e, warn),
576        )),
577        "rate_limit_7d" => Some(Box::new(
578            rate_limit::seven_day::RateLimit7dSegment::from_extras(e, warn),
579        )),
580        "rate_limit_5h_reset" => Some(Box::new(
581            rate_limit::five_hour::RateLimit5hResetSegment::from_extras(e, warn),
582        )),
583        "rate_limit_7d_reset" => Some(Box::new(
584            rate_limit::seven_day::RateLimit7dResetSegment::from_extras(e, warn),
585        )),
586        "extra_usage" => Some(Box::new(extra_usage::ExtraUsageSegment::from_extras(
587            e, warn,
588        ))),
589        "session_duration" => Some(Box::new(session_duration::SessionDurationSegment)),
590        "tokens_input" => Some(Box::new(tokens::TokensInputSegment)),
591        "tokens_output" => Some(Box::new(tokens::TokensOutputSegment)),
592        "tokens_cached" => Some(Box::new(tokens::TokensCachedSegment)),
593        "tokens_total" => Some(Box::new(tokens::TokensTotalSegment)),
594        "version" => Some(Box::new(version::VersionSegment::from_extras(e, warn))),
595        _ => None,
596    }
597}
598
599/// Wraps a `Segment` to override its `defaults()` output while
600/// delegating `render` unchanged. Applying `[segments.<id>]` overrides
601/// without touching the inner segment.
602pub struct OverriddenSegment {
603    inner: Box<dyn Segment>,
604    priority: Option<u8>,
605    width: Option<WidthBounds>,
606    user_style: Option<Style>,
607}
608
609impl OverriddenSegment {
610    #[must_use]
611    pub fn new(inner: Box<dyn Segment>) -> Self {
612        Self {
613            inner,
614            priority: None,
615            width: None,
616            user_style: None,
617        }
618    }
619
620    #[must_use]
621    pub fn with_priority(mut self, priority: u8) -> Self {
622        self.priority = Some(priority);
623        self
624    }
625
626    #[must_use]
627    pub fn with_width(mut self, bounds: WidthBounds) -> Self {
628        self.width = Some(bounds);
629        self
630    }
631
632    /// Wholesale-replaces the inner segment's declared style at render
633    /// time. See `docs/specs/theming.md` §Resolution precedence.
634    #[must_use]
635    pub fn with_user_style(mut self, style: Style) -> Self {
636        self.user_style = Some(style);
637        self
638    }
639}
640
641impl Segment for OverriddenSegment {
642    fn render(&self, ctx: &DataContext, rc: &RenderContext) -> RenderResult {
643        let result = self.inner.render(ctx, rc)?;
644        Ok(result.map(|r| match &self.user_style {
645            Some(override_style) => {
646                let merged = merge_user_override(r.style(), override_style);
647                r.with_style(merged)
648            }
649            None => r,
650        }))
651    }
652
653    fn shrink_to_fit(
654        &self,
655        ctx: &DataContext,
656        rc: &RenderContext,
657        target: u16,
658    ) -> Option<RenderedSegment> {
659        let inner = self.inner.shrink_to_fit(ctx, rc, target)?;
660        Some(match &self.user_style {
661            Some(override_style) => {
662                let merged = merge_user_override(inner.style(), override_style);
663                inner.with_style(merged)
664            }
665            None => inner,
666        })
667    }
668
669    fn data_deps(&self) -> &'static [DataDep] {
670        self.inner.data_deps()
671    }
672
673    fn defaults(&self) -> SegmentDefaults {
674        let mut d = self.inner.defaults();
675        if let Some(p) = self.priority {
676            d.priority = p;
677        }
678        if let Some(w) = self.width {
679            d.width = Some(w);
680        }
681        d
682    }
683}
684
685/// Merge a user-config style override onto the inner segment's style.
686/// Visual fields (role, fg, bold, italic, underline, dim) take the
687/// override's value — that's the documented "user wholesale-replaces
688/// segment styling" behavior. `hyperlink` is the exception: it carries
689/// segment behavior (the link target) rather than appearance, and the
690/// user-style TOML syntax doesn't expose a way to set it, so the
691/// override always arrives with `hyperlink: None`. Inheriting the
692/// inner segment's hyperlink keeps `[segments.X] color = "red"` from
693/// silently stripping links the segment emits.
694fn merge_user_override(inner: &Style, override_style: &Style) -> Style {
695    let mut merged = override_style.clone();
696    if merged.hyperlink.is_none() {
697        merged.hyperlink = inner.hyperlink.clone();
698    }
699    merged
700}
701
702/// One slot in a line layout: a configured segment or an inline
703/// separator between segments. The builder (`build_segments` /
704/// `build_lines`) interleaves separators between adjacent segments
705/// from `[layout_options].separator`; the renderer walks this list
706/// directly. See `docs/specs/segment-system.md` §Data model.
707///
708/// A plugin's per-render override ([`RenderedSegment::with_separator`])
709/// beats the inline `Separator` only when an inline-separator slot
710/// exists immediately to the segment's right. An override on the
711/// rightmost segment, or a segment whose right-neighbor separator
712/// has already been pruned, has no boundary to apply to and is
713/// silently discarded.
714///
715/// Per-variant `#[non_exhaustive]` is omitted from `LineItem::Segment`
716/// because consumers pattern-match `{ id, segment }` directly and the
717/// consumer set is narrow (builder + tests + benches). Contrast
718/// `LayoutDecision`'s per-variant `#[non_exhaustive]` (ADR-0026 §C):
719/// those events are observability surfaces with an unknown consumer set,
720/// so field-additive forward-compat justifies the `, ..` pattern cost.
721#[non_exhaustive]
722pub enum LineItem {
723    /// A segment paired with the user-facing config id that names it
724    /// (per ADR-0026). Sourced from `LineEntry::segment_id()` (the TOML key).
725    ///
726    /// `id` is a label, not an identity: the layout engine threads it
727    /// through `LayoutDecision` events but does not verify it against the
728    /// inner segment's type. External constructors must keep the two in sync.
729    ///
730    /// `Cow::Borrowed` vs `Cow::Owned` is a per-emit allocation trade-off,
731    /// not a correctness invariant. Built-in ids land as `Cow::Borrowed`;
732    /// plugin and user-config ids land as `Cow::Owned`. External
733    /// constructors that don't preserve this partition are correct but pay
734    /// one extra allocation per built-in emit.
735    Segment {
736        id: std::borrow::Cow<'static, str>,
737        segment: Box<dyn Segment>,
738    },
739    Separator(Separator),
740}
741
742impl std::fmt::Debug for LineItem {
743    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
744        match self {
745            // The trait has no `Debug` bound, so surface the id +
746            // layout intent (priority, width hints) — that's what's
747            // load-bearing in panic dumps and `dbg!` output anyway.
748            Self::Segment { id, segment } => f
749                .debug_struct("Segment")
750                .field("id", id)
751                .field("defaults", &segment.defaults())
752                .finish(),
753            Self::Separator(sep) => f.debug_tuple("Separator").field(sep).finish(),
754        }
755    }
756}
757
758#[cfg(test)]
759mod layout_type_tests {
760    use super::*;
761
762    #[test]
763    fn rendered_segment_computes_width() {
764        let r = RenderedSegment::new("hello");
765        assert_eq!(r.text(), "hello");
766        assert_eq!(r.width(), 5);
767        assert_eq!(r.right_separator(), None);
768    }
769
770    #[test]
771    fn rendered_segment_counts_cells_not_bytes_for_middle_dot() {
772        // U+00B7 MIDDLE DOT is 2 bytes but 1 cell.
773        let r = RenderedSegment::new("42% · 200k");
774        assert_eq!(r.width(), 10);
775    }
776
777    #[test]
778    fn rendered_segment_strips_csi_clear_screen_injection() {
779        // \x1b[2J clears the screen if it reaches stdout.
780        let r = RenderedSegment::new("evil\x1b[2J");
781        assert_eq!(r.text(), "evil[2J");
782        assert_eq!(r.width(), 7);
783        assert!(!r.text().contains('\x1b'));
784    }
785
786    #[test]
787    fn rendered_segment_strips_osc_set_title_with_bel_terminator() {
788        // OSC 0 sets the terminal title; BEL (\x07) terminates it.
789        // Both entry/exit bytes are controls and must drop out.
790        let r = RenderedSegment::new("\x1b]0;pwn\x07rest");
791        assert_eq!(r.text(), "]0;pwnrest");
792        assert!(!r.text().contains('\x1b'));
793        assert!(!r.text().contains('\x07'));
794    }
795
796    #[test]
797    fn rendered_segment_strips_common_c0_controls() {
798        let r = RenderedSegment::new("a\x07b\x08c\td\ne\rf");
799        assert_eq!(r.text(), "abcdef");
800        assert_eq!(r.width(), 6);
801    }
802
803    #[test]
804    fn rendered_segment_strips_c1_controls_and_del() {
805        let r = RenderedSegment::new("x\u{007F}y\u{0085}z\u{009B}");
806        assert_eq!(r.text(), "xyz");
807        assert_eq!(r.width(), 3);
808    }
809
810    #[test]
811    fn rendered_segment_preserves_unicode_without_controls() {
812        let r = RenderedSegment::new("café · 日本語");
813        assert_eq!(r.text(), "café · 日本語");
814    }
815
816    #[test]
817    fn rendered_segment_empty_string_stays_empty() {
818        let r = RenderedSegment::new("");
819        assert_eq!(r.text(), "");
820        assert_eq!(r.width(), 0);
821    }
822
823    #[test]
824    fn rendered_segment_all_control_input_collapses_to_empty() {
825        // Downstream layout math must cope with zero-width non-None
826        // renders; the `width == text_width(text)` invariant still holds.
827        let r = RenderedSegment::new("\x1b\x07\n\t");
828        assert_eq!(r.text(), "");
829        assert_eq!(r.width(), 0);
830    }
831
832    #[test]
833    fn rendered_segment_with_separator_also_strips_controls() {
834        let r = RenderedSegment::with_separator("hi\x1bthere", Separator::None);
835        assert_eq!(r.text(), "hithere");
836        assert_eq!(r.width(), 7);
837    }
838
839    #[test]
840    fn rendered_segment_with_separator_exposes_override() {
841        let r = RenderedSegment::with_separator("x", Separator::None);
842        assert_eq!(r.right_separator(), Some(&Separator::None));
843    }
844
845    #[test]
846    fn separator_widths_match_expected() {
847        assert_eq!(Separator::Space.width(), 1);
848        assert_eq!(Separator::Theme.width(), 1);
849        assert_eq!(Separator::None.width(), 0);
850        assert_eq!(Separator::Literal(Cow::Borrowed(" | ")).width(), 3);
851        // Powerline is configurable: width 1 (Nerd Font default) or
852        // width 2 (some fonts/sizes render the chevron as 2 cells).
853        // The reported width adds 2 cells of padding (one space on
854        // each side of the chevron) since `text()` emits " ▶ ".
855        assert_eq!(Separator::powerline().width(), 3);
856        assert_eq!(
857            Separator::Powerline {
858                width: PowerlineWidth::Two,
859            }
860            .width(),
861            4
862        );
863    }
864
865    #[test]
866    fn width_bounds_rejects_inverted_range() {
867        assert!(WidthBounds::new(20, 10).is_none());
868        assert!(WidthBounds::new(10, 10).is_some());
869        assert!(WidthBounds::new(0, u16::MAX).is_some());
870    }
871
872    #[test]
873    fn line_item_debug_renders_each_variant() {
874        // The hand-written `Debug` impl on `LineItem` exists because
875        // `Box<dyn Segment>` blocks `derive(Debug)`. Pin that both
876        // variants format without panicking and that the variant
877        // tag + id are visible in the output so panic backtraces
878        // and `dbg!` calls identify the slot.
879        struct StubSeg;
880        impl Segment for StubSeg {
881            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
882                Ok(None)
883            }
884        }
885        let seg = LineItem::Segment {
886            id: std::borrow::Cow::Borrowed("stub"),
887            segment: Box::new(StubSeg),
888        };
889        let sep = LineItem::Separator(Separator::powerline());
890        let seg_dbg = format!("{seg:?}");
891        let sep_dbg = format!("{sep:?}");
892        assert!(seg_dbg.starts_with("Segment {"), "got {seg_dbg:?}");
893        assert!(sep_dbg.starts_with("Separator("), "got {sep_dbg:?}");
894        // The Segment-variant body surfaces id + defaults so panic
895        // dumps carry the slot name + priority/width context.
896        // Field-named `id:` + `defaults:` defend against a regression
897        // that renames either field while preserving the body content.
898        assert!(seg_dbg.contains("id:"), "got {seg_dbg:?}");
899        assert!(seg_dbg.contains("defaults:"), "got {seg_dbg:?}");
900        assert!(seg_dbg.contains("stub"), "got {seg_dbg:?}");
901        assert!(seg_dbg.contains("priority"), "got {seg_dbg:?}");
902    }
903
904    #[test]
905    fn segment_defaults_default_priority_is_128() {
906        assert_eq!(SegmentDefaults::default().priority, 128);
907    }
908
909    #[test]
910    fn with_priority_preserves_other_defaults() {
911        let d = SegmentDefaults::with_priority(64);
912        assert_eq!(d.priority, 64);
913        assert_eq!(d.width, None);
914        assert!(!d.truncatable);
915    }
916
917    #[test]
918    fn builders_chain_on_segment_defaults() {
919        let bounds = WidthBounds::new(4, 40).expect("valid bounds");
920        let d = SegmentDefaults::with_priority(32)
921            .with_width(bounds)
922            .with_truncatable(true);
923        assert_eq!(d.priority, 32);
924        assert_eq!(d.width, Some(bounds));
925        assert!(d.truncatable);
926    }
927
928    #[test]
929    fn segment_error_display_includes_message_only_without_source() {
930        let err = SegmentError::new("missing rate_limits field");
931        assert_eq!(err.to_string(), "missing rate_limits field");
932    }
933
934    #[test]
935    fn segment_error_display_chains_source() {
936        let src = std::io::Error::new(std::io::ErrorKind::NotFound, "cache.json");
937        let err = SegmentError::with_source("cache read failed", Box::new(src));
938        let rendered = err.to_string();
939        assert!(rendered.starts_with("cache read failed: "));
940        assert!(rendered.contains("cache.json"));
941    }
942
943    #[test]
944    fn segment_error_source_chain_is_walkable() {
945        use std::error::Error;
946        let src = std::io::Error::other("inner");
947        let err = SegmentError::with_source("outer", Box::new(src));
948        let source = err.source().expect("source present");
949        assert_eq!(source.to_string(), "inner");
950    }
951
952    // --- registry ---
953
954    #[test]
955    fn built_in_by_id_resolves_every_default_segment() {
956        for id in DEFAULT_SEGMENT_IDS {
957            assert!(
958                built_in_by_id(id, None, &mut |_| {}).is_some(),
959                "expected built-in registry to know {id}"
960            );
961        }
962    }
963
964    #[test]
965    fn built_in_by_id_resolves_additional_documented_ids() {
966        for id in [
967            "context_bar",
968            "session_duration",
969            "rate_limit_5h",
970            "rate_limit_7d",
971            "rate_limit_5h_reset",
972            "rate_limit_7d_reset",
973            "extra_usage",
974            "tokens_input",
975            "tokens_output",
976            "tokens_cached",
977            "tokens_total",
978            "output_style",
979            "vim",
980            "agent",
981        ] {
982            assert!(
983                built_in_by_id(id, None, &mut |_| {}).is_some(),
984                "expected {id} to resolve"
985            );
986        }
987    }
988
989    #[test]
990    fn built_in_by_id_resolves_every_id_in_built_in_segment_ids() {
991        // Anchors the contract documented at `BUILT_IN_SEGMENT_IDS`:
992        // every id in the const must round-trip through the registry.
993        // Catches drift between the const and the match arms.
994        for id in BUILT_IN_SEGMENT_IDS {
995            assert!(
996                built_in_by_id(id, None, &mut |_| {}).is_some(),
997                "BUILT_IN_SEGMENT_IDS lists {id} but built_in_by_id can't construct it"
998            );
999        }
1000    }
1001
1002    #[test]
1003    fn built_in_by_id_rejects_unknown() {
1004        assert!(built_in_by_id("nope", None, &mut |_| {}).is_none());
1005        assert!(built_in_by_id("", None, &mut |_| {}).is_none());
1006    }
1007
1008    #[test]
1009    fn built_in_by_id_threads_extras_to_version_segment() {
1010        // Pin the registry → from_extras wiring for `version`. A
1011        // future refactor that drops `from_extras` and constructs
1012        // `VersionSegment::default()` would silently break user
1013        // configs that set `[segments.version].prefix = "CC "`.
1014        use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
1015        use std::collections::BTreeMap;
1016        use std::path::PathBuf;
1017        use std::sync::Arc;
1018
1019        let mut extras = BTreeMap::new();
1020        extras.insert("prefix".to_string(), toml::Value::String("CC ".to_string()));
1021        let seg = built_in_by_id("version", Some(&extras), &mut |_| {})
1022            .expect("version segment resolves");
1023
1024        let ctx = DataContext::new(StatusContext {
1025            tool: Tool::ClaudeCode,
1026            model: Some(ModelInfo {
1027                display_name: "X".into(),
1028            }),
1029            workspace: Some(WorkspaceInfo {
1030                project_dir: PathBuf::from("/r"),
1031                git_worktree: None,
1032            }),
1033            context_window: None,
1034            cost: None,
1035            effort: None,
1036            vim: None,
1037            output_style: None,
1038            agent_name: None,
1039            version: Some("2.1.90".into()),
1040            raw: Arc::new(serde_json::Value::Null),
1041        });
1042        let rc = RenderContext::new(80);
1043        let rendered = seg.render(&ctx, &rc).unwrap().expect("renders");
1044        assert_eq!(rendered.text(), "CC 2.1.90");
1045    }
1046
1047    // --- OverriddenSegment ---
1048
1049    #[test]
1050    fn overridden_segment_replaces_priority() {
1051        let base = built_in_by_id("workspace", None, &mut |_| {}).expect("known id");
1052        let base_priority = base.defaults().priority;
1053        let wrapped = OverriddenSegment::new(base).with_priority(200);
1054        assert_eq!(wrapped.defaults().priority, 200);
1055        assert_ne!(wrapped.defaults().priority, base_priority);
1056    }
1057
1058    #[test]
1059    fn overridden_segment_replaces_width_bounds() {
1060        let base = built_in_by_id("workspace", None, &mut |_| {}).expect("known id");
1061        assert_eq!(base.defaults().width, None);
1062        let bounds = WidthBounds::new(5, 40).expect("valid");
1063        let wrapped = OverriddenSegment::new(base).with_width(bounds);
1064        assert_eq!(wrapped.defaults().width, Some(bounds));
1065    }
1066
1067    #[test]
1068    fn overridden_segment_delegates_render_to_inner() {
1069        let wrapped =
1070            OverriddenSegment::new(built_in_by_id("workspace", None, &mut |_| {}).unwrap())
1071                .with_priority(0);
1072        let rendered = wrapped
1073            .render(&stub_ctx(), &stub_rc())
1074            .unwrap()
1075            .expect("rendered");
1076        assert_eq!(rendered.text(), "linesmith");
1077    }
1078
1079    #[test]
1080    fn style_override_wholesale_replaces_inner_declared_style() {
1081        // A stub that declares Role::Accent + bold at render time. The
1082        // override must wipe both, not merge with them.
1083        struct Styled;
1084        impl Segment for Styled {
1085            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1086                Ok(Some(
1087                    RenderedSegment::new("x")
1088                        .with_role(Role::Accent)
1089                        .with_style(Style {
1090                            bold: true,
1091                            ..Style::default()
1092                        }),
1093                ))
1094            }
1095            fn defaults(&self) -> SegmentDefaults {
1096                SegmentDefaults::with_priority(0)
1097            }
1098        }
1099        let override_style = Style {
1100            role: Some(Role::Primary),
1101            italic: true,
1102            ..Style::default()
1103        };
1104        let wrapped =
1105            OverriddenSegment::new(Box::new(Styled)).with_user_style(override_style.clone());
1106        let rendered = wrapped
1107            .render(&stub_ctx(), &stub_rc())
1108            .unwrap()
1109            .expect("rendered");
1110        assert_eq!(rendered.style, override_style);
1111    }
1112
1113    #[test]
1114    fn user_style_override_preserves_inner_hyperlink() {
1115        // Pin the merge contract: visual override fields wholesale-
1116        // replace, but the inner segment's hyperlink survives so a
1117        // user `[segments.X] color = "red"` doesn't silently strip
1118        // links the segment emits. The user-style TOML syntax has no
1119        // hyperlink slot today, so the override's hyperlink is
1120        // always None — inheriting from the inner is lossless.
1121        struct Linked;
1122        impl Segment for Linked {
1123            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1124                Ok(Some(RenderedSegment::new("x").with_style(
1125                    Style::default().with_hyperlink("https://example.com"),
1126                )))
1127            }
1128            fn defaults(&self) -> SegmentDefaults {
1129                SegmentDefaults::with_priority(0)
1130            }
1131        }
1132        let override_style = Style::role(Role::Error);
1133        let wrapped =
1134            OverriddenSegment::new(Box::new(Linked)).with_user_style(override_style.clone());
1135        let rendered = wrapped
1136            .render(&stub_ctx(), &stub_rc())
1137            .unwrap()
1138            .expect("rendered");
1139        assert_eq!(rendered.style.role, Some(Role::Error));
1140        assert_eq!(
1141            rendered.style.hyperlink.as_deref(),
1142            Some("https://example.com"),
1143        );
1144    }
1145
1146    #[test]
1147    fn style_override_preserves_inner_none_return() {
1148        struct Hidden;
1149        impl Segment for Hidden {
1150            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1151                Ok(None)
1152            }
1153            fn defaults(&self) -> SegmentDefaults {
1154                SegmentDefaults::with_priority(0)
1155            }
1156        }
1157        let wrapped =
1158            OverriddenSegment::new(Box::new(Hidden)).with_user_style(Style::role(Role::Primary));
1159        assert_eq!(wrapped.render(&stub_ctx(), &stub_rc()).unwrap(), None);
1160    }
1161
1162    #[test]
1163    fn shrink_to_fit_passthrough_reaches_inner_with_user_style_applied() {
1164        // The OverriddenSegment wrapper must forward shrink_to_fit to
1165        // the inner segment so user-overridden segments retain their
1166        // layout-pressure compaction. The wrapper also has to apply
1167        // user_style to the shrunk render the same way it does on
1168        // render — otherwise a styled override loses its theme on the
1169        // compact path.
1170        struct Shrinkable;
1171        impl Segment for Shrinkable {
1172            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1173                Ok(Some(RenderedSegment::new("full")))
1174            }
1175            fn shrink_to_fit(
1176                &self,
1177                _: &DataContext,
1178                _: &RenderContext,
1179                target: u16,
1180            ) -> Option<RenderedSegment> {
1181                let r = RenderedSegment::new("c");
1182                (r.width <= target).then_some(r)
1183            }
1184        }
1185        let override_style = Style {
1186            role: Some(Role::Primary),
1187            italic: true,
1188            ..Style::default()
1189        };
1190        let wrapped =
1191            OverriddenSegment::new(Box::new(Shrinkable)).with_user_style(override_style.clone());
1192        let shrunk = wrapped
1193            .shrink_to_fit(&stub_ctx(), &stub_rc(), 5)
1194            .expect("inner returned compact form");
1195        assert_eq!(shrunk.text, "c");
1196        assert_eq!(shrunk.style, override_style);
1197    }
1198
1199    #[test]
1200    fn shrink_to_fit_passthrough_keeps_inner_style_when_no_user_override() {
1201        // The `None` arm of `match self.user_style` must pass the
1202        // inner shrunk render through unchanged. A regression that
1203        // unconditionally applies a default style would clobber the
1204        // inner segment's role (e.g. `git_branch`'s `Role::Accent`
1205        // would silently drop on the compact path for any user
1206        // without a configured style override).
1207        struct ShrinkableWithRole;
1208        impl Segment for ShrinkableWithRole {
1209            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1210                Ok(Some(RenderedSegment::new("full").with_role(Role::Accent)))
1211            }
1212            fn shrink_to_fit(
1213                &self,
1214                _: &DataContext,
1215                _: &RenderContext,
1216                _target: u16,
1217            ) -> Option<RenderedSegment> {
1218                Some(RenderedSegment::new("c").with_role(Role::Accent))
1219            }
1220        }
1221        // No `with_user_style` call — wrapper carries no override.
1222        let wrapped = OverriddenSegment::new(Box::new(ShrinkableWithRole));
1223        let shrunk = wrapped
1224            .shrink_to_fit(&stub_ctx(), &stub_rc(), 10)
1225            .expect("inner returned compact form");
1226        assert_eq!(shrunk.style.role, Some(Role::Accent));
1227    }
1228
1229    #[test]
1230    fn shrink_to_fit_passthrough_returns_none_when_inner_declines() {
1231        // Default trait impl returns None; the wrapper must forward
1232        // None unchanged rather than emit a stub render of its own.
1233        struct Plain;
1234        impl Segment for Plain {
1235            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1236                Ok(Some(RenderedSegment::new("plain")))
1237            }
1238        }
1239        let wrapped =
1240            OverriddenSegment::new(Box::new(Plain)).with_user_style(Style::role(Role::Primary));
1241        assert!(wrapped
1242            .shrink_to_fit(&stub_ctx(), &stub_rc(), 100)
1243            .is_none());
1244    }
1245
1246    fn stub_ctx() -> DataContext {
1247        use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
1248        use std::path::PathBuf;
1249        use std::sync::Arc;
1250        DataContext::new(StatusContext {
1251            tool: Tool::ClaudeCode,
1252            model: Some(ModelInfo {
1253                display_name: "Claude".into(),
1254            }),
1255            workspace: Some(WorkspaceInfo {
1256                project_dir: PathBuf::from("/repo/linesmith"),
1257                git_worktree: None,
1258            }),
1259            context_window: None,
1260            cost: None,
1261            effort: None,
1262            vim: None,
1263            output_style: None,
1264            agent_name: None,
1265            version: None,
1266            raw: Arc::new(serde_json::Value::Null),
1267        })
1268    }
1269
1270    fn stub_rc() -> RenderContext {
1271        RenderContext::new(80)
1272    }
1273}