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/// first parenthetical (including the trailing question) is preserved verbatim.
144fn cap_member_list(text: &str, max_members: usize) -> String {
145    let Some(open) = text.find('(') else {
146        return text.to_string();
147    };
148    let Some(rel_close) = text[open..].find(')') else {
149        return text.to_string();
150    };
151    let close = open + rel_close;
152    let inner = &text[open + 1..close];
153    // Only collapse a genuine member list (comma-separated identifiers), never a
154    // prose parenthetical like "(env)" or "(the cache)".
155    let members: Vec<&str> = inner.split(", ").collect();
156    if members.len() <= max_members {
157        return text.to_string();
158    }
159    let shown = members[..max_members].join(", ");
160    let more = members.len() - max_members;
161    format!(
162        "{}({shown}, +{more} more){}",
163        &text[..open],
164        &text[close + 1..]
165    )
166}
167
168/// Drop a trailing decision question (a sentence ending in `?`) so a guided tour
169/// shows the plain observation, not a per-file question. The decision's open
170/// question is still carried in the decisions brief and the JSON, where each
171/// decision stands alone; in the tour the section header frames the action once,
172/// so a question repeated on every row reads as a wall of the same sentence.
173fn drop_trailing_question(text: &str) -> String {
174    let parts: Vec<&str> = text.split(". ").collect();
175    let mut end = parts.len();
176    while end > 0 && parts[end - 1].trim_end().ends_with('?') {
177        end -= 1;
178    }
179    // Nothing trailing was a question (end unchanged), or the whole text is a
180    // question (end hit 0): leave it as-is rather than emit an empty fragment.
181    if end == parts.len() || end == 0 {
182        return text.to_string();
183    }
184    let kept = parts[..end].join(". ");
185    if kept.ends_with(['.', '!', '?']) {
186        kept
187    } else {
188        format!("{kept}.")
189    }
190}
191
192/// Cap an arbitrary list of names for inline display: first `max` names, then a
193/// "+N more" sentinel. Shared by the out-of-diff consumer fact in both surfaces.
194#[must_use]
195pub fn cap_names(names: &[String], max: usize) -> (Vec<&str>, usize) {
196    let shown: Vec<&str> = names.iter().take(max).map(String::as_str).collect();
197    let more = names.len().saturating_sub(shown.len());
198    (shown, more)
199}
200
201/// Whether a staged file collapses into Cleared instead of showing in its stage.
202/// A file is cleared when the local viewed-state marked it seen (the
203/// `--mark-viewed` collapse): it must appear ONLY under Cleared, never in both.
204#[must_use]
205fn is_collapsed_into_cleared(file: &str, viewed: &[String]) -> bool {
206    viewed.iter().any(|v| v == file)
207}
208
209/// Whether `file` is a de-prioritized focus unit. The `--help` contract is that
210/// de-prioritized files collapse INTO the Cleared panel, so they must be removed
211/// from their stage row and shown only under Cleared.
212#[must_use]
213fn is_deprioritized(guide: &StandardWalkthroughGuide, file: &str) -> bool {
214    guide
215        .digest
216        .focus
217        .deprioritized
218        .iter()
219        .any(|u| u.file == file)
220}
221
222/// True when a direction unit collapses out of its stage and into Cleared,
223/// because it is de-prioritized OR locally viewed. Each file is then in exactly
224/// one rendered place (stage XOR cleared).
225#[must_use]
226fn collapses_into_cleared(guide: &StandardWalkthroughGuide, file: &str, viewed: &[String]) -> bool {
227    is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed)
228}
229
230/// The visible stage members for a guide: direction units in order, MINUS any
231/// file collapsed into Cleared (de-prioritized or viewed). Returned as references
232/// into the guide so the caller can render rows without cloning.
233#[must_use]
234pub fn visible_stage_units<'a>(
235    guide: &'a StandardWalkthroughGuide,
236    viewed: &[String],
237) -> Vec<&'a DirectionUnit> {
238    guide
239        .direction
240        .order
241        .iter()
242        .filter(|file| !collapses_into_cleared(guide, file, viewed))
243        .filter_map(|file| guide.direction.units.iter().find(|u| &u.file == file))
244        .collect()
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::audit_brief::{
251        DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts, ReviewBriefSchemaVersion,
252        ReviewDeltas, ReviewEffort, RiskClass, StandardReviewBriefOutput,
253    };
254    use crate::audit_decision_surface::DecisionSurface;
255    use crate::audit_focus::{FocusLabel, FocusMap, FocusScore, FocusUnit};
256    use crate::audit_routing::RoutingFacts;
257    use crate::audit_walkthrough::{
258        AgentSchema, DirectionUnit, INJECTION_NOTE, ReviewDirection, StandardWalkthroughGuide,
259    };
260
261    fn focus_unit(file: &str, label: FocusLabel) -> FocusUnit {
262        FocusUnit {
263            file: file.to_string(),
264            score: FocusScore::default(),
265            label,
266            reason: format!("reason for {file}"),
267            confidence: Vec::new(),
268        }
269    }
270
271    fn dir_unit(file: &str) -> DirectionUnit {
272        DirectionUnit {
273            file: file.to_string(),
274            concern_lens: "orientation".to_string(),
275            scoring_budget: 1,
276            out_of_diff: Vec::new(),
277            expert: Vec::new(),
278            test_adjacency: None,
279        }
280    }
281
282    /// A guide whose direction is the review_here + deprioritized source units (as
283    /// the engine builds it), with `changed` total files that may exceed the source
284    /// unit count (the non-source excluded bucket).
285    fn guide_for(
286        review_here: &[&str],
287        deprioritized: &[&str],
288        changed_total: usize,
289    ) -> StandardWalkthroughGuide {
290        let order: Vec<String> = review_here
291            .iter()
292            .chain(deprioritized.iter())
293            .map(|s| (*s).to_string())
294            .collect();
295        let units: Vec<DirectionUnit> = order.iter().map(|f| dir_unit(f)).collect();
296        let digest = StandardReviewBriefOutput {
297            branching: None,
298            schema_version: ReviewBriefSchemaVersion::default(),
299            version: "test".to_string(),
300            command: "audit-brief".to_string(),
301            triage: DiffTriage {
302                files: changed_total,
303                hunks: None,
304                net_lines: None,
305                risk_class: RiskClass::Medium,
306                review_effort: ReviewEffort::Review,
307            },
308            graph_facts: GraphFacts {
309                exports_added: 0,
310                api_width_delta: 0,
311                boundaries_touched: Vec::new(),
312            },
313            partition: PartitionFacts::default(),
314            impact_closure: ImpactClosureFacts::default(),
315            focus: FocusMap {
316                review_here: review_here
317                    .iter()
318                    .map(|f| focus_unit(f, FocusLabel::ReviewHere))
319                    .collect(),
320                deprioritized: deprioritized
321                    .iter()
322                    .map(|f| focus_unit(f, FocusLabel::NotPrioritized))
323                    .collect(),
324            },
325            deltas: ReviewDeltas::default(),
326            weakening: Vec::new(),
327            routing: RoutingFacts::default(),
328            decisions: DecisionSurface::default(),
329        };
330        StandardWalkthroughGuide {
331            schema_version: ReviewBriefSchemaVersion::default(),
332            version: "test".to_string(),
333            command: "review-walkthrough-guide".to_string(),
334            graph_snapshot_hash: "hash1".to_string(),
335            digest,
336            direction: ReviewDirection { order, units },
337            change_anchors: Vec::new(),
338            agent_schema: AgentSchema {
339                judgment_shape: "",
340                echo_field: "graph_snapshot_hash",
341                anchoring_rule: "",
342                action_vocabulary: &[],
343                concern_vocabulary: &[],
344            },
345            injection_note: INJECTION_NOTE,
346        }
347    }
348
349    #[test]
350    fn accounting_reconciles_staged_cleared_excluded() {
351        // 16 changed files: 2 review-here source, 1 de-prioritized source, 13
352        // non-source (migrations/config/docs). No viewed.
353        let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 16);
354        let acc = WalkthroughAccounting::compute(&guide, &[]);
355        assert_eq!(acc.changed, 16);
356        assert_eq!(acc.staged, 2, "review-here source units stay in stages");
357        assert_eq!(acc.cleared, 1, "de-prioritized collapses into cleared");
358        assert_eq!(
359            acc.excluded, 13,
360            "non-source files are excluded, not dropped"
361        );
362        // The header total accounts for the whole changed set.
363        assert_eq!(acc.header_total(), 16);
364        assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
365    }
366
367    #[test]
368    fn viewed_file_moves_from_staged_to_cleared() {
369        let guide = guide_for(&["src/a.ts", "src/b.ts"], &[], 2);
370        let viewed = vec!["src/a.ts".to_string()];
371        let acc = WalkthroughAccounting::compute(&guide, &viewed);
372        assert_eq!(acc.staged, 1, "the viewed file left the stage");
373        assert_eq!(acc.cleared, 1, "the viewed file is counted in cleared");
374        assert_eq!(acc.excluded, 0);
375        assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
376    }
377
378    #[test]
379    fn deprioritized_and_viewed_appear_in_exactly_one_place() {
380        let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 3);
381        let viewed = vec!["src/a.ts".to_string()];
382        // a.ts is viewed -> cleared; c.ts is de-prioritized -> cleared; only b.ts
383        // remains visible in a stage.
384        let visible = visible_stage_units(&guide, &viewed);
385        let files: Vec<&str> = visible.iter().map(|u| u.file.as_str()).collect();
386        assert_eq!(files, vec!["src/b.ts"]);
387        assert!(collapses_into_cleared(&guide, "src/a.ts", &viewed));
388        assert!(collapses_into_cleared(&guide, "src/c.ts", &viewed));
389        assert!(!collapses_into_cleared(&guide, "src/b.ts", &viewed));
390    }
391
392    #[test]
393    fn strips_leading_path_caps_members_and_drops_question() {
394        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?";
395        let out = clean_decision_fact(q, "src/db/schema.ts", 3);
396        // The leading path is gone (printed once by the row).
397        assert!(
398            !out.starts_with("`src/db/schema.ts`"),
399            "leading path must be stripped: {out}"
400        );
401        // The member list is capped with a "+N more".
402        assert!(out.contains("(a, b, c, +5 more)"), "got: {out}");
403        // The trailing decision question is dropped in the tour (it lives in the brief).
404        assert!(
405            !out.contains('?'),
406            "trailing question must be dropped: {out}"
407        );
408        assert!(
409            out.ends_with("outside this PR."),
410            "the observation survives, ending cleanly: {out}"
411        );
412        // No backticks remain to be escaped.
413        assert!(!out.contains('`'), "no backticks remain: {out}");
414    }
415
416    #[test]
417    fn short_member_list_is_kept_and_question_dropped() {
418        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?";
419        let out = clean_decision_fact(q, "src/lib/r2.ts", 6);
420        assert_eq!(
421            out,
422            "changes exports (getR2, getR2Text) imported by 6 files outside this PR."
423        );
424    }
425
426    #[test]
427    fn single_member_prose_parenthetical_is_kept_question_dropped() {
428        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?";
429        let out = clean_decision_fact(q, "src/lib/env.ts", 6);
430        assert!(out.contains("(env)"), "single member kept: {out}");
431        assert!(!out.contains('?'), "trailing question dropped: {out}");
432        assert!(out.ends_with("outside this PR."), "observation kept: {out}");
433    }
434
435    #[test]
436    fn non_anchor_path_is_kept_but_question_dropped() {
437        // A boundary question names a DIFFERENT path than the anchor; its leading
438        // token is not stripped, but the tour still drops the trailing question.
439        let q = "`ui` now imports `db` for the first time. Intended coupling, or should this edge not exist?";
440        let out = clean_decision_fact(q, "src/ui/page.ts", 6);
441        assert_eq!(out, "`ui` now imports `db` for the first time.");
442    }
443
444    #[test]
445    fn public_api_surface_question_drops_to_one_sentence() {
446        // The consolidated public-API-surface decision has no leading path and no
447        // member parenthetical; the tour keeps only the one observation sentence,
448        // dropping the trailing "Intended as maintained contracts ...?" question.
449        let q = "This change adds 3 exports to the public API surface. Intended as maintained contracts, or should they stay internal?";
450        let out = clean_decision_fact(q, "src/lib/id.ts", 6);
451        assert_eq!(out, "This change adds 3 exports to the public API surface.");
452    }
453
454    #[test]
455    fn cap_names_first_k_then_more() {
456        let names = vec![
457            "a".to_string(),
458            "b".to_string(),
459            "c".to_string(),
460            "d".to_string(),
461        ];
462        let (shown, more) = cap_names(&names, 2);
463        assert_eq!(shown, vec!["a", "b"]);
464        assert_eq!(more, 2);
465    }
466}