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