Skip to main content

linesmith_core/
layout.rs

1//! Layout engine. Takes a list of `Segment`s plus a `StatusContext` and
2//! fits their renders into a terminal-width budget, dropping the
3//! highest-priority (numerically largest) segments first — or, when a
4//! segment opts in via `truncatable`, shrinking it to fit before drop.
5//! Priority-0 segments are never dropped or truncated, even when that
6//! overflows the budget.
7//!
8//! See `docs/specs/segment-system.md` §Layout algorithm.
9
10use crate::data_context::DataContext;
11use crate::segments::{
12    text_width, RenderContext, RenderedSegment, Segment, SegmentDefaults, Separator, WidthBounds,
13};
14use crate::theme::{self, Capability, Style, StyledRun, Theme};
15use unicode_segmentation::UnicodeSegmentation;
16
17/// Render `segments` for `ctx` within `terminal_width` cells. Returns the
18/// final line without a trailing newline. Segment render errors go
19/// through [`crate::lsm_error!`] so a broken segment always surfaces,
20/// even under `LINESMITH_LOG=off` — a blank statusline with zero
21/// diagnostic is a bad UX even when the user opted into quiet mode.
22/// Output is unstyled (callers that want theming use
23/// [`render_with_warn`] with their own closure).
24#[must_use]
25pub fn render(segments: &[Box<dyn Segment>], ctx: &DataContext, terminal_width: u16) -> String {
26    let mut warn = |msg: &str| crate::lsm_error!("{msg}");
27    render_with_warn(
28        segments,
29        ctx,
30        terminal_width,
31        &mut warn,
32        theme::default_theme(),
33        Capability::None,
34        false,
35    )
36}
37
38/// Same as [`render`] but routes segment render-error diagnostics
39/// through `warn` and emits ANSI SGR around each segment per `theme`
40/// and `capability`. Used by [`crate::run_with_context`] so `cli_main`
41/// tests can capture segment errors alongside exit codes while the
42/// render path picks up theme colors.
43///
44/// `hyperlinks` gates OSC 8 emission for runs whose `Style.hyperlink`
45/// is set. Pass `true` when the terminal advertises OSC 8 support
46/// (e.g. via the `supports-hyperlinks` crate or an explicit user
47/// override), `false` otherwise — capable terminals render the run
48/// as a clickable link, others see plain text.
49///
50/// Thin wrapper over [`render_to_runs`] + [`runs_to_ansi`]; same
51/// layout, same bytes. Callers that need the styled-run form (e.g.
52/// the TUI preview pane) call [`render_to_runs`] directly.
53#[must_use]
54pub fn render_with_warn(
55    segments: &[Box<dyn Segment>],
56    ctx: &DataContext,
57    terminal_width: u16,
58    warn: &mut dyn FnMut(&str),
59    theme: &Theme,
60    capability: Capability,
61    hyperlinks: bool,
62) -> String {
63    let runs = render_to_runs(segments, ctx, terminal_width, warn);
64    runs_to_ansi(&runs, theme, capability, hyperlinks)
65}
66
67/// Render `segments` into a flat [`StyledRun`] sequence. One run per
68/// surviving segment, plus one run per non-empty inter-segment
69/// separator (in render order). Layout decisions — priority-drop,
70/// `shrink_to_fit`, truncatable reflow, width-bound truncation —
71/// match [`render`] / [`render_with_warn`] exactly; only the emit
72/// form differs.
73///
74/// `Separator::None` between segments contributes no run; it would
75/// be an empty-text run with no consumer use. Separator runs carry
76/// [`Style::default`]; separators inherit no styling from their
77/// flanking segments.
78///
79/// Segment render errors and `Ok(None)` go through `warn` exactly as
80/// in the ANSI path; the run sequence reflects only segments that
81/// survived to the layout pass.
82#[must_use]
83pub fn render_to_runs(
84    segments: &[Box<dyn Segment>],
85    ctx: &DataContext,
86    terminal_width: u16,
87    warn: &mut dyn FnMut(&str),
88) -> Vec<StyledRun> {
89    let rc = RenderContext::new(terminal_width);
90    let items = collect_items_with(segments, ctx, &rc, warn);
91    let laid_out = apply_layout(items, ctx, &rc, terminal_width);
92    items_to_runs(&laid_out)
93}
94
95/// Emit a flat [`StyledRun`] sequence as an ANSI SGR-wrapped string
96/// suitable for terminal stdout. Each run with non-empty styling gets
97/// its own `sgr_open` / `sgr_reset` pair so decorations don't leak
98/// across boundaries; plain runs pass through unwrapped. When
99/// `hyperlinks` is `true`, runs carrying `Style.hyperlink` are
100/// additionally wrapped in OSC 8 open/close so capable terminals
101/// render them as clickable links; the OSC 8 wrap sits *outside* the
102/// SGR pair so the link survives the SGR reset. `hyperlinks = false`
103/// drops the URL silently — the run still emits, just without the
104/// link.
105#[must_use]
106pub fn runs_to_ansi(
107    runs: &[StyledRun],
108    theme: &Theme,
109    capability: Capability,
110    hyperlinks: bool,
111) -> String {
112    let mut out = String::new();
113    for run in runs {
114        let link = run.style.hyperlink.as_deref().filter(|_| hyperlinks);
115        if let Some(url) = link {
116            push_osc8_open(&mut out, url);
117        }
118        let open = theme::sgr_open(&run.style, theme, capability);
119        if open.is_empty() {
120            out.push_str(&run.text);
121        } else {
122            out.push_str(&open);
123            out.push_str(&run.text);
124            out.push_str(theme::sgr_reset());
125        }
126        if link.is_some() {
127            push_osc8_close(&mut out);
128        }
129    }
130    out
131}
132
133/// OSC 8 hyperlink open: `ESC ] 8 ; ; <url> ST`. Uses ESC `\` (the
134/// canonical String Terminator) rather than the BEL alternative;
135/// modern terminals accept both but ESC `\` is the spec form and
136/// safer when output is piped through tools that interpret BEL.
137///
138/// Strips control characters from `url` before emission. Without
139/// this, an embedded `ESC \` in a plugin- or repo-derived URL would
140/// terminate the OSC 8 envelope early and turn the remainder into
141/// raw terminal control sequences — the same escape-injection class
142/// `RenderedSegment::new` strips from segment text.
143fn push_osc8_open(out: &mut String, url: &str) {
144    out.push_str("\x1b]8;;");
145    for c in url.chars() {
146        if !c.is_control() {
147            out.push(c);
148        }
149    }
150    out.push_str("\x1b\\");
151}
152
153/// OSC 8 hyperlink close: same envelope, empty URL.
154fn push_osc8_close(out: &mut String) {
155    out.push_str("\x1b]8;;\x1b\\");
156}
157
158/// Rendered output paired with the defaults needed to place it (priority,
159/// separator, bounds) and a back-reference to the segment so the reflow
160/// loop can call `shrink_to_fit` without re-walking the input slice.
161/// Bundled here so drop/emit passes don't re-query the trait.
162struct Item<'a> {
163    rendered: RenderedSegment,
164    defaults: SegmentDefaults,
165    segment: &'a dyn Segment,
166}
167
168fn collect_items_with<'a>(
169    segments: &'a [Box<dyn Segment>],
170    ctx: &DataContext,
171    rc: &RenderContext,
172    warn: &mut dyn FnMut(&str),
173) -> Vec<Item<'a>> {
174    segments
175        .iter()
176        .filter_map(|seg| {
177            let defaults = seg.defaults();
178            let rendered = match seg.render(ctx, rc) {
179                Ok(Some(r)) => r,
180                Ok(None) => return None,
181                Err(err) => {
182                    warn(&format!("segment error: {err}"));
183                    return None;
184                }
185            };
186            apply_width_bounds(rendered, defaults.width).map(|r| Item {
187                rendered: r,
188                defaults,
189                segment: seg.as_ref(),
190            })
191        })
192        .collect()
193}
194
195/// Pure layout pass — no styling, no emission. Runs the
196/// priority-drop / shrink / reflow loop and returns surviving items
197/// in render order.
198fn apply_layout<'a>(
199    mut items: Vec<Item<'a>>,
200    ctx: &DataContext,
201    rc: &RenderContext,
202    terminal_width: u16,
203) -> Vec<Item<'a>> {
204    let budget = u32::from(terminal_width);
205    loop {
206        let total = total_width(&items);
207        if total <= budget {
208            break;
209        }
210        let Some(drop_idx) = items
211            .iter()
212            .enumerate()
213            .filter(|(_, item)| item.defaults.priority > 0)
214            .max_by_key(|(_, item)| item.defaults.priority)
215            .map(|(i, _)| i)
216        else {
217            break;
218        };
219        let overflow = total - budget;
220        // Try segment-side compaction first; the segment knows things
221        // the engine doesn't (which decoration is signal-bearing,
222        // which prefix to keep). Falls through to generic end-ellipsis
223        // truncation only when shrink_to_fit declines.
224        if let Some(shrunk) = try_shrink(&items[drop_idx], ctx, rc, overflow) {
225            items[drop_idx].rendered = shrunk;
226            continue;
227        }
228        if items[drop_idx].defaults.truncatable {
229            if let Some(reflowed) = try_reflow(&items[drop_idx], overflow) {
230                items[drop_idx] = reflowed;
231                continue;
232            }
233        }
234        items.remove(drop_idx);
235    }
236    items
237}
238
239/// Test-only helper that mirrors `render_with_warn`'s compose order.
240/// Lets unit tests build `Item` literals directly without restating
241/// the layout-then-emit dance per case.
242#[cfg(test)]
243fn render_items(
244    items: Vec<Item<'_>>,
245    ctx: &DataContext,
246    rc: &RenderContext,
247    terminal_width: u16,
248    theme: &Theme,
249    capability: Capability,
250) -> String {
251    let laid_out = apply_layout(items, ctx, rc, terminal_width);
252    let runs = items_to_runs(&laid_out);
253    runs_to_ansi(&runs, theme, capability, false)
254}
255
256/// Flatten step for [`render_to_runs`]: see that function for the
257/// emit contract. Separator runs carry [`Style::default`];
258/// `Separator::None` is filtered here so consumers don't see
259/// empty-text runs.
260fn items_to_runs(items: &[Item<'_>]) -> Vec<StyledRun> {
261    let mut runs = Vec::with_capacity(items.len().saturating_mul(2));
262    for (i, item) in items.iter().enumerate() {
263        runs.push(StyledRun {
264            text: item.rendered.text.clone(),
265            style: item.rendered.style.clone(),
266        });
267        if i + 1 < items.len() {
268            let sep = effective_separator(item);
269            let sep_text = sep.text();
270            if !sep_text.is_empty() {
271                runs.push(StyledRun {
272                    text: sep_text.to_string(),
273                    style: separator_style(sep),
274                });
275            }
276        }
277    }
278    runs
279}
280
281/// Style for an inter-segment separator run. Plain separators carry
282/// `Style::default()`; powerline chevrons get `Role::Muted` so the
283/// chevron reads as readable secondary text rather than dropping into
284/// the dim divider/border shade (which on most dark themes renders too
285/// close to the background to be legible without bg fill).
286fn separator_style(sep: &Separator) -> Style {
287    match sep {
288        Separator::Powerline { .. } => Style::role(theme::Role::Muted),
289        _ => Style::default(),
290    }
291}
292
293/// Sum of segment widths plus the separators that sit *between* segments
294/// (no trailing separator). `u32` prevents `u16` overflow on many wide
295/// segments.
296fn total_width(items: &[Item<'_>]) -> u32 {
297    if items.is_empty() {
298        return 0;
299    }
300    let seg_sum: u32 = items.iter().map(|i| u32::from(i.rendered.width)).sum();
301    let sep_sum: u32 = items
302        .iter()
303        .take(items.len() - 1)
304        .map(|item| u32::from(effective_separator(item).width()))
305        .sum();
306    seg_sum + sep_sum
307}
308
309fn effective_separator<'i>(item: &'i Item<'_>) -> &'i Separator {
310    item.rendered
311        .right_separator
312        .as_ref()
313        .unwrap_or(&item.defaults.default_separator)
314}
315
316/// Applies `bounds`: under-min drops the segment, over-max truncates with
317/// a trailing ellipsis and a recomputed width. `None` bounds is an
318/// explicit passthrough — the segment carries no constraints.
319fn apply_width_bounds(
320    rendered: RenderedSegment,
321    bounds: Option<WidthBounds>,
322) -> Option<RenderedSegment> {
323    let Some(bounds) = bounds else {
324        return Some(rendered);
325    };
326    if rendered.width < bounds.min() {
327        return None;
328    }
329    if rendered.width > bounds.max() {
330        return Some(truncate_to(rendered, bounds.max()));
331    }
332    Some(rendered)
333}
334
335/// Shrink `item` by `overflow` cells so the layout fits, or return
336/// `None` when the result would fall below `max(width.min, 2)` cells
337/// (one content grapheme plus the ellipsis), so the caller can drop the
338/// segment whole.
339///
340/// Subtracting exactly `overflow` lands total width on the budget so
341/// the reflow loop exits on its next check; a wide grapheme straddling
342/// the boundary may yield a slightly narrower result, which still
343/// meets the `overflow` requirement.
344fn try_reflow<'a>(item: &Item<'a>, overflow: u32) -> Option<Item<'a>> {
345    let floor = item.defaults.width.map_or(2, |b| b.min().max(2));
346    let cur = item.rendered.width;
347    let target = u32::from(cur).checked_sub(overflow)?;
348    let target_u16 = u16::try_from(target).ok()?;
349    if target_u16 < floor {
350        return None;
351    }
352    let truncated = truncate_to(item.rendered.clone(), target_u16);
353    if truncated.width < floor {
354        return None;
355    }
356    Some(Item {
357        rendered: truncated,
358        defaults: item.defaults.clone(),
359        segment: item.segment,
360    })
361}
362
363/// Ask the segment to produce a render at most `cur_width - overflow`
364/// cells wide. Returns `None` when `shrink_to_fit` itself returns
365/// `None` (default impl, or the segment declined). A segment that
366/// returns `Some(r)` with `r.width > target` violates the documented
367/// contract — the engine rejects the response (to preserve the
368/// layout-fit invariant) and routes the violation through
369/// [`crate::lsm_warn!`] so the misbehavior is visible to the segment
370/// author. The caller falls through to `truncatable` end-ellipsis or
371/// drop on any of these outcomes.
372fn try_shrink(
373    item: &Item<'_>,
374    ctx: &DataContext,
375    rc: &RenderContext,
376    overflow: u32,
377) -> Option<RenderedSegment> {
378    let cur = item.rendered.width;
379    // `cur < overflow` is reachable: one segment frequently can't
380    // absorb the whole overflow alone (e.g. cost=6 when total
381    // overshoots by 12). `checked_sub` returns `None` and the engine
382    // drops the segment so the loop iterates with a smaller total.
383    let target = u16::try_from(u32::from(cur).checked_sub(overflow)?).ok()?;
384    // Honor the user's declared `width.min` floor on the shrunk
385    // render the same way `apply_width_bounds` and `try_reflow` do —
386    // a configured min is a contract that a too-narrow render is
387    // worse than no render. No `+ 2` like `try_reflow`'s floor
388    // because `shrink_to_fit` produces an arbitrary string, not
389    // text + ellipsis.
390    let min_floor = item.defaults.width.map_or(0, |b| b.min());
391    if target < min_floor {
392        return None;
393    }
394    let shrunk = item.segment.shrink_to_fit(ctx, rc, target)?;
395    if shrunk.width > target {
396        crate::lsm_warn!(
397            "segment shrink_to_fit returned width {} > target {}; rejecting",
398            shrunk.width,
399            target,
400        );
401        return None;
402    }
403    if shrunk.width < min_floor {
404        return None;
405    }
406    Some(shrunk)
407}
408
409/// Truncate `rendered` to at most `max_cells` terminal cells, appending
410/// `…` (U+2026, 1 cell) as a continuation marker. Iterates by grapheme
411/// cluster so combining marks, ZWJ sequences, and emoji stay intact.
412pub(crate) fn truncate_to(rendered: RenderedSegment, max_cells: u16) -> RenderedSegment {
413    if max_cells == 0 {
414        return RenderedSegment::from_parts(
415            String::new(),
416            0,
417            rendered.right_separator,
418            rendered.style,
419        );
420    }
421    // Reserve one cell for the ellipsis.
422    let budget = max_cells.saturating_sub(1);
423    let mut out = String::new();
424    let mut used: u16 = 0;
425    for cluster in rendered.text.graphemes(true) {
426        let w = text_width(cluster);
427        if used.saturating_add(w) > budget {
428            break;
429        }
430        out.push_str(cluster);
431        used = used.saturating_add(w);
432    }
433    out.push('…');
434    RenderedSegment::from_parts(
435        out,
436        used.saturating_add(1),
437        rendered.right_separator,
438        rendered.style,
439    )
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
446    use crate::theme;
447    use std::borrow::Cow;
448    use std::path::PathBuf;
449    use std::sync::Arc;
450
451    /// Stub `Segment` for layout tests that build `Item` literals
452    /// directly. The reflow loop's `shrink_to_fit` callback gets the
453    /// default `None`, so layout tests focused on priority-drop /
454    /// separators / truncatable behavior don't need to mint a fresh
455    /// segment per case.
456    struct NoopSegment;
457    impl Segment for NoopSegment {
458        fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
459            Ok(None)
460        }
461    }
462    static NOOP: NoopSegment = NoopSegment;
463    fn noop_segment() -> &'static dyn Segment {
464        &NOOP
465    }
466
467    fn empty_ctx() -> DataContext {
468        DataContext::new(StatusContext {
469            tool: Tool::ClaudeCode,
470            model: Some(ModelInfo {
471                display_name: "X".into(),
472            }),
473            workspace: Some(WorkspaceInfo {
474                project_dir: PathBuf::from("/"),
475                git_worktree: None,
476            }),
477            context_window: None,
478            cost: None,
479            effort: None,
480            vim: None,
481            output_style: None,
482            agent_name: None,
483            version: None,
484            raw: Arc::new(serde_json::Value::Null),
485        })
486    }
487
488    fn empty_rc() -> RenderContext {
489        RenderContext::new(80)
490    }
491
492    fn item(text: &str, priority: u8) -> Item<'static> {
493        Item {
494            rendered: RenderedSegment::new(text),
495            defaults: SegmentDefaults::with_priority(priority),
496            segment: noop_segment(),
497        }
498    }
499
500    /// Test helper: exercise `render_items` with the default theme and
501    /// no color capability so output is plain text — the invariant most
502    /// layout tests actually care about (priority-drop, separators,
503    /// truncation behavior) is independent of theming.
504    fn render_plain(items: Vec<Item<'_>>, terminal_width: u16) -> String {
505        render_items(
506            items,
507            &empty_ctx(),
508            &empty_rc(),
509            terminal_width,
510            theme::default_theme(),
511            theme::Capability::None,
512        )
513    }
514
515    #[test]
516    fn render_items_wraps_each_styled_segment_under_palette16() {
517        // Plain + styled + plain layout: the styled one gets SGR
518        // wrapping, the plain ones pass through. Confirms the layout
519        // emits SGR *per segment* rather than globally, so decorations
520        // don't leak across separators.
521        use crate::theme::Role;
522        let items = vec![
523            Item {
524                rendered: RenderedSegment::new("a"),
525                defaults: SegmentDefaults::with_priority(10),
526                segment: noop_segment(),
527            },
528            Item {
529                rendered: RenderedSegment::new("b").with_role(Role::Warning),
530                defaults: SegmentDefaults::with_priority(10),
531                segment: noop_segment(),
532            },
533            Item {
534                rendered: RenderedSegment::new("c"),
535                defaults: SegmentDefaults::with_priority(10),
536                segment: noop_segment(),
537            },
538        ];
539        let out = render_items(
540            items,
541            &empty_ctx(),
542            &empty_rc(),
543            100,
544            theme::default_theme(),
545            theme::Capability::Palette16,
546        );
547        // Warning → BrightYellow (SGR 93) on the default theme.
548        assert_eq!(out, "a \x1b[93mb\x1b[0m c");
549    }
550
551    #[test]
552    fn total_width_counts_inter_segment_separators_only() {
553        let items = vec![item("ab", 10), item("cd", 10), item("ef", 10)];
554        // widths 2+2+2 = 6, separators between: 2 * 1 = 2, total 8.
555        assert_eq!(total_width(&items), 8);
556    }
557
558    #[test]
559    fn total_width_zero_for_empty() {
560        assert_eq!(total_width(&[]), 0);
561    }
562
563    #[test]
564    fn total_width_single_segment_has_no_separator() {
565        let items = vec![item("abcde", 10)];
566        assert_eq!(total_width(&items), 5);
567    }
568
569    #[test]
570    fn no_width_pressure_renders_all_with_separators() {
571        let items = vec![item("one", 10), item("two", 20), item("three", 30)];
572        assert_eq!(render_plain(items, 100), "one two three");
573    }
574
575    #[test]
576    fn drops_highest_priority_under_pressure() {
577        let items = vec![
578            item("aaaa", 10),
579            item("bbbb", 200), // highest priority → drops first
580            item("cccc", 50),
581        ];
582        // Full: 4+1+4+1+4 = 14. Budget 10 forces one drop.
583        let out = render_plain(items, 10);
584        assert!(!out.contains("bbbb"));
585        assert!(out.contains("aaaa"));
586        assert!(out.contains("cccc"));
587    }
588
589    #[test]
590    fn drops_in_descending_priority_order() {
591        let items = vec![
592            item("one", 10),
593            item("two", 200), // drops first
594            item("three", 20),
595            item("four", 150), // drops second
596            item("five", 30),
597        ];
598        // Full: 3+1+3+1+5+1+4+1+4 = 23. Budget 15 forces two drops.
599        assert_eq!(render_plain(items, 15), "one three five");
600    }
601
602    #[test]
603    fn priority_zero_never_drops_even_over_budget() {
604        let items = vec![item("aaaa", 0), item("bbbb", 0)];
605        let out = render_plain(items, 3);
606        assert_eq!(out, "aaaa bbbb");
607    }
608
609    #[test]
610    fn priority_drop_recomputes_budget_with_powerline_separators() {
611        // Three priority-0 segments at width 4 with powerline chevrons
612        // between them: full = 4 + chev + 4 + chev + 4. The middle
613        // segment is the only droppable one (priority 200); after one
614        // drop the layout becomes "aaaa <chev> cccc" (4 + chev + 4)
615        // and fits the budget without a second drop. A regression that
616        // forgot to subtract a chevron's cells when its preceding
617        // segment dropped would over-drop or mis-budget.
618        let item_pl = |text: &'static str, priority: u8| Item {
619            rendered: RenderedSegment::new(text),
620            defaults: SegmentDefaults::with_priority(priority)
621                .with_default_separator(Separator::powerline()),
622            segment: noop_segment(),
623        };
624        let items = vec![item_pl("aaaa", 0), item_pl("bbbb", 200), item_pl("cccc", 0)];
625        // Full = 4 + 3 + 4 + 3 + 4 = 18; after drop = 4 + 3 + 4 = 11.
626        let out = render_plain(items, 14);
627        assert!(out.contains("aaaa"));
628        assert!(!out.contains("bbbb"));
629        assert!(out.contains("cccc"));
630        assert!(
631            out.contains('\u{E0B0}'),
632            "chevron survives the drop: {out:?}"
633        );
634    }
635
636    #[test]
637    fn mix_drops_positives_keeps_zeros() {
638        let items = vec![
639            item("keep-me", 0),
640            item("droppable", 200),
641            item("sticky", 0),
642        ];
643        // Budget forces drop; only the priority-200 segment is eligible.
644        let out = render_plain(items, 20);
645        assert_eq!(out, "keep-me sticky");
646    }
647
648    #[test]
649    fn no_trailing_separator() {
650        let items = vec![item("a", 10), item("b", 10)];
651        assert_eq!(render_plain(items, 100), "a b");
652    }
653
654    #[test]
655    fn empty_input_renders_empty_string() {
656        assert_eq!(render_plain(vec![], 100), "");
657    }
658
659    #[test]
660    fn respects_custom_separator_from_defaults() {
661        let items = vec![
662            Item {
663                rendered: RenderedSegment::new("a"),
664                defaults: SegmentDefaults {
665                    priority: 10,
666                    width: None,
667                    default_separator: Separator::Literal(Cow::Borrowed(" | ")),
668                    truncatable: false,
669                },
670                segment: noop_segment(),
671            },
672            Item {
673                rendered: RenderedSegment::new("b"),
674                defaults: SegmentDefaults::with_priority(10),
675                segment: noop_segment(),
676            },
677        ];
678        assert_eq!(render_plain(items, 100), "a | b");
679    }
680
681    #[test]
682    fn render_override_separator_beats_default() {
683        let items = vec![
684            Item {
685                rendered: RenderedSegment::with_separator("a", Separator::None),
686                defaults: SegmentDefaults::with_priority(10),
687                segment: noop_segment(),
688            },
689            Item {
690                rendered: RenderedSegment::new("b"),
691                defaults: SegmentDefaults::with_priority(10),
692                segment: noop_segment(),
693            },
694        ];
695        assert_eq!(render_plain(items, 100), "ab");
696    }
697
698    // --- width-bounds helpers ------------------------------------------
699
700    #[test]
701    fn apply_width_bounds_drops_below_min() {
702        let bounds = WidthBounds::new(5, 10);
703        let rendered = RenderedSegment::new("abc"); // width 3
704        assert!(apply_width_bounds(rendered, bounds).is_none());
705    }
706
707    #[test]
708    fn apply_width_bounds_truncates_above_max() {
709        let bounds = WidthBounds::new(0, 5);
710        let rendered = RenderedSegment::new("abcdefghij"); // width 10
711        let truncated = apply_width_bounds(rendered, bounds).expect("truncated");
712        assert_eq!(truncated.width, 5);
713        assert!(truncated.text.ends_with('…'));
714        assert_eq!(truncated.text, "abcd…");
715    }
716
717    #[test]
718    fn apply_width_bounds_passthrough_within_range() {
719        let bounds = WidthBounds::new(2, 10);
720        let original = RenderedSegment::new("hello");
721        let result = apply_width_bounds(original.clone(), bounds).expect("kept");
722        assert_eq!(result, original);
723    }
724
725    #[test]
726    fn apply_width_bounds_none_is_passthrough() {
727        let original = RenderedSegment::new("anything");
728        let result = apply_width_bounds(original.clone(), None).expect("kept");
729        assert_eq!(result, original);
730    }
731
732    #[test]
733    fn truncate_to_zero_yields_empty() {
734        let out = truncate_to(RenderedSegment::new("abc"), 0);
735        assert_eq!(out.text, "");
736        assert_eq!(out.width, 0);
737    }
738
739    #[test]
740    fn truncate_handles_wide_grapheme_without_splitting() {
741        // The middle-dot is 1 cell; truncating "42% · 200k" (10 cells) to
742        // 6 cells should yield "42% ·…" (5 cells of content + ellipsis).
743        let bounds = WidthBounds::new(0, 6);
744        let truncated =
745            apply_width_bounds(RenderedSegment::new("42% · 200k"), bounds).expect("truncated");
746        assert_eq!(truncated.text, "42% ·…");
747        assert_eq!(truncated.width, 6);
748    }
749
750    #[test]
751    fn truncate_preserves_combining_mark_with_base() {
752        // "é" is U+0065 U+0301 (2 code points, 1 grapheme, 1 cell).
753        // `abéde` is 5 cells, truncate to 4 should yield `abé…`.
754        let r = RenderedSegment::new("ab\u{65}\u{301}de");
755        assert_eq!(r.width, 5);
756        let out = truncate_to(r, 4);
757        assert_eq!(out.text, "ab\u{65}\u{301}…");
758        assert_eq!(out.width, 4);
759    }
760
761    #[test]
762    fn truncate_does_not_split_zwj_emoji_sequence() {
763        // 👨‍👩‍👦 is a ZWJ sequence (5 code points, 1 grapheme, 2 cells).
764        // Total "a👨‍👩‍👦b" = 1 + 2 + 1 = 4 cells. Truncating to 3 cells:
765        // budget for content is 2; we can fit "a" (1 cell) then the ZWJ
766        // family (2 cells) would exceed budget, so output is "a…".
767        let text = "a\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}b";
768        let r = RenderedSegment::new(text);
769        let out = truncate_to(r, 3);
770        assert_eq!(out.text, "a…");
771        assert_eq!(out.width, 2);
772    }
773
774    #[test]
775    fn truncate_to_max_cells_one_emits_only_ellipsis() {
776        let r = RenderedSegment::new("anything");
777        let out = truncate_to(r, 1);
778        assert_eq!(out.text, "…");
779        assert_eq!(out.width, 1);
780    }
781
782    #[test]
783    fn priority_ties_drop_rightmost_first() {
784        let items = vec![item("left", 200), item("mid", 50), item("right", 200)];
785        // Full: 4+1+3+1+5 = 14. Budget 10 forces one drop; tied priorities
786        // on "left" and "right" — right drops first.
787        assert_eq!(render_plain(items, 10), "left mid");
788    }
789
790    #[test]
791    fn separator_none_not_charged_to_budget() {
792        // Three segments; middle one declares Separator::None on its right
793        // edge, collapsing against the next. Widths 1+1+1 = 3; separators
794        // are Space (1) between 0-1, None (0) between 1-2. Total = 4.
795        // Any budget ≥ 4 must keep everything and emit "a bc".
796        let items = vec![
797            Item {
798                rendered: RenderedSegment::new("a"),
799                defaults: SegmentDefaults::with_priority(200),
800                segment: noop_segment(),
801            },
802            Item {
803                rendered: RenderedSegment::with_separator("b", Separator::None),
804                defaults: SegmentDefaults::with_priority(200),
805                segment: noop_segment(),
806            },
807            Item {
808                rendered: RenderedSegment::new("c"),
809                defaults: SegmentDefaults::with_priority(200),
810                segment: noop_segment(),
811            },
812        ];
813        assert_eq!(render_plain(items, 4), "a bc");
814    }
815
816    #[test]
817    fn total_width_returns_u32_beyond_u16_range() {
818        // Three segments at u16::MAX each: sum = 3 * u16::MAX plus two
819        // separator cells. Must not wrap.
820        let items = vec![
821            Item {
822                rendered: RenderedSegment::new("x".repeat(u16::MAX as usize)),
823                defaults: SegmentDefaults::with_priority(10),
824                segment: noop_segment(),
825            },
826            Item {
827                rendered: RenderedSegment::new("x".repeat(u16::MAX as usize)),
828                defaults: SegmentDefaults::with_priority(10),
829                segment: noop_segment(),
830            },
831            Item {
832                rendered: RenderedSegment::new("x".repeat(u16::MAX as usize)),
833                defaults: SegmentDefaults::with_priority(10),
834                segment: noop_segment(),
835            },
836        ];
837        assert_eq!(total_width(&items), 3 * u32::from(u16::MAX) + 2);
838    }
839
840    #[test]
841    fn all_priority_zero_keeps_every_segment_even_when_overfull() {
842        let items = vec![item("aaa", 0), item("bbb", 0), item("ccc", 0)];
843        // Full 3+1+3+1+3 = 11. Budget 4 is nowhere near; all three stay.
844        assert_eq!(render_plain(items, 4), "aaa bbb ccc");
845    }
846
847    // --- error handling ---
848
849    use crate::segments::{RenderResult, SegmentError};
850
851    struct StubSegment(RenderResult);
852
853    impl Segment for StubSegment {
854        fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
855            match &self.0 {
856                Ok(Some(r)) => Ok(Some(r.clone())),
857                Ok(None) => Ok(None),
858                Err(e) => Err(SegmentError::new(e.message.clone())),
859            }
860        }
861    }
862
863    #[test]
864    fn segment_error_is_logged_and_hides_segment() {
865        let segments: Vec<Box<dyn Segment>> = vec![
866            Box::new(StubSegment(Ok(Some(RenderedSegment::new("ok-before"))))),
867            Box::new(StubSegment(Err(SegmentError::new("boom")))),
868            Box::new(StubSegment(Ok(Some(RenderedSegment::new("ok-after"))))),
869        ];
870        let mut warnings = Vec::new();
871        let items = collect_items_with(&segments, &empty_ctx(), &empty_rc(), &mut |msg| {
872            warnings.push(msg.to_string());
873        });
874        // The Err segment vanishes from layout; neighbors survive.
875        assert_eq!(items.len(), 2);
876        assert_eq!(items[0].rendered.text, "ok-before");
877        assert_eq!(items[1].rendered.text, "ok-after");
878        // The error is surfaced to stderr exactly once.
879        assert_eq!(warnings.len(), 1);
880        assert!(warnings[0].contains("segment error"));
881        assert!(warnings[0].contains("boom"));
882    }
883
884    #[test]
885    fn ok_none_is_silently_hidden() {
886        let segments: Vec<Box<dyn Segment>> = vec![
887            Box::new(StubSegment(Ok(Some(RenderedSegment::new("visible"))))),
888            Box::new(StubSegment(Ok(None))),
889        ];
890        let mut warnings = Vec::new();
891        let items = collect_items_with(&segments, &empty_ctx(), &empty_rc(), &mut |msg| {
892            warnings.push(msg.to_string());
893        });
894        assert_eq!(items.len(), 1);
895        assert!(warnings.is_empty());
896    }
897
898    /// `WidthEcho` emits whatever `terminal_width` it receives — used
899    /// by both reflow-threading tests below.
900    struct WidthEcho;
901    impl Segment for WidthEcho {
902        fn render(&self, _ctx: &DataContext, rc: &RenderContext) -> RenderResult {
903            Ok(Some(RenderedSegment::new(rc.terminal_width.to_string())))
904        }
905    }
906
907    #[test]
908    fn render_context_threads_terminal_width_into_segments() {
909        // Asserts the engine threads `RenderContext::new(42)` to the
910        // segment unmodified at the `collect_items_with` layer —
911        // pinning runtime behavior, since type-signature compilation
912        // alone doesn't prove the value moves.
913        let segments: Vec<Box<dyn Segment>> = vec![Box::new(WidthEcho)];
914        let mut warnings = Vec::new();
915        let rc = RenderContext::new(42);
916        let items = collect_items_with(&segments, &empty_ctx(), &rc, &mut |msg| {
917            warnings.push(msg.to_string());
918        });
919        assert_eq!(items.len(), 1);
920        assert_eq!(items[0].rendered.text, "42");
921    }
922
923    #[test]
924    fn render_with_warn_constructs_render_context_from_terminal_width_arg() {
925        // Pins the construction line in `render_with_warn`: the public
926        // entrypoint must build `RenderContext::new(terminal_width)`
927        // from its argument and pass it to segments. A regression that
928        // hard-coded a default would slip past the
929        // `collect_items_with`-only test above.
930        let segments: Vec<Box<dyn Segment>> = vec![Box::new(WidthEcho)];
931        let mut warnings = Vec::new();
932        let line = render_with_warn(
933            &segments,
934            &empty_ctx(),
935            137,
936            &mut |msg| warnings.push(msg.to_string()),
937            theme::default_theme(),
938            theme::Capability::None,
939            false,
940        );
941        assert!(line.contains("137"), "got {line:?}");
942    }
943
944    // --- truncate-before-drop (reflow) ---
945
946    fn truncatable_item(text: &str, priority: u8) -> Item<'static> {
947        Item {
948            rendered: RenderedSegment::new(text),
949            defaults: SegmentDefaults::with_priority(priority).with_truncatable(true),
950            segment: noop_segment(),
951        }
952    }
953
954    #[test]
955    fn reflow_truncates_highest_priority_before_dropping() {
956        // Workspace-style scenario: long location plus a small fixed
957        // segment. Without reflow the location would drop entirely;
958        // with reflow it shrinks to fit so the user keeps orientation.
959        let items = vec![
960            truncatable_item("linesmith/very-long-feature-branch-name", 200),
961            item("Sonnet", 0),
962        ];
963        // Total: 39 + 1 + 6 = 46. Budget 30 → overflow 16.
964        // Workspace truncates from 39 → 23 cells; result fits exactly.
965        let out = render_plain(items, 30);
966        assert!(out.starts_with("linesmith/very-long-fe"), "got {out:?}");
967        assert!(out.ends_with("… Sonnet"), "got {out:?}");
968        assert_eq!(text_width(&out), 30);
969    }
970
971    #[test]
972    fn reflow_drops_when_truncation_would_fall_below_floor() {
973        // Budget so tight that truncating the workspace segment would
974        // leave only the ellipsis (or less). Engine falls back to drop.
975        let items = vec![truncatable_item("workspace-name", 200), item("KEEP", 0)];
976        // Total: 14 + 1 + 4 = 19. Budget 4 → overflow 15.
977        // workspace target = 14 - 15 < 0 → reflow returns None → drop.
978        let out = render_plain(items, 4);
979        assert_eq!(out, "KEEP");
980    }
981
982    #[test]
983    fn reflow_respects_explicit_width_min_floor() {
984        // Segment declares min=8; reflow must not shrink below that
985        // even if a smaller truncation would fit the budget.
986        let bounds = WidthBounds::new(8, u16::MAX).expect("valid");
987        let mut wide = truncatable_item("abcdefghijklmnop", 200); // width 16
988        wide.defaults.width = Some(bounds);
989        let items = vec![wide, item("X", 0)];
990        // Total 16 + 1 + 1 = 18. Budget 10 → overflow 8 → target 8 ✓
991        // (target equals floor; reflow proceeds).
992        let out = render_plain(items, 10);
993        assert!(out.contains('…'), "got {out:?}");
994        assert!(out.ends_with(" X"), "got {out:?}");
995
996        // Now budget 9 → overflow 9 → target 7 < floor 8 → drop.
997        let bounds = WidthBounds::new(8, u16::MAX).expect("valid");
998        let mut wide = truncatable_item("abcdefghijklmnop", 200);
999        wide.defaults.width = Some(bounds);
1000        let items = vec![wide, item("X", 0)];
1001        let out = render_plain(items, 9);
1002        assert_eq!(out, "X");
1003    }
1004
1005    #[test]
1006    fn non_truncatable_drops_unchanged_under_pressure() {
1007        // Default `truncatable=false` keeps the legacy whole-segment
1008        // drop path so numeric segments don't suddenly start emitting
1009        // half-cut percentages or dollar figures.
1010        let items = vec![item("45% · 200k", 200), item("Sonnet", 0)];
1011        // Total 10 + 1 + 6 = 17. Budget 10 → drop the wider one.
1012        let out = render_plain(items, 10);
1013        assert_eq!(out, "Sonnet");
1014    }
1015
1016    #[test]
1017    fn reflow_iterates_when_first_truncation_insufficient() {
1018        // Two truncatable segments, both same priority. After tying
1019        // priority we drop the right-most first; if that's still over
1020        // budget the loop comes back for the left one.
1021        let items = vec![
1022            truncatable_item("aaaaaaaaaa", 100),
1023            truncatable_item("bbbbbbbbbb", 100),
1024            item("KEEP", 0),
1025        ];
1026        // Total: 10 + 1 + 10 + 1 + 4 = 26. Budget 12 → overflow 14.
1027        // Right-most ("b...") is chosen first; truncating it to
1028        // 10-14 < 0 fails, so it drops. New total 10+1+4 = 15.
1029        // Loop continues; next iteration overflow=3, "a..." truncates
1030        // to 10-3 = 7 ("aaaaaa…").
1031        let out = render_plain(items, 12);
1032        assert_eq!(out, "aaaaaa… KEEP");
1033        assert_eq!(text_width(&out), 12);
1034    }
1035
1036    #[test]
1037    fn reflow_does_not_touch_priority_zero_even_when_truncatable() {
1038        // Priority 0 is "user said don't drop"; the reflow loop never
1039        // selects it (the existing droppable filter guards this).
1040        let items = vec![
1041            Item {
1042                rendered: RenderedSegment::new("untouchable-long-name"),
1043                defaults: SegmentDefaults::with_priority(0).with_truncatable(true),
1044                segment: noop_segment(),
1045            },
1046            item("Sonnet", 0),
1047        ];
1048        let out = render_plain(items, 5);
1049        assert_eq!(out, "untouchable-long-name Sonnet");
1050    }
1051
1052    // --- shrink_to_fit (layout-pressure-aware compaction) ---
1053
1054    /// Stub segment whose `shrink_to_fit` returns the configured
1055    /// compact form unconditionally — the engine's `target` check
1056    /// gates whether it's accepted. Higher-than-default priority so
1057    /// it's the one the reflow loop selects under pressure.
1058    struct ShrinkableSegment {
1059        full: &'static str,
1060        compact: &'static str,
1061    }
1062    impl Segment for ShrinkableSegment {
1063        fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
1064            Ok(Some(RenderedSegment::new(self.full)))
1065        }
1066        fn shrink_to_fit(
1067            &self,
1068            _ctx: &DataContext,
1069            _rc: &RenderContext,
1070            target: u16,
1071        ) -> Option<RenderedSegment> {
1072            let r = RenderedSegment::new(self.compact);
1073            (r.width <= target).then_some(r)
1074        }
1075        fn defaults(&self) -> SegmentDefaults {
1076            SegmentDefaults::with_priority(200)
1077        }
1078    }
1079
1080    /// Segment that always renders `text` and is priority-0 (never
1081    /// dropped under pressure). Used as the "anchor" in shrink tests
1082    /// so the reflow loop has only one droppable target.
1083    struct AnchorSegment(&'static str);
1084    impl Segment for AnchorSegment {
1085        fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
1086            Ok(Some(RenderedSegment::new(self.0)))
1087        }
1088        fn defaults(&self) -> SegmentDefaults {
1089            SegmentDefaults::with_priority(0)
1090        }
1091    }
1092
1093    #[test]
1094    fn shrink_to_fit_replaces_full_render_when_compact_form_fits() {
1095        // Engine-level pin: the reflow loop calls shrink_to_fit
1096        // before considering drop. Full = "longbranch * ↑2 ↓1" (18
1097        // cells), compact = "longbranch" (10 cells). KEEP is
1098        // priority-0 so it can't be the drop target — only the
1099        // shrinkable segment is eligible.
1100        let segments: Vec<Box<dyn Segment>> = vec![
1101            Box::new(ShrinkableSegment {
1102                full: "longbranch * ↑2 ↓1",
1103                compact: "longbranch",
1104            }),
1105            Box::new(AnchorSegment("KEEP")),
1106        ];
1107        let mut warnings = Vec::new();
1108        let line = render_with_warn(
1109            &segments,
1110            &empty_ctx(),
1111            17,
1112            &mut |m| warnings.push(m.to_string()),
1113            theme::default_theme(),
1114            theme::Capability::None,
1115            false,
1116        );
1117        // Full 18 + sep 1 + KEEP 4 = 23. Budget 17 → overflow 6.
1118        // shrink target = 18 - 6 = 12. Compact "longbranch" (10)
1119        // fits → "longbranch KEEP" (15 cells).
1120        assert_eq!(line, "longbranch KEEP");
1121    }
1122
1123    #[test]
1124    fn shrink_to_fit_falls_back_to_drop_when_compact_form_too_wide() {
1125        // Compact form is wider than target → engine rejects it,
1126        // falls through to drop (segment isn't truncatable).
1127        let segments: Vec<Box<dyn Segment>> = vec![
1128            Box::new(ShrinkableSegment {
1129                full: "longbranch",
1130                compact: "stilltoolongtruly",
1131            }),
1132            Box::new(AnchorSegment("X")),
1133        ];
1134        let mut warnings = Vec::new();
1135        let line = render_with_warn(
1136            &segments,
1137            &empty_ctx(),
1138            5,
1139            &mut |m| warnings.push(m.to_string()),
1140            theme::default_theme(),
1141            theme::Capability::None,
1142            false,
1143        );
1144        // Compact form 17 cells > target → reject → drop. Only the
1145        // anchor remains.
1146        assert_eq!(line, "X");
1147    }
1148
1149    #[test]
1150    fn shrink_to_fit_honors_configured_width_min_floor() {
1151        // A segment with `width.min = 8` configured: even though its
1152        // compact form is 5 cells (would otherwise fit a target ≥ 5),
1153        // the engine must reject the shrunk render and drop the
1154        // segment because the user contracted "at least 8 cells or
1155        // hide me." Pins parity with `apply_width_bounds` /
1156        // `try_reflow`.
1157        struct LowFloorShrink;
1158        impl Segment for LowFloorShrink {
1159            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1160                Ok(Some(RenderedSegment::new("longerprefix")))
1161            }
1162            fn shrink_to_fit(
1163                &self,
1164                _: &DataContext,
1165                _: &RenderContext,
1166                _target: u16,
1167            ) -> Option<RenderedSegment> {
1168                Some(RenderedSegment::new("five5"))
1169            }
1170            fn defaults(&self) -> SegmentDefaults {
1171                SegmentDefaults::with_priority(200)
1172                    .with_width(WidthBounds::new(8, u16::MAX).expect("valid"))
1173            }
1174        }
1175        let segments: Vec<Box<dyn Segment>> =
1176            vec![Box::new(LowFloorShrink), Box::new(AnchorSegment("X"))];
1177        let line = render_with_warn(
1178            &segments,
1179            &empty_ctx(),
1180            7,
1181            &mut |_| {},
1182            theme::default_theme(),
1183            theme::Capability::None,
1184            false,
1185        );
1186        // shrunk would deliver 5 cells, but width.min=8 → rejected,
1187        // segment drops. Only anchor remains.
1188        assert_eq!(line, "X");
1189    }
1190
1191    #[test]
1192    fn shrink_to_fit_rejects_too_wide_response_and_drops() {
1193        // A misbehaving segment ignores `target` and emits a render
1194        // wider than the engine asked for. The engine must reject
1195        // the response (preserving the layout-fit invariant) and
1196        // fall through to drop. The contract violation also fires
1197        // `lsm_warn!` (visible on stderr during test runs); that
1198        // side effect isn't captured by the warn closure passed to
1199        // `render_with_warn`, which only carries segment-render
1200        // errors — asserting layout outcome is the testable
1201        // contract here.
1202        struct MisbehavingSegment;
1203        impl Segment for MisbehavingSegment {
1204            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1205                Ok(Some(RenderedSegment::new("longbranch")))
1206            }
1207            fn shrink_to_fit(
1208                &self,
1209                _: &DataContext,
1210                _: &RenderContext,
1211                _target: u16,
1212            ) -> Option<RenderedSegment> {
1213                Some(RenderedSegment::new("stilltoolongtruly"))
1214            }
1215            fn defaults(&self) -> SegmentDefaults {
1216                SegmentDefaults::with_priority(200)
1217            }
1218        }
1219        let segments: Vec<Box<dyn Segment>> =
1220            vec![Box::new(MisbehavingSegment), Box::new(AnchorSegment("X"))];
1221        let line = render_with_warn(
1222            &segments,
1223            &empty_ctx(),
1224            5,
1225            &mut |_| {},
1226            theme::default_theme(),
1227            theme::Capability::None,
1228            false,
1229        );
1230        assert_eq!(line, "X");
1231    }
1232
1233    #[test]
1234    fn shrink_to_fit_runs_before_truncatable_end_ellipsis() {
1235        // A segment that's both truncatable AND has shrink_to_fit:
1236        // segment-side intelligence wins. The compact form replaces
1237        // the full render before generic end-ellipsis fires.
1238        struct DualSegment;
1239        impl Segment for DualSegment {
1240            fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
1241                Ok(Some(RenderedSegment::new("longprefix-with-tail")))
1242            }
1243            fn shrink_to_fit(
1244                &self,
1245                _ctx: &DataContext,
1246                _rc: &RenderContext,
1247                target: u16,
1248            ) -> Option<RenderedSegment> {
1249                let r = RenderedSegment::new("longprefix");
1250                (r.width <= target).then_some(r)
1251            }
1252            fn defaults(&self) -> SegmentDefaults {
1253                SegmentDefaults::with_priority(200).with_truncatable(true)
1254            }
1255        }
1256        let segments: Vec<Box<dyn Segment>> = vec![
1257            Box::new(DualSegment),
1258            Box::new(StubSegment(Ok(Some(RenderedSegment::new("X"))))),
1259        ];
1260        let mut warnings = Vec::new();
1261        let line = render_with_warn(
1262            &segments,
1263            &empty_ctx(),
1264            13,
1265            &mut |m| warnings.push(m.to_string()),
1266            theme::default_theme(),
1267            theme::Capability::None,
1268            false,
1269        );
1270        // Full = 20, X = 1, separator = 1 → total 22. Budget 13 →
1271        // overflow 9. shrink target = 20 - 9 = 11. Compact
1272        // "longprefix" (10) fits → "longprefix X" (12 cells).
1273        // No "…" appears because shrink_to_fit ran first.
1274        assert!(line.contains("longprefix"), "got {line:?}");
1275        assert!(!line.contains('…'), "no end-ellipsis: {line:?}");
1276    }
1277
1278    // --- render_to_runs ---------------------------------------------------
1279
1280    #[test]
1281    fn render_to_runs_empty_input_yields_no_runs() {
1282        let segments: Vec<Box<dyn Segment>> = vec![];
1283        let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1284        assert!(runs.is_empty());
1285    }
1286
1287    #[test]
1288    fn render_to_runs_emits_segment_then_separator_then_segment() {
1289        // Neither segment requested a role, so all three emitted runs
1290        // carry Style::default().
1291        let segments: Vec<Box<dyn Segment>> = vec![
1292            Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
1293            Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
1294        ];
1295        let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1296        assert_eq!(runs.len(), 3);
1297        assert_eq!(runs[0].text, "a");
1298        assert_eq!(runs[0].style, Style::default());
1299        assert_eq!(runs[1].text, " ");
1300        assert_eq!(runs[1].style, Style::default());
1301        assert_eq!(runs[2].text, "b");
1302        assert_eq!(runs[2].style, Style::default());
1303    }
1304
1305    #[test]
1306    fn render_to_runs_preserves_segment_style() {
1307        // The styled segment's role lands on its run unchanged; the
1308        // TUI consumer maps role → ratatui Color, so anything dropped
1309        // here would silently break themed preview.
1310        use crate::theme::Role;
1311        let segments: Vec<Box<dyn Segment>> = vec![
1312            Box::new(StubSegment(Ok(Some(RenderedSegment::new("plain"))))),
1313            Box::new(StubSegment(Ok(Some(
1314                RenderedSegment::new("warn").with_role(Role::Warning),
1315            )))),
1316        ];
1317        let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1318        assert_eq!(runs.len(), 3);
1319        assert_eq!(runs[2].text, "warn");
1320        assert_eq!(runs[2].style.role, Some(Role::Warning));
1321    }
1322
1323    #[test]
1324    fn render_to_runs_skips_separator_none_between_segments() {
1325        // `Separator::None` is "no gap"; the runs view skips it
1326        // entirely so consumers don't have to filter empty-text runs.
1327        let segments: Vec<Box<dyn Segment>> = vec![
1328            Box::new(StubSegment(Ok(Some(RenderedSegment::with_separator(
1329                "a",
1330                Separator::None,
1331            ))))),
1332            Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
1333        ];
1334        let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1335        assert_eq!(runs.len(), 2);
1336        assert_eq!(runs[0].text, "a");
1337        assert_eq!(runs[1].text, "b");
1338    }
1339
1340    #[test]
1341    fn render_to_runs_drops_segments_under_width_pressure() {
1342        // The runs view reflects post-layout state: dropped segments
1343        // produce no run, and the separator that would have followed
1344        // a dropped segment also vanishes.
1345        let segments: Vec<Box<dyn Segment>> = vec![
1346            Box::new(StubSegment(Ok(Some(
1347                RenderedSegment::new("keep").with_role(crate::theme::Role::Primary),
1348            )))),
1349            Box::new(DroppableStub("droppable")),
1350            Box::new(StubSegment(Ok(Some(RenderedSegment::new("anchor"))))),
1351        ];
1352        // Total: 4 + 1 + 9 + 1 + 6 = 21. Budget 12 forces the
1353        // priority-200 middle segment to drop.
1354        let runs = render_to_runs(&segments, &empty_ctx(), 12, &mut |_| {});
1355        let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
1356        assert_eq!(texts, vec!["keep", " ", "anchor"]);
1357    }
1358
1359    /// Build a styled multi-segment layout for round-trip tests:
1360    /// roled segments + a plain literal in the middle so both styled
1361    /// and unstyled run paths are exercised.
1362    fn round_trip_segments() -> Vec<Box<dyn Segment>> {
1363        use crate::theme::Role;
1364        vec![
1365            Box::new(StubSegment(Ok(Some(
1366                RenderedSegment::new("ctx").with_role(Role::Info),
1367            )))),
1368            Box::new(StubSegment(Ok(Some(RenderedSegment::new("|"))))),
1369            Box::new(StubSegment(Ok(Some(
1370                RenderedSegment::new("err").with_role(Role::Error),
1371            )))),
1372        ]
1373    }
1374
1375    fn round_trip_assert(terminal_width: u16, capability: theme::Capability, hyperlinks: bool) {
1376        let segments = round_trip_segments();
1377        let direct = render_with_warn(
1378            &segments,
1379            &empty_ctx(),
1380            terminal_width,
1381            &mut |_| {},
1382            theme::default_theme(),
1383            capability,
1384            hyperlinks,
1385        );
1386        let runs = render_to_runs(&segments, &empty_ctx(), terminal_width, &mut |_| {});
1387        let recomposed = runs_to_ansi(&runs, theme::default_theme(), capability, hyperlinks);
1388        assert_eq!(
1389            direct, recomposed,
1390            "cap={capability:?} width={terminal_width} hyperlinks={hyperlinks}"
1391        );
1392    }
1393
1394    #[test]
1395    fn render_to_runs_then_runs_to_ansi_matches_render_with_warn() {
1396        // Round-trip pin: `render_to_runs` → `runs_to_ansi` must match
1397        // `render_with_warn` byte-for-byte. The contract that lets
1398        // `render_with_warn` stay a thin wrapper.
1399        round_trip_assert(100, theme::Capability::Palette16, false);
1400    }
1401
1402    #[test]
1403    fn render_to_runs_round_trip_holds_under_capability_none() {
1404        // No-color path: every run goes through the `open.is_empty()`
1405        // branch in `runs_to_ansi`. A future change to `sgr_open`
1406        // returning a non-empty string for `Capability::None` would
1407        // silently leak escapes; this pins it.
1408        round_trip_assert(100, theme::Capability::None, false);
1409    }
1410
1411    #[test]
1412    fn render_to_runs_round_trip_holds_under_width_pressure() {
1413        // Width pressure forces `apply_layout` to drop a segment;
1414        // both emit paths must produce the same post-drop output.
1415        // `round_trip_segments` totals 9 cells; budget 5 drops the
1416        // rightmost priority-128 tie ("err"), leaving "ctx |".
1417        round_trip_assert(5, theme::Capability::Palette16, false);
1418    }
1419
1420    #[test]
1421    fn render_to_runs_round_trip_holds_with_hyperlinks_enabled() {
1422        // The `hyperlinks` bool must thread identically through both
1423        // emit paths. `round_trip_segments` carries no hyperlinks
1424        // today, so the equivalence is structural — a regression
1425        // where one path silently dropped the bool would still match
1426        // here. Adding a hyperlinked segment to the round-trip set
1427        // is a follow-up; this test names the bool-thread contract.
1428        round_trip_assert(100, theme::Capability::Palette16, true);
1429    }
1430
1431    #[test]
1432    fn render_to_runs_with_one_survivor_emits_no_trailing_separator() {
1433        // Drop pressure leaves a single segment. The `i + 1 < items.len()`
1434        // guard in `items_to_runs` must suppress the trailing separator;
1435        // otherwise the runs view ends with a stray " " run.
1436        let segments: Vec<Box<dyn Segment>> = vec![
1437            Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
1438            Box::new(DroppableStub("droppable")),
1439        ];
1440        // Total: 1 + 1 + 9 = 11. Budget 1 drops the priority-200
1441        // segment; "a" survives alone with no trailing separator.
1442        let runs = render_to_runs(&segments, &empty_ctx(), 1, &mut |_| {});
1443        assert_eq!(runs.len(), 1);
1444        assert_eq!(runs[0].text, "a");
1445    }
1446
1447    #[test]
1448    fn render_to_runs_emits_powerline_chevron_with_muted_role() {
1449        // Pins both the glyph and the `Role::Muted` style — a future
1450        // bg-transition restyle should land as an intentional update
1451        // to this assertion.
1452        use crate::theme::Role;
1453        struct PowerlineSeg;
1454        impl Segment for PowerlineSeg {
1455            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1456                Ok(Some(RenderedSegment::new("a").with_role(Role::Primary)))
1457            }
1458            fn defaults(&self) -> SegmentDefaults {
1459                SegmentDefaults::with_priority(10).with_default_separator(Separator::powerline())
1460            }
1461        }
1462        let segments: Vec<Box<dyn Segment>> = vec![
1463            Box::new(PowerlineSeg),
1464            Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
1465        ];
1466        let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1467        assert_eq!(runs.len(), 3);
1468        assert_eq!(runs[1].text, " \u{E0B0} ");
1469        assert_eq!(runs[1].style.role, Some(Role::Muted));
1470    }
1471
1472    #[test]
1473    fn powerline_separator_emits_padded_chevron_with_correct_width() {
1474        // The chevron is a Nerd Font glyph in the private-use range;
1475        // unicode-width doesn't know its cell count, so the layout's
1476        // total_width math depends on `Separator::width()`'s answer.
1477        // Pin both the emitted text (single-space pad on each side of
1478        // the chevron) and the reported width (1-cell chevron + 2
1479        // padding cells = 3).
1480        assert_eq!(Separator::powerline().width(), 3);
1481        assert_eq!(Separator::powerline().text(), " \u{E0B0} ");
1482    }
1483
1484    #[test]
1485    fn powerline_chevrons_are_charged_to_total_width_in_layout() {
1486        // total_width counts inter-segment separators. Three priority-0
1487        // segments at width 4 plus two powerline chevrons between them
1488        // = 4 + chev + 4 + chev + 4. A regression that stopped counting
1489        // Powerline width would silently push lines past budget.
1490        // Computed (not hardcoded) so a future change to the chevron's
1491        // padding-cell count fails this assertion at the right line.
1492        let item = |text: &str| Item {
1493            rendered: RenderedSegment::new(text),
1494            defaults: SegmentDefaults::with_priority(0)
1495                .with_default_separator(Separator::powerline()),
1496            segment: noop_segment(),
1497        };
1498        let items = vec![item("aaaa"), item("bbbb"), item("cccc")];
1499        let chev = u32::from(Separator::powerline().width());
1500        assert_eq!(total_width(&items), 4 + chev + 4 + chev + 4);
1501    }
1502
1503    #[test]
1504    fn render_with_warn_emits_powerline_chevron_wrapped_in_muted_sgr() {
1505        // End-to-end pin: drive two segments through `render_with_warn`
1506        // under Palette16 with powerline separators between them. The
1507        // output must contain the padded chevron wrapped in *some* SGR
1508        // open + reset; the exact bytes are computed from
1509        // `theme::sgr_open` for the Muted role on the default theme,
1510        // so this test adapts if the default theme's Muted color is
1511        // ever retuned. Decouples "chevron emits styled" from "the
1512        // exact ANSI code for Muted on theme X."
1513        struct PowerlineSeg(&'static str, theme::Role);
1514        impl Segment for PowerlineSeg {
1515            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1516                Ok(Some(RenderedSegment::new(self.0).with_role(self.1)))
1517            }
1518            fn defaults(&self) -> SegmentDefaults {
1519                SegmentDefaults::with_priority(10).with_default_separator(Separator::powerline())
1520            }
1521        }
1522        let segments: Vec<Box<dyn Segment>> = vec![
1523            Box::new(PowerlineSeg("a", theme::Role::Primary)),
1524            Box::new(PowerlineSeg("b", theme::Role::Info)),
1525        ];
1526        let line = render_with_warn(
1527            &segments,
1528            &empty_ctx(),
1529            100,
1530            &mut |_| {},
1531            theme::default_theme(),
1532            theme::Capability::Palette16,
1533            false,
1534        );
1535        let muted_sgr = theme::sgr_open(
1536            &Style::role(theme::Role::Muted),
1537            theme::default_theme(),
1538            theme::Capability::Palette16,
1539        );
1540        let expected = format!("{muted_sgr} \u{E0B0} \x1b[0m");
1541        assert!(
1542            line.contains(&expected),
1543            "padded chevron with Muted SGR not in line: {line:?} (expected substring: {expected:?})"
1544        );
1545    }
1546
1547    #[test]
1548    fn render_to_runs_emits_literal_separator_with_default_style() {
1549        // `Separator::Literal(" | ")` from the segment's defaults
1550        // becomes a separator run with that exact text and
1551        // Style::default() — separators don't inherit segment styling.
1552        struct PipeSepSegment;
1553        impl Segment for PipeSepSegment {
1554            fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1555                Ok(Some(
1556                    RenderedSegment::new("a").with_role(crate::theme::Role::Warning),
1557                ))
1558            }
1559            fn defaults(&self) -> SegmentDefaults {
1560                SegmentDefaults::with_priority(10)
1561                    .with_default_separator(Separator::Literal(Cow::Borrowed(" | ")))
1562            }
1563        }
1564        let segments: Vec<Box<dyn Segment>> = vec![
1565            Box::new(PipeSepSegment),
1566            Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
1567        ];
1568        let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1569        assert_eq!(runs.len(), 3);
1570        assert_eq!(runs[1].text, " | ");
1571        assert_eq!(runs[1].style, Style::default());
1572    }
1573
1574    #[test]
1575    fn runs_to_ansi_emits_osc8_around_styled_run_when_hyperlinks_supported() {
1576        // Pin the OSC 8 envelope and its order: the link wraps
1577        // *outside* the SGR pair so the link survives `sgr_reset`.
1578        // Bytes asserted explicitly so a future change to the OSC 8
1579        // emitter (BEL terminator, different escape) is caught.
1580        use crate::theme::Role;
1581        let runs = vec![StyledRun::new(
1582            "branch",
1583            Style::role(Role::Primary).with_hyperlink("https://example.com/b"),
1584        )];
1585        let out = runs_to_ansi(
1586            &runs,
1587            theme::default_theme(),
1588            theme::Capability::Palette16,
1589            true,
1590        );
1591        assert_eq!(
1592            out, "\x1b]8;;https://example.com/b\x1b\\\x1b[95mbranch\x1b[0m\x1b]8;;\x1b\\",
1593            "got {out:?}"
1594        );
1595    }
1596
1597    #[test]
1598    fn runs_to_ansi_drops_hyperlink_when_not_supported() {
1599        // `hyperlinks = false` must produce zero OSC 8 bytes; the
1600        // run still emits with its SGR styling. The URL is dropped
1601        // silently — capable terminals get the link, others get the
1602        // text.
1603        use crate::theme::Role;
1604        let runs = vec![StyledRun::new(
1605            "branch",
1606            Style::role(Role::Primary).with_hyperlink("https://example.com/b"),
1607        )];
1608        let out = runs_to_ansi(
1609            &runs,
1610            theme::default_theme(),
1611            theme::Capability::Palette16,
1612            false,
1613        );
1614        assert_eq!(out, "\x1b[95mbranch\x1b[0m");
1615        assert!(!out.contains("\x1b]8"), "no OSC 8: {out:?}");
1616    }
1617
1618    #[test]
1619    fn runs_to_ansi_emits_no_osc8_when_style_has_no_hyperlink() {
1620        // `hyperlinks = true` is permission, not obligation: a run
1621        // with `Style.hyperlink = None` emits no OSC 8 even when the
1622        // terminal supports it.
1623        let runs = vec![StyledRun::new("plain", Style::default())];
1624        let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1625        assert_eq!(out, "plain");
1626        assert!(!out.contains("\x1b]8"), "no OSC 8: {out:?}");
1627    }
1628
1629    #[test]
1630    fn runs_to_ansi_emits_osc8_around_unstyled_run() {
1631        // An unstyled run with a hyperlink still gets OSC 8: the link
1632        // is independent of color/decoration. The text passes through
1633        // without an SGR pair.
1634        let runs = vec![StyledRun::new(
1635            "click",
1636            Style::default().with_hyperlink("https://example.com"),
1637        )];
1638        let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1639        assert_eq!(out, "\x1b]8;;https://example.com\x1b\\click\x1b]8;;\x1b\\");
1640    }
1641
1642    #[test]
1643    fn osc8_pair_balanced_when_hyperlinked_run_is_truncated() {
1644        // Truncation rewrites the run's text; the OSC 8 wrapper sits
1645        // outside text in `runs_to_ansi`, so truncated text still
1646        // emits a balanced OSC 8 open/close. Pins the design
1647        // contract: hyperlinks live on `Style`, never in `text`, so
1648        // there's no escape-sequence inside the string for
1649        // truncation to split.
1650        let mut rendered = RenderedSegment::new("very-long-branch-name")
1651            .with_style(Style::default().with_hyperlink("https://example.com/branch"));
1652        rendered = truncate_to(rendered, 8);
1653        // Truncation produces "very-lo…" (7 graphemes + ellipsis = 8 cells).
1654        let runs = vec![StyledRun::new(
1655            rendered.text().to_string(),
1656            rendered.style.clone(),
1657        )];
1658        let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1659        assert!(
1660            out.starts_with("\x1b]8;;https://example.com/branch\x1b\\"),
1661            "OSC 8 open present: {out:?}"
1662        );
1663        assert!(
1664            out.ends_with("\x1b]8;;\x1b\\"),
1665            "OSC 8 close present: {out:?}"
1666        );
1667        assert!(out.contains('…'), "truncation marker preserved: {out:?}");
1668        assert_eq!(
1669            out.matches("\x1b]8;;").count(),
1670            2,
1671            "exactly one open and one close: {out:?}"
1672        );
1673    }
1674
1675    #[test]
1676    fn osc8_pair_balanced_when_hyperlinked_run_truncated_to_zero() {
1677        // truncate_to(_, 0) yields empty text + preserved style. The
1678        // OSC 8 pair must still be balanced — emitting a half-open
1679        // envelope would break every later byte on the line.
1680        let rendered = RenderedSegment::new("anything")
1681            .with_style(Style::default().with_hyperlink("https://example.com"));
1682        let truncated = truncate_to(rendered, 0);
1683        let runs = vec![StyledRun::new(
1684            truncated.text().to_string(),
1685            truncated.style.clone(),
1686        )];
1687        let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1688        assert_eq!(
1689            out, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\",
1690            "empty-text run still emits balanced OSC 8 pair: {out:?}"
1691        );
1692    }
1693
1694    #[test]
1695    fn runs_to_ansi_emits_independent_osc8_pairs_for_adjacent_hyperlinked_runs() {
1696        // Adjacent runs with different links must each get their own
1697        // open/close pair — no nesting, no leak across the boundary.
1698        // Pins the per-run scoping of OSC 8 emission.
1699        let runs = vec![
1700            StyledRun::new("a", Style::default().with_hyperlink("https://a.example")),
1701            StyledRun::new("b", Style::default().with_hyperlink("https://b.example")),
1702        ];
1703        let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1704        assert_eq!(
1705            out,
1706            "\x1b]8;;https://a.example\x1b\\a\x1b]8;;\x1b\\\x1b]8;;https://b.example\x1b\\b\x1b]8;;\x1b\\"
1707        );
1708        assert_eq!(out.matches("\x1b]8;;").count(), 4, "two opens + two closes");
1709    }
1710
1711    #[test]
1712    fn push_osc8_open_strips_control_chars_from_url() {
1713        // Security regression: a URL with embedded ESC `\` would
1714        // close the OSC 8 envelope early, turning the rest of the
1715        // line into raw control sequences. `push_osc8_open` strips
1716        // control bytes before emit. The bare `\` survives but
1717        // cannot reconstitute a String Terminator without the
1718        // stripped ESC.
1719        let runs = vec![StyledRun::new(
1720            "x",
1721            Style::default().with_hyperlink("https://example.com\x1b\\evil\x07more"),
1722        )];
1723        let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1724        // Exactly one OSC 8 open and one close — the embedded ESC `\`
1725        // can't smuggle a second close into the output.
1726        assert_eq!(
1727            out.matches("\x1b]8;;").count(),
1728            2,
1729            "exactly one pair: {out:?}"
1730        );
1731        assert!(!out.contains("\x1b\\evil"), "ESC \\ stripped: {out:?}");
1732        assert!(!out.contains('\x07'), "BEL stripped: {out:?}");
1733        assert!(
1734            out.contains("https://example.com\\evilmore"),
1735            "non-control chars survive: {out:?}"
1736        );
1737    }
1738
1739    #[test]
1740    fn push_osc8_open_strips_c1_string_terminator_and_nul() {
1741        // `char::is_control()` covers C0 (0x00-0x1F, 0x7F) and C1
1742        // (0x80-0x9F). The most plausible bypass via the C1 range is
1743        // 0x9C (single-byte ST in 8-bit terminals); NUL and DEL are
1744        // the other classics. Pin that all three are stripped so a
1745        // future change to the sanitizer can't quietly narrow the
1746        // filter.
1747        let runs = vec![StyledRun::new(
1748            "x",
1749            Style::default().with_hyperlink("https://a.example\x00b\x7fc\u{009C}d"),
1750        )];
1751        let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1752        assert_eq!(out.matches("\x1b]8;;").count(), 2, "single pair: {out:?}");
1753        assert!(!out.contains('\x00'), "NUL stripped: {out:?}");
1754        assert!(!out.contains('\x7f'), "DEL stripped: {out:?}");
1755        assert!(!out.contains('\u{009C}'), "C1 ST stripped: {out:?}");
1756        assert!(out.contains("https://a.examplebcd"));
1757    }
1758
1759    #[test]
1760    fn runs_to_ansi_capability_none_emits_unwrapped_text() {
1761        // Pin the no-color emit path independent of layout: a run
1762        // with a styled role + Capability::None must produce zero
1763        // ANSI escapes. Catches a regression where `sgr_open` would
1764        // start emitting decoration codes for the no-color tier.
1765        use crate::theme::Role;
1766        let runs = vec![
1767            StyledRun::new("plain", Style::default()),
1768            StyledRun::new(" ", Style::default()),
1769            StyledRun::new("warn", Style::role(Role::Warning)),
1770        ];
1771        let out = runs_to_ansi(
1772            &runs,
1773            theme::default_theme(),
1774            theme::Capability::None,
1775            false,
1776        );
1777        assert_eq!(out, "plain warn");
1778        assert!(!out.contains('\x1b'), "unexpected ANSI escape: {out:?}");
1779    }
1780
1781    /// Stub for the drop-under-pressure run test: priority-200 so it
1782    /// becomes the layout's first drop target. `StubSegment`'s default
1783    /// priority (128) wouldn't be eligible against the anchors.
1784    struct DroppableStub(&'static str);
1785    impl Segment for DroppableStub {
1786        fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1787            Ok(Some(RenderedSegment::new(self.0)))
1788        }
1789        fn defaults(&self) -> SegmentDefaults {
1790            SegmentDefaults::with_priority(200)
1791        }
1792    }
1793}