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_walkthrough::{DirectionUnit, StandardWalkthroughGuide};
13
14/// Max contract members named inline in a coordination fact before collapsing
15/// the rest into a "+N more" suffix. Keeps the most load-bearing line readable
16/// in a terminal without discarding the trailing guidance.
17pub const MAX_CONTRACT_MEMBERS: usize = 6;
18
19/// Honest file accounting for the walkthrough header + status, reconciled so the
20/// numbers add up: `staged + cleared + excluded == changed`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct WalkthroughAccounting {
23    /// Total changed files in the diff (the engine's `triage.files`, the real set).
24    pub changed: usize,
25    /// Source units shown in a stage (in `direction.order`, not collapsed).
26    pub staged: usize,
27    /// Source units collapsed into the Cleared panel (de-prioritized + viewed).
28    pub cleared: usize,
29    /// Non-source files in the diff that carry no contract to review (migrations,
30    /// lockfiles, config, docs): counted, never silently dropped.
31    pub excluded: usize,
32}
33
34impl WalkthroughAccounting {
35    /// Reconcile the change into `staged + cleared + excluded`.
36    ///
37    /// `staged` is the count of direction units that REMAIN in a stage after the
38    /// de-prioritized and viewed files collapse out. `cleared` is the
39    /// de-prioritized escape hatch plus any staged-then-viewed files. `excluded`
40    /// is the remainder of the real changed set that is not a reviewable source
41    /// unit. The header renders `staged + cleared + excluded` (which equals
42    /// `changed` whenever the engine's changed count is the real set), so the
43    /// header, the status line, and reality agree, and no file is counted twice.
44    #[must_use]
45    pub fn compute(guide: &StandardWalkthroughGuide, viewed: &[String]) -> Self {
46        // Each direction unit lands in exactly one rendered bucket: collapsed into
47        // Cleared (de-prioritized OR viewed) or visible in a stage.
48        let mut staged_visible = 0usize;
49        let mut collapsed = 0usize;
50        for file in &guide.direction.order {
51            if is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed) {
52                collapsed += 1;
53            } else {
54                staged_visible += 1;
55            }
56        }
57        // De-prioritized NON-source files (if any ever appear) plus de-prioritized
58        // source files all live under Cleared; the loop above already counted the
59        // source ones (they are in `direction.order`). Add de-prioritized files NOT
60        // in the order so the escape hatch stays fully accounted.
61        let deprioritized_off_spine = guide
62            .digest
63            .focus
64            .deprioritized
65            .iter()
66            .filter(|u| !guide.direction.order.iter().any(|f| f == &u.file))
67            .count();
68        let cleared = collapsed + deprioritized_off_spine;
69        // Source units the engine analyzed (review_here + deprioritized). The diff
70        // may also touch non-source files (counted in `changed` but never in the
71        // focus map); surface them as the excluded bucket instead of dropping them.
72        let source_units = guide.digest.focus.total_units();
73        let changed = guide.digest.triage.files;
74        let excluded = changed.saturating_sub(source_units);
75        WalkthroughAccounting {
76            changed,
77            staged: staged_visible,
78            cleared,
79            excluded,
80        }
81    }
82
83    /// The honest "files in this change" total the header should display:
84    /// `staged + cleared + excluded`. Equal to `changed` on a normal change; the
85    /// `max` guards the rare case where the engine's count lags the parts.
86    #[must_use]
87    pub fn header_total(&self) -> usize {
88        (self.staged + self.cleared + self.excluded).max(self.changed)
89    }
90}
91
92/// The clean, surface-agnostic fact text for a decision question, for the tour.
93///
94/// The raw wire `question` leads with `` `<anchor_file>` `` (which every render
95/// surface ALREADY shows as the row's leading path), inlines the full, unbounded
96/// contract-member list, and ends with the decision's open question. For a guided
97/// tour this: strips the redundant leading path, caps the member list to
98/// `max_members` names + "+N more", and DROPS the trailing question (the section
99/// header frames the action once, and the question is still carried in the
100/// decisions brief and the JSON, where each decision stands alone). The result is
101/// plain prose (no backticks), so a markdown surface needs no escaping and a human
102/// surface needs no truncation.
103#[must_use]
104pub fn clean_decision_fact(question: &str, anchor_file: &str, max_members: usize) -> String {
105    let stripped = strip_leading_path(question, anchor_file);
106    let capped = cap_member_list(&stripped, max_members);
107    drop_trailing_question(&capped)
108}
109
110/// Drop a leading `` `<anchor_file>` `` token (with one trailing space) from the
111/// question, so the path is not printed a second time after the row's path.
112fn strip_leading_path(question: &str, anchor_file: &str) -> String {
113    let prefix = format!("`{anchor_file}` ");
114    question
115        .strip_prefix(&prefix)
116        .map_or_else(|| question.to_string(), str::to_string)
117}
118
119/// Cap the FIRST parenthesized comma-list (the contract members) to
120/// `max_members` names, replacing the overflow with "+N more". Text outside that
121/// first parenthetical (including the trailing question) is preserved verbatim.
122fn cap_member_list(text: &str, max_members: usize) -> String {
123    let Some(open) = text.find('(') else {
124        return text.to_string();
125    };
126    let Some(rel_close) = text[open..].find(')') else {
127        return text.to_string();
128    };
129    let close = open + rel_close;
130    let inner = &text[open + 1..close];
131    // Only collapse a genuine member list (comma-separated identifiers), never a
132    // prose parenthetical like "(env)" or "(the cache)".
133    let members: Vec<&str> = inner.split(", ").collect();
134    if members.len() <= max_members {
135        return text.to_string();
136    }
137    let shown = members[..max_members].join(", ");
138    let more = members.len() - max_members;
139    format!(
140        "{}({shown}, +{more} more){}",
141        &text[..open],
142        &text[close + 1..]
143    )
144}
145
146/// Drop a trailing decision question (a sentence ending in `?`) so a guided tour
147/// shows the plain observation, not a per-file question. The decision's open
148/// question is still carried in the decisions brief and the JSON, where each
149/// decision stands alone; in the tour the section header frames the action once,
150/// so a question repeated on every row reads as a wall of the same sentence.
151fn drop_trailing_question(text: &str) -> String {
152    let parts: Vec<&str> = text.split(". ").collect();
153    let mut end = parts.len();
154    while end > 0 && parts[end - 1].trim_end().ends_with('?') {
155        end -= 1;
156    }
157    // Nothing trailing was a question (end unchanged), or the whole text is a
158    // question (end hit 0): leave it as-is rather than emit an empty fragment.
159    if end == parts.len() || end == 0 {
160        return text.to_string();
161    }
162    let kept = parts[..end].join(". ");
163    if kept.ends_with(['.', '!', '?']) {
164        kept
165    } else {
166        format!("{kept}.")
167    }
168}
169
170/// Cap an arbitrary list of names for inline display: first `max` names, then a
171/// "+N more" sentinel. Shared by the out-of-diff consumer fact in both surfaces.
172#[must_use]
173pub fn cap_names(names: &[String], max: usize) -> (Vec<&str>, usize) {
174    let shown: Vec<&str> = names.iter().take(max).map(String::as_str).collect();
175    let more = names.len().saturating_sub(shown.len());
176    (shown, more)
177}
178
179/// Whether a staged file collapses into Cleared instead of showing in its stage.
180/// A file is cleared when the local viewed-state marked it seen (the
181/// `--mark-viewed` collapse): it must appear ONLY under Cleared, never in both.
182#[must_use]
183fn is_collapsed_into_cleared(file: &str, viewed: &[String]) -> bool {
184    viewed.iter().any(|v| v == file)
185}
186
187/// Whether `file` is a de-prioritized focus unit. The `--help` contract is that
188/// de-prioritized files collapse INTO the Cleared panel, so they must be removed
189/// from their stage row and shown only under Cleared.
190#[must_use]
191fn is_deprioritized(guide: &StandardWalkthroughGuide, file: &str) -> bool {
192    guide
193        .digest
194        .focus
195        .deprioritized
196        .iter()
197        .any(|u| u.file == file)
198}
199
200/// True when a direction unit collapses out of its stage and into Cleared,
201/// because it is de-prioritized OR locally viewed. Each file is then in exactly
202/// one rendered place (stage XOR cleared).
203#[must_use]
204fn collapses_into_cleared(guide: &StandardWalkthroughGuide, file: &str, viewed: &[String]) -> bool {
205    is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed)
206}
207
208/// The visible stage members for a guide: direction units in order, MINUS any
209/// file collapsed into Cleared (de-prioritized or viewed). Returned as references
210/// into the guide so the caller can render rows without cloning.
211#[must_use]
212pub fn visible_stage_units<'a>(
213    guide: &'a StandardWalkthroughGuide,
214    viewed: &[String],
215) -> Vec<&'a DirectionUnit> {
216    guide
217        .direction
218        .order
219        .iter()
220        .filter(|file| !collapses_into_cleared(guide, file, viewed))
221        .filter_map(|file| guide.direction.units.iter().find(|u| &u.file == file))
222        .collect()
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::audit_brief::{
229        DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts, ReviewBriefSchemaVersion,
230        ReviewDeltas, ReviewEffort, RiskClass, StandardReviewBriefOutput,
231    };
232    use crate::audit_decision_surface::DecisionSurface;
233    use crate::audit_focus::{FocusLabel, FocusMap, FocusScore, FocusUnit};
234    use crate::audit_routing::RoutingFacts;
235    use crate::audit_walkthrough::{
236        AgentSchema, DirectionUnit, INJECTION_NOTE, ReviewDirection, StandardWalkthroughGuide,
237    };
238
239    fn focus_unit(file: &str, label: FocusLabel) -> FocusUnit {
240        FocusUnit {
241            file: file.to_string(),
242            score: FocusScore::default(),
243            label,
244            reason: format!("reason for {file}"),
245            confidence: Vec::new(),
246        }
247    }
248
249    fn dir_unit(file: &str) -> DirectionUnit {
250        DirectionUnit {
251            file: file.to_string(),
252            concern_lens: "orientation".to_string(),
253            scoring_budget: 1,
254            out_of_diff: Vec::new(),
255            expert: Vec::new(),
256        }
257    }
258
259    /// A guide whose direction is the review_here + deprioritized source units (as
260    /// the engine builds it), with `changed` total files that may exceed the source
261    /// unit count (the non-source excluded bucket).
262    fn guide_for(
263        review_here: &[&str],
264        deprioritized: &[&str],
265        changed_total: usize,
266    ) -> StandardWalkthroughGuide {
267        let order: Vec<String> = review_here
268            .iter()
269            .chain(deprioritized.iter())
270            .map(|s| (*s).to_string())
271            .collect();
272        let units: Vec<DirectionUnit> = order.iter().map(|f| dir_unit(f)).collect();
273        let digest = StandardReviewBriefOutput {
274            schema_version: ReviewBriefSchemaVersion::default(),
275            version: "test".to_string(),
276            command: "audit-brief".to_string(),
277            triage: DiffTriage {
278                files: changed_total,
279                hunks: None,
280                net_lines: None,
281                risk_class: RiskClass::Medium,
282                review_effort: ReviewEffort::Review,
283            },
284            graph_facts: GraphFacts {
285                exports_added: 0,
286                api_width_delta: 0,
287                reachable_from: Vec::new(),
288                boundaries_touched: Vec::new(),
289            },
290            partition: PartitionFacts::default(),
291            impact_closure: ImpactClosureFacts::default(),
292            focus: FocusMap {
293                review_here: review_here
294                    .iter()
295                    .map(|f| focus_unit(f, FocusLabel::ReviewHere))
296                    .collect(),
297                deprioritized: deprioritized
298                    .iter()
299                    .map(|f| focus_unit(f, FocusLabel::NotPrioritized))
300                    .collect(),
301            },
302            deltas: ReviewDeltas::default(),
303            weakening: Vec::new(),
304            routing: RoutingFacts::default(),
305            decisions: DecisionSurface::default(),
306        };
307        StandardWalkthroughGuide {
308            schema_version: ReviewBriefSchemaVersion::default(),
309            version: "test".to_string(),
310            command: "review-walkthrough-guide".to_string(),
311            graph_snapshot_hash: "hash1".to_string(),
312            digest,
313            direction: ReviewDirection { order, units },
314            change_anchors: Vec::new(),
315            agent_schema: AgentSchema {
316                judgment_shape: "",
317                echo_field: "graph_snapshot_hash",
318                anchoring_rule: "",
319            },
320            injection_note: INJECTION_NOTE,
321        }
322    }
323
324    #[test]
325    fn accounting_reconciles_staged_cleared_excluded() {
326        // 16 changed files: 2 review-here source, 1 de-prioritized source, 13
327        // non-source (migrations/config/docs). No viewed.
328        let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 16);
329        let acc = WalkthroughAccounting::compute(&guide, &[]);
330        assert_eq!(acc.changed, 16);
331        assert_eq!(acc.staged, 2, "review-here source units stay in stages");
332        assert_eq!(acc.cleared, 1, "de-prioritized collapses into cleared");
333        assert_eq!(
334            acc.excluded, 13,
335            "non-source files are excluded, not dropped"
336        );
337        // The header total accounts for the whole changed set.
338        assert_eq!(acc.header_total(), 16);
339        assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
340    }
341
342    #[test]
343    fn viewed_file_moves_from_staged_to_cleared() {
344        let guide = guide_for(&["src/a.ts", "src/b.ts"], &[], 2);
345        let viewed = vec!["src/a.ts".to_string()];
346        let acc = WalkthroughAccounting::compute(&guide, &viewed);
347        assert_eq!(acc.staged, 1, "the viewed file left the stage");
348        assert_eq!(acc.cleared, 1, "the viewed file is counted in cleared");
349        assert_eq!(acc.excluded, 0);
350        assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
351    }
352
353    #[test]
354    fn deprioritized_and_viewed_appear_in_exactly_one_place() {
355        let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 3);
356        let viewed = vec!["src/a.ts".to_string()];
357        // a.ts is viewed -> cleared; c.ts is de-prioritized -> cleared; only b.ts
358        // remains visible in a stage.
359        let visible = visible_stage_units(&guide, &viewed);
360        let files: Vec<&str> = visible.iter().map(|u| u.file.as_str()).collect();
361        assert_eq!(files, vec!["src/b.ts"]);
362        assert!(collapses_into_cleared(&guide, "src/a.ts", &viewed));
363        assert!(collapses_into_cleared(&guide, "src/c.ts", &viewed));
364        assert!(!collapses_into_cleared(&guide, "src/b.ts", &viewed));
365    }
366
367    #[test]
368    fn strips_leading_path_caps_members_and_drops_question() {
369        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?";
370        let out = clean_decision_fact(q, "src/db/schema.ts", 3);
371        // The leading path is gone (printed once by the row).
372        assert!(
373            !out.starts_with("`src/db/schema.ts`"),
374            "leading path must be stripped: {out}"
375        );
376        // The member list is capped with a "+N more".
377        assert!(out.contains("(a, b, c, +5 more)"), "got: {out}");
378        // The trailing decision question is dropped in the tour (it lives in the brief).
379        assert!(
380            !out.contains('?'),
381            "trailing question must be dropped: {out}"
382        );
383        assert!(
384            out.ends_with("outside this PR."),
385            "the observation survives, ending cleanly: {out}"
386        );
387        // No backticks remain to be escaped.
388        assert!(!out.contains('`'), "no backticks remain: {out}");
389    }
390
391    #[test]
392    fn short_member_list_is_kept_and_question_dropped() {
393        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?";
394        let out = clean_decision_fact(q, "src/lib/r2.ts", 6);
395        assert_eq!(
396            out,
397            "changes exports (getR2, getR2Text) imported by 6 files outside this PR."
398        );
399    }
400
401    #[test]
402    fn single_member_prose_parenthetical_is_kept_question_dropped() {
403        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?";
404        let out = clean_decision_fact(q, "src/lib/env.ts", 6);
405        assert!(out.contains("(env)"), "single member kept: {out}");
406        assert!(!out.contains('?'), "trailing question dropped: {out}");
407        assert!(out.ends_with("outside this PR."), "observation kept: {out}");
408    }
409
410    #[test]
411    fn non_anchor_path_is_kept_but_question_dropped() {
412        // A boundary question names a DIFFERENT path than the anchor; its leading
413        // token is not stripped, but the tour still drops the trailing question.
414        let q = "`ui` now imports `db` for the first time. Intended coupling, or should this edge not exist?";
415        let out = clean_decision_fact(q, "src/ui/page.ts", 6);
416        assert_eq!(out, "`ui` now imports `db` for the first time.");
417    }
418
419    #[test]
420    fn public_api_surface_question_drops_to_one_sentence() {
421        // The consolidated public-API-surface decision has no leading path and no
422        // member parenthetical; the tour keeps only the one observation sentence,
423        // dropping the trailing "Intended as maintained contracts ...?" question.
424        let q = "This change adds 3 exports to the public API surface. Intended as maintained contracts, or should they stay internal?";
425        let out = clean_decision_fact(q, "src/lib/id.ts", 6);
426        assert_eq!(out, "This change adds 3 exports to the public API surface.");
427    }
428
429    #[test]
430    fn cap_names_first_k_then_more() {
431        let names = vec![
432            "a".to_string(),
433            "b".to_string(),
434            "c".to_string(),
435            "d".to_string(),
436        ];
437        let (shown, more) = cap_names(&names, 2);
438        assert_eq!(shown, vec!["a", "b"]);
439        assert_eq!(more, 2);
440    }
441}