Skip to main content

linesmith_core/layout/
decision.rs

1//! Typed events the layout engine emits at its five silent decision
2//! sites — priority-drop, `shrink_to_fit` success, truncatable
3//! end-ellipsis reflow, `apply_width_bounds` under-min drop, and
4//! `apply_width_bounds` over-max truncate — per ADR-0026.
5//!
6//! Production stdout pays zero observable cost: the engine emits via
7//! `LayoutObservers::emit_with(impl FnOnce -> LayoutDecision)` (lsm-rbvv),
8//! so `LayoutDecision` construction is deferred behind the
9//! observer-presence check. The TUI live preview (lsm-dtdq) collects
10//! these events to render per-segment status badges.
11//!
12//! The enum itself is intentionally NOT `#[non_exhaustive]` —
13//! consumers' exhaustive `match` should break at compile time when a
14//! sixth variant lands. Per-variant struct bodies ARE
15//! `#[non_exhaustive]` so the engine can add a sixth field without
16//! breaking pattern-matchers.
17
18use std::borrow::Cow;
19
20/// Event emitted by the layout engine when it takes one of five
21/// decisions under width pressure. The `id` field on every variant
22/// is borrowed from [`crate::segments::LineItem::Segment.id`] (per
23/// ADR-0026); built-in segment ids carry as `Cow::Borrowed` so the
24/// production path stays allocation-free.
25///
26/// Engine emit sites use the `pub(crate)` constructors below to pick
27/// up the `debug_assert!`'d width-relation invariants for free.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum LayoutDecision {
30    /// The reflow loop dropped a segment outright (no shrink form
31    /// via `try_shrink`, no truncatable end-ellipsis feasible via
32    /// `try_reflow`, or simply not `truncatable`). Fires whenever
33    /// the engine removes a segment under width pressure.
34    #[non_exhaustive]
35    PriorityDrop {
36        id: Cow<'static, str>,
37        /// Engine `defaults().priority` at drop time. `> 0`
38        /// (priority-0 is pinned per `highest_priority_droppable`).
39        priority: u8,
40        /// Terminal cell budget at the call site
41        /// (`apply_layout`'s `terminal_width` parameter).
42        terminal_width: u16,
43        /// `total_width() - budget` at loop entry; `>= 1`.
44        /// `u32` mirrors `total_width()`'s overflow-safe accumulator
45        /// so a layout with many wide segments can't wrap `u16`.
46        overflow: u32,
47        /// Pre-drop `rendered.width` of the segment removed (not the
48        /// line delta). May be `0` — empty-render segments are valid
49        /// drop targets when separator pressure forces slot removal.
50        dropped_width: u16,
51    },
52    /// `try_shrink` returned a valid compact render. `from` is the
53    /// pre-shrink width, `to` is the post-shrink width, `target` is
54    /// the width the reflow loop asked the segment to fit under.
55    /// Invariant: `to <= target < from`.
56    #[non_exhaustive]
57    ShrinkApplied {
58        id: Cow<'static, str>,
59        from: u16,
60        to: u16,
61        target: u16,
62    },
63    /// `try_reflow` succeeded with an end-ellipsis on a `truncatable`
64    /// segment. Same `from`/`to`/`target` shape as `ShrinkApplied`.
65    #[non_exhaustive]
66    ReflowApplied {
67        id: Cow<'static, str>,
68        from: u16,
69        to: u16,
70        target: u16,
71    },
72    /// `apply_width_bounds` returned `None` because the rendered
73    /// width fell below the configured `width.min` — the segment is
74    /// hidden rather than displayed at a wrong width.
75    #[non_exhaustive]
76    WidthBoundUnderMinDrop {
77        id: Cow<'static, str>,
78        rendered_width: u16,
79        min: u16,
80    },
81    /// `apply_width_bounds` clipped a too-wide render via
82    /// `truncate_to` (end-ellipsis) because `rendered_width > max`.
83    /// Emitted at the `apply_width_bounds` call site, NOT inside
84    /// `truncate_to` itself (which is also reached from `try_reflow`,
85    /// where the emit is `ReflowApplied`).
86    #[non_exhaustive]
87    WidthBoundOverMaxTruncate {
88        id: Cow<'static, str>,
89        rendered_width: u16,
90        max: u16,
91    },
92}
93
94impl LayoutDecision {
95    /// Engine-recommended remediation phrasing for the decision, or
96    /// `None` when the decision doesn't have a user-actionable fix.
97    /// Returns `&'static str` because every remediation is a literal;
98    /// reach for `format!` and the signature stops compiling, which
99    /// keeps the table in one place and testable.
100    ///
101    /// Per-variant match (no `_` arm) so a future sixth variant
102    /// forces the author to declare its remediation intent at
103    /// compile time rather than silently inheriting `None`.
104    #[must_use]
105    pub fn remediation(&self) -> Option<&'static str> {
106        match self {
107            Self::ShrinkApplied { .. } => Some("Set `width.max` to clamp earlier"),
108            Self::WidthBoundOverMaxTruncate { .. } => {
109                Some("Increase `width.max` or lower `priority`")
110            }
111            Self::PriorityDrop { .. } => None,
112            Self::ReflowApplied { .. } => None,
113            Self::WidthBoundUnderMinDrop { .. } => None,
114        }
115    }
116
117    /// `priority > 0`: `apply_layout`'s `highest_priority_droppable` filter
118    /// excludes priority-0 segments; the assertion catches a future bypass.
119    /// `overflow >= 1` mirrors the engine invariant: the loop only enters
120    /// this branch when `total > budget`. `dropped_width == 0` is allowed —
121    /// a zero-cell render (`RenderedSegment::new("")`) is a valid drop
122    /// target when separator pressure forces removal of the slot itself.
123    #[must_use]
124    pub(crate) fn priority_drop(
125        id: Cow<'static, str>,
126        priority: u8,
127        terminal_width: u16,
128        overflow: u32,
129        dropped_width: u16,
130    ) -> Self {
131        debug_assert!(
132            priority > 0 && overflow >= 1,
133            "PriorityDrop invariants: priority>0, overflow>=1 (got priority={priority}, overflow={overflow})"
134        );
135        Self::PriorityDrop {
136            id,
137            priority,
138            terminal_width,
139            overflow,
140            dropped_width,
141        }
142    }
143
144    /// `apply_layout` enforces `overflow >= 1` so `target < from` holds;
145    /// the assertion catches a future refactor that drops that precondition.
146    #[must_use]
147    pub(crate) fn shrink_applied(id: Cow<'static, str>, from: u16, to: u16, target: u16) -> Self {
148        debug_assert!(
149            to <= target && target < from,
150            "ShrinkApplied requires to <= target < from (got from={from}, to={to}, target={target})"
151        );
152        Self::ShrinkApplied {
153            id,
154            from,
155            to,
156            target,
157        }
158    }
159
160    /// Same width-relation invariant as [`shrink_applied`](Self::shrink_applied).
161    #[must_use]
162    pub(crate) fn reflow_applied(id: Cow<'static, str>, from: u16, to: u16, target: u16) -> Self {
163        debug_assert!(
164            to <= target && target < from,
165            "ReflowApplied requires to <= target < from (got from={from}, to={to}, target={target})"
166        );
167        Self::ReflowApplied {
168            id,
169            from,
170            to,
171            target,
172        }
173    }
174
175    #[must_use]
176    pub(crate) fn width_bound_under_min_drop(
177        id: Cow<'static, str>,
178        rendered_width: u16,
179        min: u16,
180    ) -> Self {
181        debug_assert!(
182            rendered_width < min,
183            "WidthBoundUnderMinDrop requires rendered_width < min (got rendered_width={rendered_width}, min={min})"
184        );
185        Self::WidthBoundUnderMinDrop {
186            id,
187            rendered_width,
188            min,
189        }
190    }
191
192    #[must_use]
193    pub(crate) fn width_bound_over_max_truncate(
194        id: Cow<'static, str>,
195        rendered_width: u16,
196        max: u16,
197    ) -> Self {
198        debug_assert!(
199            rendered_width > max,
200            "WidthBoundOverMaxTruncate requires rendered_width > max (got rendered_width={rendered_width}, max={max})"
201        );
202        Self::WidthBoundOverMaxTruncate {
203            id,
204            rendered_width,
205            max,
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn remediation_pins_per_variant_phrasing() {
216        // Per-variant exhaustive: both `Some` arms quote the literal
217        // verbatim (a swap of the two remediation strings would trip
218        // here), and all three `None` arms are confirmed.
219        let shrink = LayoutDecision::shrink_applied(Cow::Borrowed("git"), 20, 17, 17);
220        let over_max = LayoutDecision::width_bound_over_max_truncate(Cow::Borrowed("git"), 50, 30);
221        let drop = LayoutDecision::priority_drop(Cow::Borrowed("git"), 200, 80, 12, 6);
222        let reflow = LayoutDecision::reflow_applied(Cow::Borrowed("git"), 20, 17, 17);
223        let under_min = LayoutDecision::width_bound_under_min_drop(Cow::Borrowed("git"), 3, 5);
224
225        assert_eq!(
226            shrink.remediation(),
227            Some("Set `width.max` to clamp earlier")
228        );
229        assert_eq!(
230            over_max.remediation(),
231            Some("Increase `width.max` or lower `priority`")
232        );
233        assert!(drop.remediation().is_none());
234        assert!(reflow.remediation().is_none());
235        assert!(under_min.remediation().is_none());
236    }
237
238    #[test]
239    fn constructors_accept_invariant_boundaries() {
240        // Boundary-acceptance table: pin the `==` and adjacent edges
241        // of each invariant so a future refactor that tightens `<=`
242        // to `<` (or vice versa) is caught.
243
244        // Shrink/Reflow: `to == target` (the `<=` boundary) accepted.
245        let _ = LayoutDecision::shrink_applied(Cow::Borrowed("a"), 20, 17, 17);
246        let _ = LayoutDecision::reflow_applied(Cow::Borrowed("a"), 20, 17, 17);
247        // Shrink/Reflow: `to < target` (other side of `<=`) accepted.
248        let _ = LayoutDecision::shrink_applied(Cow::Borrowed("a"), 20, 16, 17);
249        let _ = LayoutDecision::reflow_applied(Cow::Borrowed("a"), 20, 16, 17);
250
251        // PriorityDrop: priority=1 + overflow=1 at their minimum
252        // non-zero boundaries; dropped_width is unconstrained, so
253        // `dropped_width=1` here is a typical value, not a boundary.
254        let _ = LayoutDecision::priority_drop(Cow::Borrowed("a"), 1, 80, 1, 1);
255
256        // Width bounds: rendered = bound ± 1 accepted (the strict
257        // `<`/`>` boundary).
258        let _ = LayoutDecision::width_bound_under_min_drop(Cow::Borrowed("a"), 4, 5);
259        let _ = LayoutDecision::width_bound_over_max_truncate(Cow::Borrowed("a"), 6, 5);
260    }
261
262    // Compile-time witness that `LayoutDecision: Eq`. ADR-0026 §C
263    // requires the derive; `PartialEq` is exercised at runtime in
264    // `derives_clone_debug_partial_eq` below, but `Eq` is a marker
265    // trait with no runtime surface — so pin it at compile time.
266    const _ASSERT_EQ_DERIVED: fn() = || {
267        fn assert_eq<T: Eq>() {}
268        assert_eq::<LayoutDecision>();
269    };
270
271    #[test]
272    fn derives_clone_debug_partial_eq() {
273        let d = LayoutDecision::shrink_applied(Cow::Borrowed("git"), 20, 17, 17);
274        let cloned = d.clone();
275        assert_eq!(d, cloned);
276        let dbg = format!("{d:?}");
277        assert!(dbg.contains("ShrinkApplied"), "got {dbg:?}");
278        // Variant name is the load-bearing piece; pin the id-field
279        // shape too so a debug-format regression that drops fields
280        // surfaces.
281        assert!(dbg.contains("id: \"git\""), "got {dbg:?}");
282    }
283
284    // The eleven #[should_panic] tests below depend on `debug_assert!`
285    // firing, which doesn't happen under `cargo test --release`. Gate
286    // each on `cfg(debug_assertions)` so release-profile runs stay green.
287
288    #[test]
289    #[cfg(debug_assertions)]
290    #[should_panic(expected = "PriorityDrop invariants: priority>0, overflow>=1")]
291    fn priority_drop_panics_in_debug_when_priority_is_zero() {
292        let _ = LayoutDecision::priority_drop(Cow::Borrowed("git"), 0, 80, 12, 6);
293    }
294
295    #[test]
296    #[cfg(debug_assertions)]
297    #[should_panic(expected = "PriorityDrop invariants: priority>0, overflow>=1")]
298    fn priority_drop_panics_when_overflow_is_zero() {
299        let _ = LayoutDecision::priority_drop(Cow::Borrowed("git"), 200, 80, 0, 6);
300    }
301
302    #[test]
303    fn priority_drop_accepts_zero_dropped_width() {
304        // Zero-cell renders (e.g. `RenderedSegment::new("")`) can be
305        // selected for drop when separator pressure forces slot removal;
306        // the constructor must not panic on `dropped_width == 0`.
307        let _ = LayoutDecision::priority_drop(Cow::Borrowed("empty"), 200, 80, 5, 0);
308    }
309
310    #[test]
311    #[cfg(debug_assertions)]
312    #[should_panic(expected = "ShrinkApplied requires to <= target < from")]
313    fn shrink_applied_panics_when_target_not_below_from() {
314        // target == from violates target < from.
315        let _ = LayoutDecision::shrink_applied(Cow::Borrowed("git"), 20, 17, 20);
316    }
317
318    #[test]
319    #[cfg(debug_assertions)]
320    #[should_panic(expected = "ShrinkApplied requires to <= target < from")]
321    fn shrink_applied_panics_when_to_above_target() {
322        let _ = LayoutDecision::shrink_applied(Cow::Borrowed("git"), 20, 18, 17);
323    }
324
325    #[test]
326    #[cfg(debug_assertions)]
327    #[should_panic(expected = "ReflowApplied requires to <= target < from")]
328    fn reflow_applied_panics_when_target_not_below_from() {
329        let _ = LayoutDecision::reflow_applied(Cow::Borrowed("git"), 20, 17, 20);
330    }
331
332    #[test]
333    #[cfg(debug_assertions)]
334    #[should_panic(expected = "ReflowApplied requires to <= target < from")]
335    fn reflow_applied_panics_when_to_above_target() {
336        let _ = LayoutDecision::reflow_applied(Cow::Borrowed("git"), 20, 18, 17);
337    }
338
339    #[test]
340    #[cfg(debug_assertions)]
341    #[should_panic(expected = "ShrinkApplied requires to <= target < from")]
342    fn shrink_applied_panics_when_target_above_from() {
343        // target > from (target=21, from=20) — distinct failure mode
344        // from target == from. A refactor that wrote `target <= from`
345        // would catch case 2 (== from) but accept this case.
346        let _ = LayoutDecision::shrink_applied(Cow::Borrowed("git"), 20, 17, 21);
347    }
348
349    #[test]
350    #[cfg(debug_assertions)]
351    #[should_panic(expected = "ReflowApplied requires to <= target < from")]
352    fn reflow_applied_panics_when_target_above_from() {
353        let _ = LayoutDecision::reflow_applied(Cow::Borrowed("git"), 20, 17, 21);
354    }
355
356    #[test]
357    #[cfg(debug_assertions)]
358    #[should_panic(expected = "WidthBoundUnderMinDrop requires rendered_width < min")]
359    fn under_min_drop_panics_when_rendered_at_or_above_min() {
360        let _ = LayoutDecision::width_bound_under_min_drop(Cow::Borrowed("git"), 5, 5);
361    }
362
363    #[test]
364    #[cfg(debug_assertions)]
365    #[should_panic(expected = "WidthBoundOverMaxTruncate requires rendered_width > max")]
366    fn over_max_truncate_panics_when_rendered_at_or_below_max() {
367        let _ = LayoutDecision::width_bound_over_max_truncate(Cow::Borrowed("git"), 30, 30);
368    }
369}