Skip to main content

fallow_cli/report/ci/
pr_comment.rs

1use crate::report::sink::outln;
2use std::process::ExitCode;
3use std::sync::OnceLock;
4
5#[cfg(test)]
6use fallow_output::is_project_level_rule;
7use fallow_output::issues_from_codeclimate_issues;
8pub use fallow_output::{
9    CiIssue, CiProvider as Provider, CodeClimateIssue, PR_DECISION_SCHEMA, PR_DETAILS_SCHEMA,
10    PrCommentEnvelope, PrCommentLayout, PrCommentTruncation, PrDecisionAnnotation,
11    PrDecisionAnnotationLevel, PrDecisionConclusion, PrDecisionDetails, PrDecisionGate,
12    PrDecisionSurface, PrDetailsArtifact, PrDetailsRow, PrDetailsSection, command_title,
13    issues_from_codeclimate,
14};
15
16/// Workspace name, set once by `main()` when the binary is invoked with
17/// `--workspace <name>`. Read by `sticky_marker_id` to auto-suffix the
18/// sticky-comment marker per workspace, which keeps parallel per-workspace
19/// jobs from racing each other's sticky body on the same PR/MR.
20///
21/// `OnceLock` gives us safe cross-function read-after-set without env-var
22/// indirection. Only main writes; readers always observe the post-CLI-parse
23/// state.
24static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
25
26/// Set the workspace marker from a `--workspace` selection list.
27///
28/// Single workspace -> the name itself, sanitised for marker grammar.
29/// N>1 workspaces -> a stable 6-char hex hash of the sorted, comma-joined
30/// list, prefixed with `w-`. Sort + join is deterministic so the same
31/// selection produces the same suffix across runs; two jobs with disjoint
32/// selections get distinct markers and don't race.
33#[allow(
34    dead_code,
35    reason = "called from main.rs bin target; lib target sees no caller"
36)]
37pub fn set_workspace_marker_from_list(values: &[String]) {
38    let trimmed: Vec<&str> = values
39        .iter()
40        .map(|value| value.trim())
41        .filter(|value| !value.is_empty())
42        .collect();
43    if trimmed.is_empty() {
44        return;
45    }
46    let marker = if let [single] = trimmed.as_slice() {
47        (*single).to_owned()
48    } else {
49        let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
50        sorted.sort();
51        let joined = sorted.join(",");
52        format!("w-{}", short_hex_hash(&joined))
53    };
54    let _ = WORKSPACE_MARKER.set(marker);
55}
56
57/// 6-char FNV-1a hex digest. Stable across Rust versions (FNV is content-
58/// determined), short enough for a marker suffix, wide enough that the
59/// chance of two real-world workspace selections colliding is ~1/16M.
60fn short_hex_hash(value: &str) -> String {
61    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
62    for byte in value.bytes() {
63        hash ^= u64::from(byte);
64        hash = hash.wrapping_mul(0x0100_0000_01b3);
65    }
66    format!("{:06x}", (hash & 0x00ff_ffff) as u32)
67}
68
69/// What a run concluded, as the sticky comment and the decision sidecar carry
70/// it: one blockquote note in the body, and one row per armed gate.
71///
72/// The two travel together because they are two renderings of the same
73/// verdict, and a call that carried only the note is exactly how a tripped
74/// gate reached the comment but not the Check Run (#2675).
75#[derive(Clone, Copy)]
76pub struct PrCommentStatus<'a> {
77    /// The blockquote note appended to the body: the type-aware message, the
78    /// baseline advisory, the gate inventory, or any combination.
79    pub message: Option<&'a str>,
80    /// One row per gate the run armed, appended to the decision surface's
81    /// `gates` array after the command row.
82    pub gates: &'a [PrDecisionGate],
83    /// The run diagnostics. The unmatched config patterns among them become a
84    /// Markdown section after the findings, as in `--format markdown`.
85    pub config_patterns: &'a [fallow_config::WorkspaceDiagnostic],
86}
87
88/// Render the sticky comment body. `conclusion` is the gate outcome the caller
89/// already computed, folded into the severity-derived verdict by the renderer;
90/// `None` renders the severity-derived verdict alone.
91#[must_use]
92pub fn render_pr_comment(
93    command: &str,
94    provider: Provider,
95    issues: &[CiIssue],
96    conclusion: Option<PrDecisionConclusion>,
97) -> String {
98    render_pr_comment_body(command, provider, issues, conclusion, None)
99}
100
101fn render_pr_comment_body(
102    command: &str,
103    provider: Provider,
104    issues: &[CiIssue],
105    conclusion: Option<PrDecisionConclusion>,
106    trailing_section: Option<&str>,
107) -> String {
108    fallow_output::render_pr_comment_with_verdict(
109        &fallow_output::PrCommentRenderInput {
110            command,
111            provider,
112            issues,
113            marker_id: sticky_marker_id(),
114            max_comments: max_comments(),
115            category_for_rule: &category_for_rule,
116            trailing_section,
117        },
118        conclusion.map(super::review::review_conclusion),
119    )
120}
121
122/// [`render_pr_comment`] with the unmatched config patterns and the status
123/// note appended, which is the body both integrations post.
124///
125/// Separate from the printing path so the body a reviewer reads is
126/// snapshot-testable: the note carries the baseline advisory and the gate
127/// inventory, and those are exactly the clauses a regression would drop.
128#[must_use]
129pub fn render_pr_comment_with_status_note(
130    command: &str,
131    provider: Provider,
132    issues: &[CiIssue],
133    conclusion: Option<PrDecisionConclusion>,
134    config_patterns: &[fallow_config::WorkspaceDiagnostic],
135    status_message: Option<&str>,
136) -> String {
137    let section = crate::report::config_pattern_text::markdown_section(config_patterns);
138    let mut body =
139        render_pr_comment_body(command, provider, issues, conclusion, section.as_deref());
140    if let Some(message) = status_message {
141        body.push_str("\n\n> ");
142        body.push_str(message);
143    }
144    body
145}
146
147/// Map a fallow rule id to its category for sticky-comment grouping.
148///
149/// Single source of truth lives on `RuleDef::category` in `explain.rs`. This
150/// helper does the lookup so callers don't need to know about the registry;
151/// the look-up-then-fallback shape also keeps the renderer working for
152/// rules a downstream consumer added without registering (rare). An
153/// unregistered rule id is an absence of information, not evidence of
154/// unreachable code, so it lands in "Other" rather than inflating the
155/// "Dead code" section.
156#[must_use]
157fn category_for_rule(rule_id: &str) -> &'static str {
158    crate::explain::rule_by_id(rule_id).map_or("Other", |def| def.category)
159}
160
161pub(crate) fn max_comments() -> usize {
162    std::env::var("FALLOW_MAX_COMMENTS")
163        .ok()
164        .and_then(|value| value.parse::<usize>().ok())
165        .unwrap_or(50)
166}
167
168#[must_use]
169pub(crate) fn pr_comment_layout_from_env() -> PrCommentLayout {
170    match std::env::var("FALLOW_PR_COMMENT_LAYOUT").as_deref() {
171        Ok("compact") => PrCommentLayout::Compact,
172        Ok("gate-only") => PrCommentLayout::GateOnly,
173        Ok("details") => PrCommentLayout::Details,
174        _ => PrCommentLayout::Default,
175    }
176}
177
178/// Compute the sticky-comment marker id. Precedence (highest first):
179///
180/// 1. `FALLOW_COMMENT_ID` set by the user explicitly: use as-is.
181/// 2. `WORKSPACE_MARKER` populated by `main()` from `--workspace <name>`:
182///    suffix the default to avoid colliding with a sibling per-workspace
183///    job's sticky on the same PR/MR.
184/// 3. Plain `fallow-results`.
185///
186/// The collision case (2) is the common monorepo shape: parallel jobs each
187/// run fallow scoped to one workspace package and post their own sticky.
188/// Without a per-workspace suffix every job edits the same marker, racing
189/// each other's bodies on every CI re-run.
190pub(crate) fn sticky_marker_id() -> String {
191    if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
192        && !value.trim().is_empty()
193    {
194        return value;
195    }
196    let suffix = WORKSPACE_MARKER
197        .get()
198        .map(|value| value.trim())
199        .filter(|value| !value.is_empty())
200        .map(sanitize_marker_segment);
201    match suffix {
202        Some(workspace) => format!("fallow-results-{workspace}"),
203        None => "fallow-results".to_owned(),
204    }
205}
206
207/// Strip characters that would break the HTML-comment marker. The marker
208/// shape is `<!-- fallow-id: <id> -->`; `<`, `>`, and `--` are reserved by
209/// the HTML comment grammar, and whitespace would split the id when the
210/// reader scans for it.
211fn sanitize_marker_segment(value: &str) -> String {
212    value
213        .chars()
214        .map(|ch| {
215            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
216                ch
217            } else {
218                '-'
219            }
220        })
221        .collect::<String>()
222        .trim_matches('-')
223        .to_owned()
224}
225
226#[must_use]
227pub(crate) fn print_pr_comment_from_codeclimate_issues(
228    command: &str,
229    provider: Provider,
230    codeclimate: &[CodeClimateIssue],
231    conclusion: Option<PrDecisionConclusion>,
232    status: PrCommentStatus<'_>,
233) -> ExitCode {
234    let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
235        issues_from_codeclimate_issues(codeclimate),
236    ));
237    let conclusion = conclusion.unwrap_or_else(|| issue_decision_conclusion(issues.is_empty()));
238    print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status)
239}
240
241fn rebase_issue_paths(mut issues: Vec<CiIssue>) -> Vec<CiIssue> {
242    let prefix = crate::report::github::report_prefix();
243    if !prefix.is_empty() {
244        for issue in &mut issues {
245            issue.path = fallow_output::apply_path_prefix(prefix, &issue.path);
246        }
247    }
248    issues
249}
250
251#[must_use]
252fn print_pr_comment_from_ci_issues(
253    command: &str,
254    provider: Provider,
255    issues: &[CiIssue],
256    conclusion: PrDecisionConclusion,
257    status: PrCommentStatus<'_>,
258) -> ExitCode {
259    let body = render_pr_comment_with_status_note(
260        command,
261        provider,
262        issues,
263        Some(conclusion),
264        status.config_patterns,
265        status.message,
266    );
267    let max_comments = max_comments();
268    let envelope = PrCommentEnvelope {
269        marker_id: sticky_marker_id(),
270        body,
271        is_clean: issues.is_empty() && conclusion == PrDecisionConclusion::Success,
272        details_url: None,
273        check_summary: Some(decision_summary_label(conclusion).to_owned()),
274        truncation: PrCommentTruncation {
275            truncated: issues.len() > max_comments,
276            shown_findings: issues.len().min(max_comments),
277            total_findings: issues.len(),
278        },
279    };
280    let decision = build_issue_decision_surface(command, issues, &envelope, conclusion, status);
281    let details = build_pr_details_artifact(command, issues);
282    write_pr_comment_envelope_sidecar(&envelope);
283    write_pr_decision_sidecar(&decision);
284    write_pr_details_sidecar(&details);
285    outln!("{}", envelope.body());
286    ExitCode::SUCCESS
287}
288
289#[must_use]
290fn build_issue_decision_surface(
291    command: &str,
292    issues: &[CiIssue],
293    envelope: &PrCommentEnvelope,
294    conclusion: PrDecisionConclusion,
295    status: PrCommentStatus<'_>,
296) -> PrDecisionSurface {
297    // The command row stays first and the caller's `conclusion` stays the
298    // surface's: the gate rows are additive display, and deriving the check-run
299    // conclusion from them would turn an advisory check into a merge blocker
300    // for every consumer with a required check.
301    let mut gates = vec![PrDecisionGate {
302        id: command.to_owned(),
303        label: command_title(command).to_owned(),
304        status: conclusion,
305        observed: count_label(issues.len(), "finding", "findings"),
306        threshold: None,
307        scope: "new code".to_owned(),
308    }];
309    gates.extend(status.gates.iter().cloned());
310    PrDecisionSurface {
311        schema: PR_DECISION_SCHEMA.to_owned(),
312        title: "Fallow".to_owned(),
313        conclusion,
314        gates,
315        annotations: issues
316            .iter()
317            .take(max_comments())
318            .map(decision_annotation_from_issue)
319            .collect(),
320        details: PrDecisionDetails {
321            summary_markdown: decision_summary_markdown(conclusion, issues.len(), status.message),
322            full_report_path: None,
323            details_url: envelope.details_url.clone(),
324        },
325    }
326}
327
328/// Gate outcome for the PR decision surface, which `ci_check_run` maps
329/// straight onto the GitHub check-run `conclusion`. Findings alone stay
330/// `Neutral` here on purpose: promoting them to `Failure` would turn an
331/// advisory check into a merge blocker for every consumer with a required
332/// check. The sticky comment's verdict line answers a different question
333/// (how severe are the findings), so the two intentionally differ.
334fn issue_decision_conclusion(is_clean: bool) -> PrDecisionConclusion {
335    if is_clean {
336        PrDecisionConclusion::Success
337    } else {
338        PrDecisionConclusion::Neutral
339    }
340}
341
342fn decision_summary_label(conclusion: PrDecisionConclusion) -> &'static str {
343    match conclusion {
344        PrDecisionConclusion::Success => "pass",
345        PrDecisionConclusion::Failure => "fail",
346        PrDecisionConclusion::Neutral => "warn",
347        PrDecisionConclusion::Skipped => "skipped",
348    }
349}
350
351fn decision_summary_markdown(
352    conclusion: PrDecisionConclusion,
353    issue_count: usize,
354    status_message: Option<&str>,
355) -> String {
356    let summary = if issue_count == 0 {
357        match conclusion {
358            PrDecisionConclusion::Failure => {
359                "Fallow quality gates failed without renderable findings.".to_owned()
360            }
361            PrDecisionConclusion::Neutral => {
362                "Fallow needs review without renderable findings.".to_owned()
363            }
364            PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
365                "Fallow found no actionable PR findings.".to_owned()
366            }
367        }
368    } else {
369        let findings = count_label(issue_count, "finding", "findings");
370        match conclusion {
371            PrDecisionConclusion::Failure => {
372                format!("Fallow quality gates failed with {findings}.")
373            }
374            PrDecisionConclusion::Neutral => format!("Fallow found {findings} for review."),
375            PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
376                format!("Fallow found {findings}.")
377            }
378        }
379    };
380    match status_message {
381        Some(message) => format!("{summary}\n\n> {message}"),
382        None => summary,
383    }
384}
385
386#[must_use]
387pub(crate) fn build_pr_details_artifact(command: &str, issues: &[CiIssue]) -> PrDetailsArtifact {
388    PrDetailsArtifact {
389        schema: PR_DETAILS_SCHEMA.to_owned(),
390        title: format!("Fallow {}", command_title(command)),
391        sections: vec![PrDetailsSection {
392            id: "findings".to_owned(),
393            title: "Findings".to_owned(),
394            rows: issues.iter().map(pr_details_row_from_issue).collect(),
395        }],
396    }
397}
398
399fn pr_details_row_from_issue(issue: &CiIssue) -> PrDetailsRow {
400    PrDetailsRow {
401        location: format!("{}:{}", issue.path, issue.line),
402        rule: issue.rule_id.clone(),
403        description: issue.description.clone(),
404        fix: super::suggestion::fix_intent(issue).map(str::to_owned),
405        fingerprint: (!issue.fingerprint.trim().is_empty()).then(|| issue.fingerprint.clone()),
406    }
407}
408
409#[must_use]
410pub(crate) fn decision_annotation_from_issue(issue: &CiIssue) -> PrDecisionAnnotation {
411    PrDecisionAnnotation {
412        path: issue.path.clone(),
413        line: u32::try_from(issue.line).unwrap_or(u32::MAX),
414        level: decision_level_from_severity(&issue.severity),
415        title: issue.rule_id.clone(),
416        message: issue.description.clone(),
417        raw_details: super::suggestion::fix_intent(issue).map(str::to_owned),
418    }
419}
420
421fn decision_level_from_severity(severity: &str) -> PrDecisionAnnotationLevel {
422    match severity {
423        "blocker" | "critical" | "major" => PrDecisionAnnotationLevel::Failure,
424        "minor" => PrDecisionAnnotationLevel::Warning,
425        _ => PrDecisionAnnotationLevel::Notice,
426    }
427}
428
429fn count_label(count: usize, singular: &str, plural: &str) -> String {
430    let noun = if count == 1 { singular } else { plural };
431    format!("{count} {noun}")
432}
433
434pub(crate) fn write_pr_comment_envelope_sidecar(envelope: &PrCommentEnvelope) {
435    let Ok(path) = std::env::var("FALLOW_PR_COMMENT_ENVELOPE_FILE") else {
436        return;
437    };
438    if path.trim().is_empty() {
439        return;
440    }
441    match serde_json::to_string_pretty(envelope)
442        .map_err(|e| e.to_string())
443        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
444    {
445        Ok(()) => {}
446        Err(e) => eprintln!("warning: failed to write PR comment envelope '{path}': {e}"),
447    }
448}
449
450pub(crate) fn write_pr_decision_sidecar(surface: &PrDecisionSurface) {
451    let Ok(path) = std::env::var("FALLOW_PR_DECISION_FILE") else {
452        return;
453    };
454    if path.trim().is_empty() {
455        return;
456    }
457    match serde_json::to_string_pretty(surface)
458        .map_err(|e| e.to_string())
459        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
460    {
461        Ok(()) => {}
462        Err(e) => eprintln!("warning: failed to write PR decision '{path}': {e}"),
463    }
464}
465
466pub(crate) fn write_pr_details_sidecar(artifact: &PrDetailsArtifact) {
467    let Ok(path) = std::env::var("FALLOW_PR_DETAILS_FILE") else {
468        return;
469    };
470    if path.trim().is_empty() {
471        return;
472    }
473    match serde_json::to_string_pretty(artifact)
474        .map_err(|e| e.to_string())
475        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
476    {
477        Ok(()) => {}
478        Err(e) => eprintln!("warning: failed to write PR details '{path}': {e}"),
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use fallow_output::{
486        CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
487    };
488
489    #[test]
490    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
491        let severities = [
492            (CodeClimateSeverity::Info, "info"),
493            (CodeClimateSeverity::Minor, "minor"),
494            (CodeClimateSeverity::Major, "major"),
495            (CodeClimateSeverity::Critical, "critical"),
496            (CodeClimateSeverity::Blocker, "blocker"),
497        ];
498        let typed = severities
499            .iter()
500            .enumerate()
501            .map(|(index, (severity, _))| CodeClimateIssue {
502                kind: CodeClimateIssueKind::Issue,
503                check_name: format!("fallow/rule-{index}"),
504                description: format!("Finding {index}"),
505                categories: vec!["Complexity".to_owned()],
506                severity: *severity,
507                fingerprint: format!("fp-{index}"),
508                location: CodeClimateLocation {
509                    path: format!("src/{index}.ts"),
510                    lines: CodeClimateLines {
511                        begin: u32::try_from(index + 1).expect("small fixture index"),
512                        end: None,
513                    },
514                },
515                other_locations: Vec::new(),
516                owner: None,
517                group: None,
518                legacy_fingerprint: None,
519            })
520            .collect::<Vec<_>>();
521        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
522
523        assert_eq!(
524            issues_from_codeclimate_issues(&typed),
525            issues_from_codeclimate(&value)
526        );
527        let typed_labels = issues_from_codeclimate_issues(&typed)
528            .into_iter()
529            .map(|issue| issue.severity)
530            .collect::<Vec<_>>();
531        let expected_labels = severities
532            .iter()
533            .map(|(_, label)| (*label).to_owned())
534            .collect::<Vec<_>>();
535        assert_eq!(typed_labels, expected_labels);
536    }
537
538    #[test]
539    fn sticky_marker_id_default_when_nothing_set() {
540        let body = render_pr_comment("check", Provider::Github, &[], None);
541        assert!(body.contains("<!-- fallow-id: fallow-results"));
542        assert!(body.contains("No findings for this pull request."));
543    }
544
545    #[test]
546    fn short_hex_hash_is_deterministic_and_six_chars() {
547        let a = short_hex_hash("api,worker");
548        assert_eq!(a.len(), 6);
549        assert_eq!(a, short_hex_hash("api,worker"));
550        assert_ne!(a, short_hex_hash("admin,web"));
551    }
552
553    #[test]
554    fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
555        assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
556        assert_eq!(
557            sanitize_marker_segment("packages/web ui"),
558            "packages-web-ui"
559        );
560        assert_eq!(sanitize_marker_segment("plain"), "plain");
561        assert_eq!(
562            sanitize_marker_segment("--leading-trailing--"),
563            "leading-trailing"
564        );
565    }
566
567    #[test]
568    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
569        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
570            assert!(
571                is_project_level_rule(rule_id),
572                "{rule_id} must be project-level"
573            );
574        }
575        for rule_id in [
576            "fallow/unused-file",
577            "fallow/unused-export",
578            "fallow/unused-type",
579            "fallow/unused-enum-member",
580            "fallow/unused-class-member",
581            "fallow/unused-store-member",
582            "fallow/unresolved-import",
583            "fallow/unlisted-dependency",
584            "fallow/duplicate-export",
585            "fallow/circular-dependency",
586            "fallow/re-export-cycle",
587            "fallow/package-cycle",
588            "fallow/boundary-violation",
589            "fallow/stale-suppression",
590            "fallow/private-type-leak",
591            "fallow/high-complexity",
592            "fallow/high-crap-score",
593        ] {
594            assert!(
595                !is_project_level_rule(rule_id),
596                "{rule_id} must NOT be project-level"
597            );
598        }
599    }
600
601    #[test]
602    fn decision_surface_preserves_blocking_conclusion_for_issue_output() {
603        let issues = [CiIssue {
604            path: "src/app.ts".to_owned(),
605            line: 12,
606            end_line: None,
607            other_locations: Vec::new(),
608            rule_id: "fallow/high-crap-score".to_owned(),
609            description: "Function is hard to safely change.".to_owned(),
610            severity: "minor".to_owned(),
611            fingerprint: "abc".to_owned(),
612            legacy_fingerprint: None,
613        }];
614        let envelope = PrCommentEnvelope {
615            marker_id: "fallow-results".to_owned(),
616            body: "body".to_owned(),
617            is_clean: false,
618            details_url: None,
619            check_summary: Some("fail".to_owned()),
620            truncation: PrCommentTruncation {
621                truncated: false,
622                shown_findings: 1,
623                total_findings: 1,
624            },
625        };
626
627        let decision = build_issue_decision_surface(
628            "audit",
629            &issues,
630            &envelope,
631            PrDecisionConclusion::Failure,
632            PrCommentStatus {
633                message: Some(crate::report::ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
634                gates: &[],
635                config_patterns: &[],
636            },
637        );
638
639        assert_eq!(decision.conclusion, PrDecisionConclusion::Failure);
640        assert_eq!(decision.gates[0].status, PrDecisionConclusion::Failure);
641        assert!(decision.details.summary_markdown.contains("incomplete"));
642        assert!(
643            decision
644                .details
645                .summary_markdown
646                .contains("quality gates failed")
647        );
648    }
649
650    /// A gate row is additive display: it lands after the command row and does
651    /// not move the surface `conclusion`, which `ci post-check-run` maps
652    /// straight onto the GitHub check-run conclusion.
653    #[test]
654    fn gate_rows_follow_the_command_row_without_moving_the_conclusion() {
655        let envelope = PrCommentEnvelope {
656            marker_id: "fallow-results".to_owned(),
657            body: "body".to_owned(),
658            is_clean: true,
659            details_url: None,
660            check_summary: Some("pass".to_owned()),
661            truncation: PrCommentTruncation {
662                truncated: false,
663                shown_findings: 0,
664                total_findings: 0,
665            },
666        };
667        let gates = [PrDecisionGate {
668            id: "stale-baseline".to_owned(),
669            label: "Stale baseline".to_owned(),
670            status: PrDecisionConclusion::Failure,
671            observed: "fail".to_owned(),
672            threshold: None,
673            scope: "this run".to_owned(),
674        }];
675
676        let decision = build_issue_decision_surface(
677            "dead-code",
678            &[],
679            &envelope,
680            PrDecisionConclusion::Success,
681            PrCommentStatus {
682                message: None,
683                gates: &gates,
684                config_patterns: &[],
685            },
686        );
687
688        assert_eq!(decision.conclusion, PrDecisionConclusion::Success);
689        assert_eq!(decision.gates[0].id, "dead-code");
690        assert_eq!(decision.gates[1].id, "stale-baseline");
691        assert_eq!(decision.gates[1].scope, "this run");
692    }
693
694    #[test]
695    fn a_body_without_a_note_is_the_bare_render() {
696        let issues: Vec<CiIssue> = Vec::new();
697        assert_eq!(
698            render_pr_comment_with_status_note("check", Provider::Github, &issues, None, &[], None),
699            render_pr_comment("check", Provider::Github, &issues, None)
700        );
701    }
702
703    #[test]
704    fn unmatched_config_patterns_follow_the_findings_and_precede_the_footer() {
705        let issues: Vec<CiIssue> = Vec::new();
706        let root = std::path::Path::new("/project");
707        let patterns = [fallow_config::WorkspaceDiagnostic::new(
708            root,
709            root.to_path_buf(),
710            fallow_config::WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched {
711                pattern: "@typo/*".to_owned(),
712            },
713        )];
714        let body = render_pr_comment_with_status_note(
715            "check",
716            Provider::Github,
717            &issues,
718            None,
719            &patterns,
720            Some("Gate outcomes: passed."),
721        );
722        let section = body.find("## Unmatched config patterns").expect("section");
723        let footer = body.find("Generated by fallow.").expect("footer");
724        let note = body.find("> Gate outcomes").expect("note");
725        assert!(section < footer && footer < note, "{body}");
726        assert!(
727            body.contains("- `ignoreDependencies`: `@typo/*` matched nothing in this run"),
728            "{body}"
729        );
730    }
731
732    #[test]
733    fn project_level_rule_ids_each_register_in_explain_registry() {
734        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
735            assert!(
736                crate::explain::rule_by_id(rule_id).is_some(),
737                "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
738            );
739        }
740    }
741}