1use cockpitctl_types::{
11 BuildfixApplyStatus, BuildfixApplySummary, BuildfixSummary, CockpitConfig, CockpitReport,
12 Highlight, PolicySignatureAlgorithm, PolicySignatureEvidence, SafetyLevel, Severity,
13 TrendDelta, VerdictStatus, severity_rank,
14};
15
16pub struct AnnotationRenderResult {
18 pub content: String,
20 pub truncated: bool,
22 pub total_count: usize,
24 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 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 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 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 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
193pub 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
232pub 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 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
303pub 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
319pub 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
432fn safety_badge(s: &SafetyLevel) -> &'static str {
437 match s {
438 SafetyLevel::Safe => "🟢 safe",
439 SafetyLevel::Guarded => "🟡 guarded",
440 SafetyLevel::Unsafe => "🔴 unsafe",
441 }
442}
443
444pub 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
485pub 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
545pub 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
561pub struct GitHubAnnotationResult {
567 pub lines: Vec<String>,
569 pub truncated: bool,
571 pub total_count: usize,
573 pub rendered_count: usize,
575}
576
577fn gh_escape(s: &str) -> String {
579 s.replace('%', "%25")
580 .replace('\n', "%0A")
581 .replace('\r', "%0D")
582}
583
584fn gh_level(s: &Severity) -> &'static str {
586 match s {
587 Severity::Error => "error",
588 Severity::Warn => "warning",
589 Severity::Info => "notice",
590 }
591}
592
593pub 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
647fn 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 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 let lines: Vec<&str> = result.content.lines().collect();
831
832 assert!(lines[0].contains("sensor_a") && lines[0].contains("code4"));
834 assert!(lines[1].contains("sensor_a") && lines[1].contains("code2"));
836 assert!(lines[2].contains("sensor_m") && lines[2].contains("code3"));
838 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 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, §ions);
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, §ions);
1027 assert!(out.contains("### Extra"));
1028 assert!(out.ends_with("Section body\n\n"));
1029 }
1030}