Skip to main content

fallow_output/
walkthrough_render.rs

1//! Shared, render-surface-agnostic helpers for the review walkthrough (W2).
2//!
3//! Both the human terminal renderer (`fallow-cli`) and the markdown renderer
4//! (`fallow-api`) project the SAME [`StandardWalkthroughGuide`] into a staged
5//! tour. The per-file "why" fact line, the staged/cleared membership split, and
6//! the file-accounting math were independently re-derived in each surface and
7//! drifted (double-counted files, a path printed twice, mid-word truncation,
8//! escaped backticks). This module centralizes that shared logic as pure
9//! functions so the two surfaces stay consistent by construction and the wire
10//! contracts (`--walkthrough-guide` JSON, audit/brief) are never touched.
11
12use crate::audit_decision_surface::Decision;
13use crate::audit_walkthrough::{DirectionUnit, StandardWalkthroughGuide};
14
15/// The surfaced decisions whose `anchor_file` is not a direction unit, in
16/// surface (rank) order. A dependency decision anchors on a `package.json`,
17/// which is never a graph module, so without this a dependency-only change
18/// renders as "0 files" while the JSON carries the top-ranked decision.
19#[must_use]
20pub fn decisions_outside_units(guide: &StandardWalkthroughGuide) -> Vec<&Decision> {
21    guide
22        .digest
23        .decisions
24        .decisions
25        .iter()
26        .filter(|decision| {
27            !guide
28                .direction
29                .order
30                .iter()
31                .any(|file| file == &decision.anchor_file)
32        })
33        .collect()
34}
35
36/// Max contract members named inline in a coordination fact before collapsing
37/// the rest into a "+N more" suffix. Keeps the most load-bearing line readable
38/// in a terminal without discarding the trailing guidance.
39pub const MAX_CONTRACT_MEMBERS: usize = 6;
40
41/// Honest file accounting for the walkthrough header + status, reconciled so the
42/// numbers add up: `staged + cleared + excluded == changed`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct WalkthroughAccounting {
45    /// Total changed files in the diff (the engine's `triage.files`, the real set).
46    pub changed: usize,
47    /// Source units shown in a stage (in `direction.order`, not collapsed).
48    pub staged: usize,
49    /// Source units collapsed into the Cleared panel (de-prioritized + viewed).
50    pub cleared: usize,
51    /// Non-source files in the diff that carry no contract to review (migrations,
52    /// lockfiles, config, docs): counted, never silently dropped.
53    pub excluded: usize,
54}
55
56impl WalkthroughAccounting {
57    /// Reconcile the change into `staged + cleared + excluded`.
58    ///
59    /// `staged` is the count of direction units that REMAIN in a stage after the
60    /// de-prioritized and viewed files collapse out. `cleared` is the
61    /// de-prioritized escape hatch plus any staged-then-viewed files. `excluded`
62    /// is the remainder of the real changed set that is not a reviewable source
63    /// unit. The header renders `staged + cleared + excluded` (which equals
64    /// `changed` whenever the engine's changed count is the real set), so the
65    /// header, the status line, and reality agree, and no file is counted twice.
66    #[must_use]
67    pub fn compute(guide: &StandardWalkthroughGuide, viewed: &[String]) -> Self {
68        // Each direction unit lands in exactly one rendered bucket: collapsed into
69        // Cleared (de-prioritized OR viewed) or visible in a stage.
70        let mut staged_visible = 0usize;
71        let mut collapsed = 0usize;
72        for file in &guide.direction.order {
73            if is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed) {
74                collapsed += 1;
75            } else {
76                staged_visible += 1;
77            }
78        }
79        // De-prioritized NON-source files (if any ever appear) plus de-prioritized
80        // source files all live under Cleared; the loop above already counted the
81        // source ones (they are in `direction.order`). Add de-prioritized files NOT
82        // in the order so the escape hatch stays fully accounted.
83        let deprioritized_off_spine = guide
84            .digest
85            .focus
86            .deprioritized
87            .iter()
88            .filter(|u| !guide.direction.order.iter().any(|f| f == &u.file))
89            .count();
90        let cleared = collapsed + deprioritized_off_spine;
91        // Source units the engine analyzed (review_here + deprioritized). The diff
92        // may also touch non-source files (counted in `changed` but never in the
93        // focus map); surface them as the excluded bucket instead of dropping them.
94        let source_units = guide.digest.focus.total_units();
95        let changed = guide.digest.triage.files;
96        let excluded = changed.saturating_sub(source_units);
97        WalkthroughAccounting {
98            changed,
99            staged: staged_visible,
100            cleared,
101            excluded,
102        }
103    }
104
105    /// The honest "files in this change" total the header should display:
106    /// `staged + cleared + excluded`. Equal to `changed` on a normal change; the
107    /// `max` guards the rare case where the engine's count lags the parts.
108    #[must_use]
109    pub fn header_total(&self) -> usize {
110        (self.staged + self.cleared + self.excluded).max(self.changed)
111    }
112}
113
114/// The clean, surface-agnostic fact text for a decision question, for the tour.
115///
116/// The raw wire `question` leads with `` `<anchor_file>` `` (which every render
117/// surface ALREADY shows as the row's leading path), inlines the full, unbounded
118/// contract-member list, and ends with the decision's open question. For a guided
119/// tour this: strips the redundant leading path, caps the member list to
120/// `max_members` names + "+N more", and DROPS the trailing question (the section
121/// header frames the action once, and the question is still carried in the
122/// decisions brief and the JSON, where each decision stands alone). The result is
123/// plain prose (no backticks), so a markdown surface needs no escaping and a human
124/// surface needs no truncation.
125#[must_use]
126pub fn clean_decision_fact(question: &str, anchor_file: &str, max_members: usize) -> String {
127    let stripped = strip_leading_path(question, anchor_file);
128    let capped = cap_member_list(&stripped, max_members);
129    drop_trailing_question(&capped)
130}
131
132/// Drop a leading `` `<anchor_file>` `` token (with one trailing space) from the
133/// question, so the path is not printed a second time after the row's path.
134fn strip_leading_path(question: &str, anchor_file: &str) -> String {
135    let prefix = format!("`{anchor_file}` ");
136    question
137        .strip_prefix(&prefix)
138        .map_or_else(|| question.to_string(), str::to_string)
139}
140
141/// Cap the first parenthesized comma-list (the contract members) to
142/// `max_members` names, replacing the overflow with "+N more". Text outside that
143/// parenthetical (including the trailing question) is preserved verbatim.
144///
145/// The scan skips parentheticals that hold no `", "`, so a single-token one is
146/// passed over rather than ending the search. Route-group directory segments
147/// make this load-bearing: an anchor path like `app/(marketing)/lib/api.ts` puts
148/// `(marketing)` ahead of the member list in every question that still carries
149/// its path, and stopping there would leave the real list uncapped.
150///
151/// Public because the human brief needs the cap WITHOUT the rest of
152/// [`clean_decision_fact`]: the brief prints no separate anchor path and the
153/// question is the judgment it exists to pose, so it has to keep both.
154///
155/// A member can carry its own parenthetical, for example a dependency marked
156/// `` `vitest` (dev) ``. The scan matches nested parentheses and splits the list
157/// only at its own depth, so such a marker neither ends the list early nor
158/// counts as a separate member.
159#[must_use]
160pub fn cap_member_list(text: &str, max_members: usize) -> String {
161    if max_members == 0 {
162        return text.to_string();
163    }
164    let mut from = 0usize;
165    while let Some(rel_open) = text[from..].find('(') {
166        let open = from + rel_open;
167        // An unclosed `(` (for example in an anchor path) is not the list.
168        // Skip it, so the list after it is still capped.
169        let Some(close) = matching_close_paren(text, open) else {
170            from = open + 1;
171            continue;
172        };
173        let inner = &text[open + 1..close];
174        let members = split_top_level_members(inner);
175        // Only collapse a genuine member list (comma-separated identifiers), never
176        // a prose parenthetical like "(env)", "(the cache)" or "(marketing)".
177        if members.len() < 2 {
178            from = open + 1;
179            continue;
180        }
181        if members.len() <= max_members {
182            return text.to_string();
183        }
184        let shown = members[..max_members].join(", ");
185        let more = members.len() - max_members;
186        return format!(
187            "{}({shown}, +{more} more){}",
188            &text[..open],
189            &text[close + 1..]
190        );
191    }
192    text.to_string()
193}
194
195/// The byte offset of the `)` that closes the `(` at `open`, or `None` when the
196/// text leaves it unclosed.
197fn matching_close_paren(text: &str, open: usize) -> Option<usize> {
198    let mut depth = 0usize;
199    for (offset, ch) in text[open..].char_indices() {
200        match ch {
201            '(' => depth += 1,
202            ')' => {
203                depth -= 1;
204                if depth == 0 {
205                    return Some(open + offset);
206                }
207            }
208            _ => {}
209        }
210    }
211    None
212}
213
214/// Split a parenthetical at each `", "` outside a nested parenthetical.
215fn split_top_level_members(inner: &str) -> Vec<&str> {
216    let mut members = Vec::new();
217    let mut depth = 0usize;
218    let mut start = 0usize;
219    for (offset, ch) in inner.char_indices() {
220        match ch {
221            '(' => depth += 1,
222            ')' => depth = depth.saturating_sub(1),
223            ',' if depth == 0 && inner[offset..].starts_with(", ") => {
224                members.push(&inner[start..offset]);
225                start = offset + 2;
226            }
227            _ => {}
228        }
229    }
230    members.push(&inner[start..]);
231    members
232}
233
234/// Drop a trailing decision question (a sentence ending in `?`) so a guided tour
235/// shows the plain observation, not a per-file question. The decision's open
236/// question is still carried in the decisions brief and the JSON, where each
237/// decision stands alone; in the tour the section header frames the action once,
238/// so a question repeated on every row reads as a wall of the same sentence.
239fn drop_trailing_question(text: &str) -> String {
240    let parts: Vec<&str> = text.split(". ").collect();
241    let mut end = parts.len();
242    while end > 0 && parts[end - 1].trim_end().ends_with('?') {
243        end -= 1;
244    }
245    // Nothing trailing was a question (end unchanged), or the whole text is a
246    // question (end hit 0): leave it as-is rather than emit an empty fragment.
247    if end == parts.len() || end == 0 {
248        return text.to_string();
249    }
250    let kept = parts[..end].join(". ");
251    if kept.ends_with(['.', '!', '?']) {
252        kept
253    } else {
254        format!("{kept}.")
255    }
256}
257
258/// Cap an arbitrary list of names for inline display: first `max` names, then a
259/// "+N more" sentinel. Shared by the out-of-diff consumer fact in both surfaces.
260#[must_use]
261pub fn cap_names(names: &[String], max: usize) -> (Vec<&str>, usize) {
262    let shown: Vec<&str> = names.iter().take(max).map(String::as_str).collect();
263    let more = names.len().saturating_sub(shown.len());
264    (shown, more)
265}
266
267/// Whether a staged file collapses into Cleared instead of showing in its stage.
268/// A file is cleared when the local viewed-state marked it seen (the
269/// `--mark-viewed` collapse): it must appear ONLY under Cleared, never in both.
270#[must_use]
271fn is_collapsed_into_cleared(file: &str, viewed: &[String]) -> bool {
272    viewed.iter().any(|v| v == file)
273}
274
275/// Whether `file` is a de-prioritized focus unit. The `--help` contract is that
276/// de-prioritized files collapse INTO the Cleared panel, so they must be removed
277/// from their stage row and shown only under Cleared.
278#[must_use]
279fn is_deprioritized(guide: &StandardWalkthroughGuide, file: &str) -> bool {
280    guide
281        .digest
282        .focus
283        .deprioritized
284        .iter()
285        .any(|u| u.file == file)
286}
287
288/// True when a direction unit collapses out of its stage and into Cleared,
289/// because it is de-prioritized OR locally viewed. Each file is then in exactly
290/// one rendered place (stage XOR cleared).
291#[must_use]
292fn collapses_into_cleared(guide: &StandardWalkthroughGuide, file: &str, viewed: &[String]) -> bool {
293    is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed)
294}
295
296/// The visible stage members for a guide: direction units in order, MINUS any
297/// file collapsed into Cleared (de-prioritized or viewed). Returned as references
298/// into the guide so the caller can render rows without cloning.
299#[must_use]
300pub fn visible_stage_units<'a>(
301    guide: &'a StandardWalkthroughGuide,
302    viewed: &[String],
303) -> Vec<&'a DirectionUnit> {
304    guide
305        .direction
306        .order
307        .iter()
308        .filter(|file| !collapses_into_cleared(guide, file, viewed))
309        .filter_map(|file| guide.direction.units.iter().find(|u| &u.file == file))
310        .collect()
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::audit_brief::{
317        DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts, ReviewBriefSchemaVersion,
318        ReviewDeltas, ReviewEffort, RiskClass, StandardReviewBriefOutput,
319    };
320    use crate::audit_decision_surface::DecisionSurface;
321    use crate::audit_focus::{FocusLabel, FocusMap, FocusScore, FocusUnit};
322    use crate::audit_routing::RoutingFacts;
323    use crate::audit_walkthrough::{
324        AgentSchema, DirectionUnit, INJECTION_NOTE, ReviewDirection, StandardWalkthroughGuide,
325    };
326
327    fn focus_unit(file: &str, label: FocusLabel) -> FocusUnit {
328        FocusUnit {
329            file: file.to_string(),
330            score: FocusScore::default(),
331            label,
332            reason: format!("reason for {file}"),
333            confidence: Vec::new(),
334        }
335    }
336
337    fn dir_unit(file: &str) -> DirectionUnit {
338        DirectionUnit {
339            file: file.to_string(),
340            concern_lens: "orientation".to_string(),
341            scoring_budget: 1,
342            out_of_diff: Vec::new(),
343            expert: Vec::new(),
344            test_adjacency: None,
345        }
346    }
347
348    /// A guide whose direction is the review_here + deprioritized source units (as
349    /// the engine builds it), with `changed` total files that may exceed the source
350    /// unit count (the non-source excluded bucket).
351    fn guide_for(
352        review_here: &[&str],
353        deprioritized: &[&str],
354        changed_total: usize,
355    ) -> StandardWalkthroughGuide {
356        let order: Vec<String> = review_here
357            .iter()
358            .chain(deprioritized.iter())
359            .map(|s| (*s).to_string())
360            .collect();
361        let units: Vec<DirectionUnit> = order.iter().map(|f| dir_unit(f)).collect();
362        let digest = StandardReviewBriefOutput {
363            branching: None,
364            schema_version: ReviewBriefSchemaVersion::default(),
365            version: "test".to_string(),
366            command: "audit-brief".to_string(),
367            triage: DiffTriage {
368                files: changed_total,
369                hunks: None,
370                net_lines: None,
371                risk_class: RiskClass::Medium,
372                review_effort: ReviewEffort::Review,
373            },
374            graph_facts: GraphFacts {
375                exports_added: 0,
376                api_width_delta: 0,
377                boundaries_touched: Vec::new(),
378            },
379            partition: PartitionFacts::default(),
380            impact_closure: ImpactClosureFacts::default(),
381            focus: FocusMap {
382                review_here: review_here
383                    .iter()
384                    .map(|f| focus_unit(f, FocusLabel::ReviewHere))
385                    .collect(),
386                deprioritized: deprioritized
387                    .iter()
388                    .map(|f| focus_unit(f, FocusLabel::NotPrioritized))
389                    .collect(),
390            },
391            deltas: ReviewDeltas::default(),
392            weakening: Vec::new(),
393            routing: RoutingFacts::default(),
394            ownership: None,
395            decisions: DecisionSurface::default(),
396        };
397        StandardWalkthroughGuide {
398            schema_version: ReviewBriefSchemaVersion::default(),
399            version: "test".to_string(),
400            command: "review-walkthrough-guide".to_string(),
401            graph_snapshot_hash: "hash1".to_string(),
402            digest,
403            direction: ReviewDirection { order, units },
404            change_anchors: Vec::new(),
405            agent_schema: AgentSchema {
406                judgment_shape: "",
407                echo_field: "graph_snapshot_hash",
408                anchoring_rule: "",
409                action_vocabulary: &[],
410                concern_vocabulary: &[],
411            },
412            injection_note: INJECTION_NOTE,
413        }
414    }
415
416    #[test]
417    fn accounting_reconciles_staged_cleared_excluded() {
418        // 16 changed files: 2 review-here source, 1 de-prioritized source, 13
419        // non-source (migrations/config/docs). No viewed.
420        let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 16);
421        let acc = WalkthroughAccounting::compute(&guide, &[]);
422        assert_eq!(acc.changed, 16);
423        assert_eq!(acc.staged, 2, "review-here source units stay in stages");
424        assert_eq!(acc.cleared, 1, "de-prioritized collapses into cleared");
425        assert_eq!(
426            acc.excluded, 13,
427            "non-source files are excluded, not dropped"
428        );
429        // The header total accounts for the whole changed set.
430        assert_eq!(acc.header_total(), 16);
431        assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
432    }
433
434    #[test]
435    fn viewed_file_moves_from_staged_to_cleared() {
436        let guide = guide_for(&["src/a.ts", "src/b.ts"], &[], 2);
437        let viewed = vec!["src/a.ts".to_string()];
438        let acc = WalkthroughAccounting::compute(&guide, &viewed);
439        assert_eq!(acc.staged, 1, "the viewed file left the stage");
440        assert_eq!(acc.cleared, 1, "the viewed file is counted in cleared");
441        assert_eq!(acc.excluded, 0);
442        assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
443    }
444
445    #[test]
446    fn deprioritized_and_viewed_appear_in_exactly_one_place() {
447        let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 3);
448        let viewed = vec!["src/a.ts".to_string()];
449        // a.ts is viewed -> cleared; c.ts is de-prioritized -> cleared; only b.ts
450        // remains visible in a stage.
451        let visible = visible_stage_units(&guide, &viewed);
452        let files: Vec<&str> = visible.iter().map(|u| u.file.as_str()).collect();
453        assert_eq!(files, vec!["src/b.ts"]);
454        assert!(collapses_into_cleared(&guide, "src/a.ts", &viewed));
455        assert!(collapses_into_cleared(&guide, "src/c.ts", &viewed));
456        assert!(!collapses_into_cleared(&guide, "src/b.ts", &viewed));
457    }
458
459    #[test]
460    fn strips_leading_path_caps_members_and_drops_question() {
461        let q = "`src/db/schema.ts` changes exports (a, b, c, d, e, f, g, h) imported by 32 files outside this PR. Does this change break or alter what those callers expect?";
462        let out = clean_decision_fact(q, "src/db/schema.ts", 3);
463        // The leading path is gone (printed once by the row).
464        assert!(
465            !out.starts_with("`src/db/schema.ts`"),
466            "leading path must be stripped: {out}"
467        );
468        // The member list is capped with a "+N more".
469        assert!(out.contains("(a, b, c, +5 more)"), "got: {out}");
470        // The trailing decision question is dropped in the tour (it lives in the brief).
471        assert!(
472            !out.contains('?'),
473            "trailing question must be dropped: {out}"
474        );
475        assert!(
476            out.ends_with("outside this PR."),
477            "the observation survives, ending cleanly: {out}"
478        );
479        // No backticks remain to be escaped.
480        assert!(!out.contains('`'), "no backticks remain: {out}");
481    }
482
483    #[test]
484    fn short_member_list_is_kept_and_question_dropped() {
485        let q = "`src/lib/r2.ts` changes exports (getR2, getR2Text) imported by 6 files outside this PR. Does this change break or alter what those callers expect?";
486        let out = clean_decision_fact(q, "src/lib/r2.ts", 6);
487        assert_eq!(
488            out,
489            "changes exports (getR2, getR2Text) imported by 6 files outside this PR."
490        );
491    }
492
493    #[test]
494    fn single_member_prose_parenthetical_is_kept_question_dropped() {
495        let q = "`src/lib/env.ts` changes export (env) imported by 22 files outside this PR. Does this change break or alter what those callers expect?";
496        let out = clean_decision_fact(q, "src/lib/env.ts", 6);
497        assert!(out.contains("(env)"), "single member kept: {out}");
498        assert!(!out.contains('?'), "trailing question dropped: {out}");
499        assert!(out.ends_with("outside this PR."), "observation kept: {out}");
500    }
501
502    #[test]
503    fn a_route_group_segment_is_not_mistaken_for_the_member_list() {
504        // Next.js and SvelteKit route groups are parenthesized directory segments,
505        // so a raw question that still carries its anchor path offers `(marketing)`
506        // as the first parenthetical. The brief caps without stripping that path,
507        // and the export list is what has to collapse.
508        let q = "`app/(marketing)/lib/api.ts` changes exports (a, b, c, d, e) imported by 9 files outside this PR.";
509        let out = cap_member_list(q, 3);
510        assert!(
511            out.starts_with("`app/(marketing)/lib/api.ts` "),
512            "the route group survives verbatim: {out}"
513        );
514        assert!(
515            out.contains("(a, b, c, +2 more)"),
516            "the export list is the parenthetical that collapses: {out}"
517        );
518    }
519
520    #[test]
521    fn a_route_group_anchor_caps_the_tour_fact_too() {
522        let q = "`src/routes/(app)/+page.server.ts` changes exports (load, actions, prerender, ssr) imported by 4 files outside this PR. Does this change break or alter what those callers expect?";
523        let out = clean_decision_fact(q, "src/routes/(app)/+page.server.ts", 2);
524        assert_eq!(
525            out,
526            "changes exports (load, actions, +2 more) imported by 4 files outside this PR."
527        );
528    }
529
530    #[test]
531    fn a_prose_parenthetical_before_the_members_does_not_end_the_scan() {
532        // "(env)" is a single token, so it is passed over rather than accepted as a
533        // one-member list that reports nothing to collapse.
534        let out = cap_member_list(
535            "changes exports (env) and (a, b, c, d) imported by 2 files outside this PR.",
536            2,
537        );
538        assert_eq!(
539            out,
540            "changes exports (env) and (a, b, +2 more) imported by 2 files outside this PR."
541        );
542    }
543
544    #[test]
545    fn a_dev_marker_inside_the_list_does_not_split_it() {
546        // Dependency questions mark a dev dependency with "(dev)" inside the
547        // member list. Before the nested match, the scan closed the list at the
548        // first "(dev)" and left five members uncapped.
549        let q = "`package.json` moves 5 dependencies across a major version (`a` 1 -> 2, \
550                 `b` (dev) 1 -> 2, `c` 1 -> 2, `d` (dev) 1 -> 2, `e` 1 -> 2), imported by \
551                 9 in-repo modules. Which changes reach those importers?";
552        assert_eq!(
553            cap_member_list(q, 3),
554            "`package.json` moves 5 dependencies across a major version (`a` 1 -> 2, \
555             `b` (dev) 1 -> 2, `c` 1 -> 2, +2 more), imported by 9 in-repo modules. \
556             Which changes reach those importers?"
557        );
558    }
559
560    #[test]
561    fn an_unclosed_parenthetical_leaves_the_text_unchanged() {
562        let q = "changes exports (a, b, c, d imported by 2 files";
563        assert_eq!(cap_member_list(q, 2), q);
564    }
565
566    #[test]
567    fn an_unclosed_parenthesis_in_the_path_does_not_stop_the_scan() {
568        let q = "`src/a(b.ts` changes exports (a, b, c, d, e) imported by 2 files";
569        assert_eq!(
570            cap_member_list(q, 3),
571            "`src/a(b.ts` changes exports (a, b, c, +2 more) imported by 2 files"
572        );
573    }
574
575    #[test]
576    fn a_cap_of_zero_leaves_the_text_unchanged() {
577        let q = "changes exports (a, b, c) imported by 2 files";
578        assert_eq!(cap_member_list(q, 0), q);
579    }
580
581    #[test]
582    fn non_anchor_path_is_kept_but_question_dropped() {
583        // A boundary question names a DIFFERENT path than the anchor; its leading
584        // token is not stripped, but the tour still drops the trailing question.
585        let q = "`ui` now imports `db` for the first time. Intended coupling, or should this edge not exist?";
586        let out = clean_decision_fact(q, "src/ui/page.ts", 6);
587        assert_eq!(out, "`ui` now imports `db` for the first time.");
588    }
589
590    #[test]
591    fn public_api_surface_question_drops_to_one_sentence() {
592        // The consolidated public-API-surface decision has no leading path and no
593        // member parenthetical; the tour keeps only the one observation sentence,
594        // dropping the trailing "Intended as maintained contracts ...?" question.
595        let q = "This change adds 3 exports to the public API surface. Intended as maintained contracts, or should they stay internal?";
596        let out = clean_decision_fact(q, "src/lib/id.ts", 6);
597        assert_eq!(out, "This change adds 3 exports to the public API surface.");
598    }
599
600    #[test]
601    fn cap_names_first_k_then_more() {
602        let names = vec![
603            "a".to_string(),
604            "b".to_string(),
605            "c".to_string(),
606            "d".to_string(),
607        ];
608        let (shown, more) = cap_names(&names, 2);
609        assert_eq!(shown, vec!["a", "b"]);
610        assert_eq!(more, 2);
611    }
612}