Skip to main content

cockpitctl_render/
lib.rs

1//! PR comment rendering for cockpitctl.
2//!
3//! Renderer must:
4//! - be deterministic
5//! - be budgeted
6//! - link to artifacts rather than dumping content
7//!
8//! It should not parse sensor-specific markdown. Link only.
9
10use cockpitctl_types::{
11    BuildfixApplyStatus, BuildfixApplySummary, BuildfixSummary, CockpitConfig, CockpitReport,
12    Highlight, PolicySignatureAlgorithm, PolicySignatureEvidence, SafetyLevel, Severity,
13    TrendDelta, VerdictStatus, severity_rank,
14};
15
16/// Result of annotation rendering, tracking whether truncation occurred.
17pub struct AnnotationRenderResult {
18    /// The rendered markdown content for annotations.
19    pub content: String,
20    /// Whether the annotations were truncated due to max_annotations cap.
21    pub truncated: bool,
22    /// Total number of annotations before truncation.
23    pub total_count: usize,
24    /// Number of annotations actually rendered.
25    pub rendered_count: usize,
26}
27
28fn status_badge(s: &VerdictStatus) -> &'static str {
29    match s {
30        VerdictStatus::Pass => "✅ pass",
31        VerdictStatus::Warn => "⚠️ warn",
32        VerdictStatus::Fail => "❌ fail",
33        VerdictStatus::Skip => "⏭ skip",
34    }
35}
36
37fn severity_badge(s: &Severity) -> &'static str {
38    match s {
39        Severity::Error => "❌",
40        Severity::Warn => "⚠️",
41        Severity::Info => "ℹ️",
42    }
43}
44
45fn extract_buildfix_summary(report: &CockpitReport) -> Option<BuildfixSummary> {
46    let data = report.data.as_ref()?;
47    let raw = data.get("_buildfix")?;
48    serde_json::from_value(raw.clone()).ok()
49}
50
51fn extract_buildfix_apply_summary(report: &CockpitReport) -> Option<BuildfixApplySummary> {
52    let data = report.data.as_ref()?;
53    let raw = data.get("_buildfix_apply")?;
54    serde_json::from_value(raw.clone()).ok()
55}
56
57fn extract_policy_signature(report: &CockpitReport) -> Option<PolicySignatureEvidence> {
58    let data = report.data.as_ref()?;
59    let raw = data.get("_policy_signature")?;
60    serde_json::from_value(raw.clone()).ok()
61}
62
63pub fn render_comment(report: &CockpitReport, cfg: &CockpitConfig) -> String {
64    let mut out = String::new();
65
66    out.push_str("<!-- cockpit:begin -->\n");
67    out.push_str("## Cockpit\n\n");
68    out.push_str("This comment is generated by `cockpitctl`. Details live in `artifacts/`.\n\n");
69
70    out.push_str("### Summary\n\n");
71    out.push_str("| Sensor | Status | Blocking | Notes |\n");
72    out.push_str("|---|---:|---:|---|\n");
73
74    for s in &report.sensors {
75        let blocking = if s.blocking { "yes" } else { "no" };
76        let mut notes = format!("`{}`", s.report_path);
77        if let Some(c) = &s.comment_path {
78            notes.push_str(&format!(" · `{}`", c));
79        }
80        if s.truncated {
81            notes.push_str(" · _truncated_");
82        }
83        out.push_str(&format!(
84            "| `{}` | {} | {} | {} |\n",
85            s.id,
86            status_badge(&s.verdict.status),
87            blocking,
88            notes
89        ));
90    }
91
92    out.push('\n');
93
94    // Highlights
95    out.push_str("### Highlights\n\n");
96    if report.highlights.is_empty() {
97        out.push_str("_No highlights._\n\n");
98    } else {
99        let max = cfg.policy.max_highlights;
100        out.push_str(&format!("(showing up to **{}**)\n\n", max));
101
102        for (i, h) in report.highlights.iter().enumerate() {
103            let f = &h.finding;
104            let loc = match &f.location {
105                Some(l) => {
106                    let mut s = String::new();
107                    if let Some(p) = &l.path {
108                        s.push_str(p);
109                    }
110                    if let Some(line) = l.line {
111                        s.push_str(&format!(":{}", line));
112                    }
113                    if s.is_empty() { None } else { Some(s) }
114                }
115                None => None,
116            };
117
118            let loc_str = loc.map(|x| format!(" at `{}`", x)).unwrap_or_default();
119            out.push_str(&format!(
120                "{}. {} **{}**: `{}`{} — {}\n",
121                i + 1,
122                severity_badge(&f.severity),
123                h.sensor_id,
124                f.code,
125                loc_str,
126                f.message.replace('\n', " ")
127            ));
128        }
129        out.push('\n');
130    }
131
132    // Annotations section
133    let sensor_blocking: std::collections::BTreeMap<String, bool> = report
134        .sensors
135        .iter()
136        .map(|s| (s.id.clone(), s.blocking))
137        .collect();
138    out.push_str(&render_annotations_section(
139        &report.highlights,
140        cfg,
141        &sensor_blocking,
142    ));
143
144    if let Some(buildfix) = extract_buildfix_summary(report) {
145        out.push_str(&render_buildfix_section(&buildfix));
146    }
147    if let Some(apply) = extract_buildfix_apply_summary(report) {
148        out.push_str(&render_buildfix_apply_section(&apply));
149    }
150    if let Some(signature) = extract_policy_signature(report) {
151        out.push_str(&render_policy_signature_section(&signature));
152    }
153
154    // Sections: group sensors by section label, render in cfg.policy.section_order order.
155    let mut by_section: std::collections::BTreeMap<String, Vec<&cockpitctl_types::SensorSummary>> =
156        std::collections::BTreeMap::new();
157
158    for s in &report.sensors {
159        let section = cfg
160            .sensors
161            .get(&s.id)
162            .and_then(|p| p.section.clone())
163            .unwrap_or_else(|| "Other".to_string());
164        by_section.entry(section).or_default().push(s);
165    }
166
167    for section in &cfg.policy.section_order {
168        let Some(sensors) = by_section.get(section) else {
169            continue;
170        };
171        out.push_str(&format!("### {}\n\n", section));
172        for s in sensors {
173            // Minimal, link-first line.
174            let mut line = format!("- `{}`: {}", s.id, status_badge(&s.verdict.status));
175            line.push_str(&format!(" · report `{}`", s.report_path));
176            if let Some(c) = &s.comment_path {
177                line.push_str(&format!(" · comment `{}`", c));
178            }
179            if let Some(p) = cfg.sensors.get(&s.id)
180                && let Some(repro) = &p.repro
181            {
182                line.push_str(&format!("\n  - repro: `{}`", repro));
183            }
184            out.push_str(&format!("{}\n", line));
185        }
186        out.push('\n');
187    }
188
189    out.push_str("<!-- cockpit:end -->\n");
190    out
191}
192
193/// Append externally supplied comment sections before the cockpit end marker.
194///
195/// This is used by post-processing hooks to contribute extra markdown sections
196/// while preserving stable sticky markers.
197pub fn append_comment_sections(comment_md: &str, sections: &[(String, String)]) -> String {
198    if sections.is_empty() {
199        return comment_md.to_string();
200    }
201
202    let mut rendered_sections = String::new();
203    for (name, content) in sections {
204        rendered_sections.push_str(&format!("### {}\n\n", name.trim()));
205        rendered_sections.push_str(content.trim_end());
206        rendered_sections.push_str("\n\n");
207    }
208
209    const END_MARKER: &str = "<!-- cockpit:end -->";
210    if let Some(idx) = comment_md.rfind(END_MARKER) {
211        let (head, tail) = comment_md.split_at(idx);
212        let mut out = String::new();
213        out.push_str(head);
214        if !head.ends_with("\n\n") {
215            if head.ends_with('\n') {
216                out.push('\n');
217            } else {
218                out.push_str("\n\n");
219            }
220        }
221        out.push_str(&rendered_sections);
222        out.push_str(tail);
223        return out;
224    }
225
226    let mut out = comment_md.trim_end().to_string();
227    out.push_str("\n\n");
228    out.push_str(&rendered_sections);
229    out
230}
231
232/// Render annotations (file-level or inline findings) with capping.
233///
234/// Annotations are rendered as a markdown list of findings with file locations.
235/// The total number of annotations is capped by `max_annotations` from policy.
236/// Deterministic ordering is maintained: severity desc -> blocking-first -> sensor_id -> path -> line -> code.
237pub fn render_annotations(
238    highlights: &[Highlight],
239    cfg: &CockpitConfig,
240    sensor_blocking: &std::collections::BTreeMap<String, bool>,
241) -> AnnotationRenderResult {
242    let max = cfg.policy.max_annotations;
243    let total_count = highlights.len();
244
245    // Sort deterministically: severity desc, blocking sensors first, then sensor_id/path/line/code/message.
246    let mut sorted: Vec<&Highlight> = highlights.iter().collect();
247    sorted.sort_by(|a, b| {
248        annotation_sort_key(a, sensor_blocking).cmp(&annotation_sort_key(b, sensor_blocking))
249    });
250
251    let truncated = total_count > max;
252    let rendered_count = total_count.min(max);
253
254    let mut out = String::new();
255
256    if sorted.is_empty() {
257        out.push_str("_No annotations._\n");
258    } else {
259        for (i, h) in sorted.iter().take(max).enumerate() {
260            let f = &h.finding;
261            let loc = match &f.location {
262                Some(l) => {
263                    let mut s = String::new();
264                    if let Some(p) = &l.path {
265                        s.push_str(p);
266                    }
267                    if let Some(line) = l.line {
268                        s.push_str(&format!(":{}", line));
269                    }
270                    if s.is_empty() { None } else { Some(s) }
271                }
272                None => None,
273            };
274
275            let loc_str = loc.map(|x| format!(" at `{}`", x)).unwrap_or_default();
276            out.push_str(&format!(
277                "{}. {} **{}**: `{}`{} — {}\n",
278                i + 1,
279                severity_badge(&f.severity),
280                h.sensor_id,
281                f.code,
282                loc_str,
283                f.message.replace('\n', " ")
284            ));
285        }
286
287        if truncated {
288            out.push_str(&format!(
289                "\n_Showing {} of {} annotations (capped by `max_annotations`)._\n",
290                rendered_count, total_count
291            ));
292        }
293    }
294
295    AnnotationRenderResult {
296        content: out,
297        truncated,
298        total_count,
299        rendered_count,
300    }
301}
302
303/// Render annotations section for the PR comment.
304///
305/// This is a convenience function that wraps `render_annotations` with a section header.
306pub fn render_annotations_section(
307    highlights: &[Highlight],
308    cfg: &CockpitConfig,
309    sensor_blocking: &std::collections::BTreeMap<String, bool>,
310) -> String {
311    let result = render_annotations(highlights, cfg, sensor_blocking);
312    let mut out = String::new();
313    out.push_str("### Annotations\n\n");
314    out.push_str(&result.content);
315    out.push('\n');
316    out
317}
318
319// ============================================================================
320// Trend section rendering
321// ============================================================================
322
323/// Render a trend delta as a markdown section for the PR comment.
324pub fn render_trend_section(trend: &TrendDelta) -> String {
325    let mut out = String::new();
326    out.push_str("### Trend\n\n");
327
328    if let Some(vc) = &trend.verdict_change {
329        out.push_str(&format!(
330            "Verdict: {} → {}\n\n",
331            status_badge(&vc.before),
332            status_badge(&vc.after)
333        ));
334    }
335
336    let cd = &trend.count_deltas;
337    if cd.info_delta != 0 || cd.warn_delta != 0 || cd.error_delta != 0 {
338        out.push_str("| Severity | Delta |\n|---|---:|\n");
339        if cd.error_delta != 0 {
340            out.push_str(&format!("| Error | {:+} |\n", cd.error_delta));
341        }
342        if cd.warn_delta != 0 {
343            out.push_str(&format!("| Warn | {:+} |\n", cd.warn_delta));
344        }
345        if cd.info_delta != 0 {
346            out.push_str(&format!("| Info | {:+} |\n", cd.info_delta));
347        }
348        out.push('\n');
349    }
350
351    if !trend.new_findings.is_empty() {
352        out.push_str(&format!(
353            "**{} new finding(s)**:\n",
354            trend.new_findings.len()
355        ));
356        for f in &trend.new_findings {
357            let loc = f
358                .path
359                .as_ref()
360                .map(|p| {
361                    if let Some(line) = f.line {
362                        format!(" at `{}:{}`", p, line)
363                    } else {
364                        format!(" at `{}`", p)
365                    }
366                })
367                .unwrap_or_default();
368            out.push_str(&format!(
369                "- {} **{}**: `{}`{}\n",
370                severity_badge(&f.severity),
371                f.sensor_id,
372                f.code,
373                loc
374            ));
375        }
376        out.push('\n');
377    }
378
379    if !trend.fixed_findings.is_empty() {
380        out.push_str(&format!(
381            "**{} fixed finding(s)**:\n",
382            trend.fixed_findings.len()
383        ));
384        for f in &trend.fixed_findings {
385            out.push_str(&format!(
386                "- ~**{}**: `{}`~ — {}\n",
387                f.sensor_id, f.code, f.message
388            ));
389        }
390        out.push('\n');
391    }
392
393    if !trend.sensors_added.is_empty() {
394        out.push_str(&format!(
395            "Sensors added: {}\n",
396            trend
397                .sensors_added
398                .iter()
399                .map(|s| format!("`{}`", s))
400                .collect::<Vec<_>>()
401                .join(", ")
402        ));
403    }
404    if !trend.sensors_removed.is_empty() {
405        out.push_str(&format!(
406            "Sensors removed: {}\n",
407            trend
408                .sensors_removed
409                .iter()
410                .map(|s| format!("`{}`", s))
411                .collect::<Vec<_>>()
412                .join(", ")
413        ));
414    }
415
416    if trend.verdict_change.is_none()
417        && trend.new_findings.is_empty()
418        && trend.fixed_findings.is_empty()
419        && trend.sensors_added.is_empty()
420        && trend.sensors_removed.is_empty()
421        && cd.info_delta == 0
422        && cd.warn_delta == 0
423        && cd.error_delta == 0
424    {
425        out.push_str("_No changes from baseline._\n");
426    }
427
428    out.push('\n');
429    out
430}
431
432// ============================================================================
433// Buildfix section rendering
434// ============================================================================
435
436fn safety_badge(s: &SafetyLevel) -> &'static str {
437    match s {
438        SafetyLevel::Safe => "🟢 safe",
439        SafetyLevel::Guarded => "🟡 guarded",
440        SafetyLevel::Unsafe => "🔴 unsafe",
441    }
442}
443
444/// Render a buildfix summary as a markdown section for the PR comment.
445pub fn render_buildfix_section(summary: &BuildfixSummary) -> String {
446    let mut out = String::new();
447    out.push_str("### Buildfix\n\n");
448
449    if summary.fixes.is_empty() {
450        out.push_str("_No fixes available._\n\n");
451        return out;
452    }
453
454    out.push_str(&format!(
455        "{} fix(es) available ({} matched, {} unmatched)\n\n",
456        summary.total_fixes, summary.matched_count, summary.unmatched_count
457    ));
458
459    out.push_str("| Fix | Safety | Matched | Description |\n");
460    out.push_str("|---|---|---:|---|\n");
461
462    for fix in &summary.fixes {
463        let matched = if fix.unmatched { "no" } else { "yes" };
464        out.push_str(&format!(
465            "| `{}` | {} | {} | {} |\n",
466            fix.fix_id,
467            safety_badge(&fix.safety),
468            matched,
469            fix.description.replace('\n', " ")
470        ));
471    }
472
473    out.push('\n');
474    out
475}
476
477fn apply_status_badge(status: BuildfixApplyStatus) -> &'static str {
478    match status {
479        BuildfixApplyStatus::Skipped => "⏭ skipped",
480        BuildfixApplyStatus::Applied => "✅ applied",
481        BuildfixApplyStatus::Failed => "❌ failed",
482    }
483}
484
485/// Render buildfix apply evidence (if present) as a markdown section.
486pub fn render_buildfix_apply_section(summary: &BuildfixApplySummary) -> String {
487    let mut out = String::new();
488    out.push_str("### Buildfix Apply\n\n");
489    out.push_str(&format!(
490        "Status: {} · max safety: `{}` · require matched finding: `{}`\n\n",
491        apply_status_badge(summary.status),
492        match summary.max_auto_apply_safety {
493            SafetyLevel::Safe => "safe",
494            SafetyLevel::Guarded => "guarded",
495            SafetyLevel::Unsafe => "unsafe",
496        },
497        summary.require_matched_finding
498    ));
499
500    if let Some(reason) = &summary.reason {
501        out.push_str(&format!("Reason: `{}`\n\n", reason));
502    }
503
504    if !summary.selected_fix_ids.is_empty() {
505        out.push_str(&format!(
506            "Selected fixes: {}\n\n",
507            summary
508                .selected_fix_ids
509                .iter()
510                .map(|id| format!("`{}`", id))
511                .collect::<Vec<_>>()
512                .join(", ")
513        ));
514    }
515
516    if !summary.applied_fix_ids.is_empty() {
517        out.push_str(&format!(
518            "Applied fixes: {}\n\n",
519            summary
520                .applied_fix_ids
521                .iter()
522                .map(|id| format!("`{}`", id))
523                .collect::<Vec<_>>()
524                .join(", ")
525        ));
526    }
527
528    if !summary.errors.is_empty() {
529        out.push_str("Errors:\n");
530        for e in &summary.errors {
531            out.push_str(&format!("- {}\n", e.replace('\n', " ")));
532        }
533        out.push('\n');
534    }
535
536    out
537}
538
539fn policy_signature_algorithm_label(algorithm: PolicySignatureAlgorithm) -> &'static str {
540    match algorithm {
541        PolicySignatureAlgorithm::HmacSha256 => "hmac_sha256",
542    }
543}
544
545/// Render policy signature evidence as a markdown section for the PR comment.
546pub fn render_policy_signature_section(signature: &PolicySignatureEvidence) -> String {
547    let mut out = String::new();
548    out.push_str("### Policy Signature\n\n");
549    out.push_str(&format!(
550        "- algorithm: `{}`\n",
551        policy_signature_algorithm_label(signature.algorithm)
552    ));
553    if let Some(key_id) = &signature.key_id {
554        out.push_str(&format!("- key_id: `{}`\n", key_id));
555    }
556    out.push_str(&format!("- policy_sha256: `{}`\n", signature.policy_sha256));
557    out.push_str(&format!("- signature: `{}`\n\n", signature.signature));
558    out
559}
560
561// ============================================================================
562// GitHub Actions workflow command annotations
563// ============================================================================
564
565/// Result of GitHub annotation rendering.
566pub struct GitHubAnnotationResult {
567    /// Rendered `::error`/`::warning`/`::notice` lines.
568    pub lines: Vec<String>,
569    /// Whether annotations were truncated due to cap.
570    pub truncated: bool,
571    /// Total number of annotations before capping.
572    pub total_count: usize,
573    /// Number of annotations actually rendered.
574    pub rendered_count: usize,
575}
576
577/// Escape a string for GitHub Actions workflow command parameters.
578fn gh_escape(s: &str) -> String {
579    s.replace('%', "%25")
580        .replace('\n', "%0A")
581        .replace('\r', "%0D")
582}
583
584/// Map severity to GitHub Actions annotation level.
585fn gh_level(s: &Severity) -> &'static str {
586    match s {
587        Severity::Error => "error",
588        Severity::Warn => "warning",
589        Severity::Info => "notice",
590    }
591}
592
593/// Render GitHub Actions workflow command annotations from highlights.
594///
595/// Produces lines like:
596/// `::error file={path},line={line},col={col},title=[{sensor_id}] {code}::{message}`
597///
598/// Capped by `max_annotations` from policy. Same deterministic sort as markdown annotations.
599pub fn render_github_annotations(
600    highlights: &[Highlight],
601    cfg: &CockpitConfig,
602    sensor_blocking: &std::collections::BTreeMap<String, bool>,
603) -> GitHubAnnotationResult {
604    let max = cfg.policy.max_annotations;
605    let total_count = highlights.len();
606
607    let mut sorted: Vec<&Highlight> = highlights.iter().collect();
608    sorted.sort_by(|a, b| {
609        annotation_sort_key(a, sensor_blocking).cmp(&annotation_sort_key(b, sensor_blocking))
610    });
611
612    let truncated = total_count > max;
613    let rendered_count = total_count.min(max);
614
615    let mut lines = Vec::with_capacity(rendered_count);
616    for h in sorted.iter().take(max) {
617        let f = &h.finding;
618        let level = gh_level(&f.severity);
619        let title = gh_escape(&format!("[{}] {}", h.sensor_id, f.code));
620
621        let mut params = Vec::new();
622        if let Some(loc) = &f.location {
623            if let Some(path) = &loc.path {
624                params.push(format!("file={}", path));
625            }
626            if let Some(line) = loc.line {
627                params.push(format!("line={}", line));
628            }
629            if let Some(col) = loc.col {
630                params.push(format!("col={}", col));
631            }
632        }
633        params.push(format!("title={}", title));
634
635        let message = gh_escape(&f.message);
636        lines.push(format!("::{} {}::{}", level, params.join(","), message));
637    }
638
639    GitHubAnnotationResult {
640        lines,
641        truncated,
642        total_count,
643        rendered_count,
644    }
645}
646
647/// Shared sort key for annotation ordering (severity desc, blocking first, then sensor_id/path/line/code/message).
648fn annotation_sort_key<'a>(
649    h: &'a Highlight,
650    sensor_blocking: &std::collections::BTreeMap<String, bool>,
651) -> (
652    u8,
653    u8,
654    &'a str,
655    Option<&'a str>,
656    Option<u32>,
657    &'a str,
658    &'a str,
659) {
660    let blocking = sensor_blocking.get(&h.sensor_id).cloned().unwrap_or(false);
661    (
662        severity_rank(&h.finding.severity),
663        if blocking { 0u8 } else { 1u8 },
664        &h.sensor_id,
665        h.finding.location.as_ref().and_then(|l| l.path.as_deref()),
666        h.finding.location.as_ref().and_then(|l| l.line),
667        &h.finding.code,
668        &h.finding.message,
669    )
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use cockpitctl_types::{Finding, Location, Severity};
676
677    fn make_highlight(
678        sensor_id: &str,
679        code: &str,
680        path: Option<&str>,
681        line: Option<u32>,
682        severity: Severity,
683    ) -> Highlight {
684        Highlight {
685            sensor_id: sensor_id.to_string(),
686            finding: Finding {
687                severity,
688                check_id: None,
689                code: code.to_string(),
690                message: format!("Message for {}", code),
691                location: Some(Location {
692                    path: path.map(String::from),
693                    line,
694                    col: None,
695                }),
696                help: None,
697                url: None,
698                fingerprint: None,
699                data: None,
700            },
701        }
702    }
703
704    #[test]
705    fn test_annotation_capping_respects_max() {
706        let mut cfg = CockpitConfig::default();
707        cfg.policy.max_annotations = 3;
708
709        let highlights = vec![
710            make_highlight(
711                "sensor_a",
712                "code1",
713                Some("src/a.rs"),
714                Some(10),
715                Severity::Error,
716            ),
717            make_highlight(
718                "sensor_a",
719                "code2",
720                Some("src/a.rs"),
721                Some(20),
722                Severity::Warn,
723            ),
724            make_highlight(
725                "sensor_b",
726                "code3",
727                Some("src/b.rs"),
728                Some(5),
729                Severity::Info,
730            ),
731            make_highlight(
732                "sensor_b",
733                "code4",
734                Some("src/b.rs"),
735                Some(15),
736                Severity::Error,
737            ),
738            make_highlight(
739                "sensor_c",
740                "code5",
741                Some("src/c.rs"),
742                Some(1),
743                Severity::Warn,
744            ),
745        ];
746
747        let blocking = std::collections::BTreeMap::new();
748        let result = render_annotations(&highlights, &cfg, &blocking);
749
750        assert!(result.truncated);
751        assert_eq!(result.total_count, 5);
752        assert_eq!(result.rendered_count, 3);
753        assert!(result.content.contains("Showing 3 of 5 annotations"));
754    }
755
756    #[test]
757    fn test_annotation_capping_no_truncation_when_under_limit() {
758        let mut cfg = CockpitConfig::default();
759        cfg.policy.max_annotations = 10;
760
761        let highlights = vec![
762            make_highlight(
763                "sensor_a",
764                "code1",
765                Some("src/a.rs"),
766                Some(10),
767                Severity::Error,
768            ),
769            make_highlight(
770                "sensor_a",
771                "code2",
772                Some("src/a.rs"),
773                Some(20),
774                Severity::Warn,
775            ),
776        ];
777
778        let blocking = std::collections::BTreeMap::new();
779        let result = render_annotations(&highlights, &cfg, &blocking);
780
781        assert!(!result.truncated);
782        assert_eq!(result.total_count, 2);
783        assert_eq!(result.rendered_count, 2);
784        assert!(!result.content.contains("truncated"));
785        assert!(!result.content.contains("capped"));
786    }
787
788    #[test]
789    fn test_annotation_ordering_is_deterministic() {
790        let mut cfg = CockpitConfig::default();
791        cfg.policy.max_annotations = 25;
792
793        // Create highlights in non-sorted order
794        let highlights = vec![
795            make_highlight(
796                "sensor_z",
797                "code1",
798                Some("src/z.rs"),
799                Some(100),
800                Severity::Info,
801            ),
802            make_highlight(
803                "sensor_a",
804                "code2",
805                Some("src/a.rs"),
806                Some(10),
807                Severity::Error,
808            ),
809            make_highlight(
810                "sensor_m",
811                "code3",
812                Some("src/m.rs"),
813                Some(50),
814                Severity::Warn,
815            ),
816            make_highlight(
817                "sensor_a",
818                "code4",
819                Some("src/a.rs"),
820                Some(5),
821                Severity::Error,
822            ),
823        ];
824
825        let blocking = std::collections::BTreeMap::new();
826        let result = render_annotations(&highlights, &cfg, &blocking);
827
828        // Errors should come first (sorted by severity desc)
829        // Then within same severity, sorted by sensor_id, path, line
830        let lines: Vec<&str> = result.content.lines().collect();
831
832        // First should be sensor_a error at line 5
833        assert!(lines[0].contains("sensor_a") && lines[0].contains("code4"));
834        // Second should be sensor_a error at line 10
835        assert!(lines[1].contains("sensor_a") && lines[1].contains("code2"));
836        // Third should be sensor_m warning
837        assert!(lines[2].contains("sensor_m") && lines[2].contains("code3"));
838        // Fourth should be sensor_z info
839        assert!(lines[3].contains("sensor_z") && lines[3].contains("code1"));
840    }
841
842    #[test]
843    fn test_annotation_ordering_blocking_sensors_first() {
844        let mut cfg = CockpitConfig::default();
845        cfg.policy.max_annotations = 25;
846
847        let highlights = vec![
848            make_highlight(
849                "non_blocking",
850                "code1",
851                Some("src/a.rs"),
852                Some(10),
853                Severity::Error,
854            ),
855            make_highlight(
856                "blocking_sensor",
857                "code2",
858                Some("src/b.rs"),
859                Some(10),
860                Severity::Error,
861            ),
862        ];
863
864        let mut blocking = std::collections::BTreeMap::new();
865        blocking.insert("blocking_sensor".to_string(), true);
866        blocking.insert("non_blocking".to_string(), false);
867
868        let result = render_annotations(&highlights, &cfg, &blocking);
869
870        let lines: Vec<&str> = result.content.lines().collect();
871        // Blocking sensor should come first even though both are errors
872        assert!(lines[0].contains("blocking_sensor"));
873        assert!(lines[1].contains("non_blocking"));
874    }
875
876    #[test]
877    fn test_annotation_ordering_blocking_branch_reverse_input() {
878        let mut cfg = CockpitConfig::default();
879        cfg.policy.max_annotations = 25;
880
881        let highlights = vec![
882            make_highlight(
883                "blocking_sensor",
884                "code_block",
885                Some("src/b.rs"),
886                Some(10),
887                Severity::Error,
888            ),
889            make_highlight(
890                "non_blocking",
891                "code_non",
892                Some("src/a.rs"),
893                Some(10),
894                Severity::Error,
895            ),
896        ];
897
898        let mut blocking = std::collections::BTreeMap::new();
899        blocking.insert("blocking_sensor".to_string(), true);
900        blocking.insert("non_blocking".to_string(), false);
901
902        let result = render_annotations(&highlights, &cfg, &blocking);
903
904        let lines: Vec<&str> = result.content.lines().collect();
905        assert!(lines[0].contains("blocking_sensor"));
906        assert!(lines[1].contains("non_blocking"));
907    }
908
909    #[test]
910    fn test_empty_annotations() {
911        let cfg = CockpitConfig::default();
912        let highlights: Vec<Highlight> = vec![];
913        let blocking = std::collections::BTreeMap::new();
914
915        let result = render_annotations(&highlights, &cfg, &blocking);
916
917        assert!(!result.truncated);
918        assert_eq!(result.total_count, 0);
919        assert_eq!(result.rendered_count, 0);
920        assert!(result.content.contains("No annotations"));
921    }
922
923    #[test]
924    fn test_annotation_at_exact_limit() {
925        let mut cfg = CockpitConfig::default();
926        cfg.policy.max_annotations = 2;
927
928        let highlights = vec![
929            make_highlight(
930                "sensor_a",
931                "code1",
932                Some("src/a.rs"),
933                Some(10),
934                Severity::Error,
935            ),
936            make_highlight(
937                "sensor_b",
938                "code2",
939                Some("src/b.rs"),
940                Some(20),
941                Severity::Warn,
942            ),
943        ];
944
945        let blocking = std::collections::BTreeMap::new();
946        let result = render_annotations(&highlights, &cfg, &blocking);
947
948        assert!(!result.truncated);
949        assert_eq!(result.total_count, 2);
950        assert_eq!(result.rendered_count, 2);
951        assert!(!result.content.contains("capped"));
952    }
953
954    #[test]
955    fn test_annotation_without_location_omits_loc_string() {
956        let cfg = CockpitConfig::default();
957        let highlights = vec![Highlight {
958            sensor_id: "sensor_a".to_string(),
959            finding: Finding {
960                severity: Severity::Warn,
961                check_id: None,
962                code: "code1".to_string(),
963                message: "message".to_string(),
964                location: None,
965                help: None,
966                url: None,
967                fingerprint: None,
968                data: None,
969            },
970        }];
971
972        let blocking = std::collections::BTreeMap::new();
973        let result = render_annotations(&highlights, &cfg, &blocking);
974
975        assert!(result.content.contains("`code1`"));
976        assert!(!result.content.contains(" at `"));
977    }
978
979    #[test]
980    fn test_annotation_with_empty_location_omits_loc_string() {
981        let cfg = CockpitConfig::default();
982        let highlights = vec![Highlight {
983            sensor_id: "sensor_a".to_string(),
984            finding: Finding {
985                severity: Severity::Info,
986                check_id: None,
987                code: "code2".to_string(),
988                message: "message".to_string(),
989                location: Some(Location {
990                    path: None,
991                    line: None,
992                    col: None,
993                }),
994                help: None,
995                url: None,
996                fingerprint: None,
997                data: None,
998            },
999        }];
1000
1001        let blocking = std::collections::BTreeMap::new();
1002        let result = render_annotations(&highlights, &cfg, &blocking);
1003
1004        assert!(result.content.contains("`code2`"));
1005        assert!(!result.content.contains(" at `"));
1006    }
1007
1008    #[test]
1009    fn append_comment_sections_inserts_before_end_marker() {
1010        let base = "<!-- cockpit:begin -->\n## Cockpit\n\n<!-- cockpit:end -->\n";
1011        let sections = vec![("Hook".to_string(), "From hook".to_string())];
1012
1013        let out = append_comment_sections(base, &sections);
1014        let hook_idx = out.find("### Hook").expect("hook section");
1015        let end_idx = out.find("<!-- cockpit:end -->").expect("end marker");
1016
1017        assert!(hook_idx < end_idx, "hook section must be before end marker");
1018        assert!(out.contains("From hook"));
1019    }
1020
1021    #[test]
1022    fn append_comment_sections_appends_when_marker_missing() {
1023        let base = "## Cockpit\n";
1024        let sections = vec![("Extra".to_string(), "Section body".to_string())];
1025
1026        let out = append_comment_sections(base, &sections);
1027        assert!(out.contains("### Extra"));
1028        assert!(out.ends_with("Section body\n\n"));
1029    }
1030}