use crate::analyses::hotspots::HotspotRow;
use crate::{CodeLoreError, Result};
use std::io::Write;
const ERROR_FLOOR: f64 = 7.0;
const WARNING_FLOOR: f64 = 4.0;
fn escape_property(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'%' => out.push_str("%25"),
'\r' => out.push_str("%0D"),
'\n' => out.push_str("%0A"),
':' => out.push_str("%3A"),
',' => out.push_str("%2C"),
c => out.push(c),
}
}
out
}
fn escape_message(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'%' => out.push_str("%25"),
'\r' => out.push_str("%0D"),
'\n' => out.push_str("%0A"),
c => out.push(c),
}
}
out
}
pub fn write_hotspots_gha<W: Write>(rows: &[HotspotRow], w: &mut W) -> Result<()> {
for row in rows {
let level = if row.hotspot_score >= ERROR_FLOOR {
"error"
} else if row.hotspot_score >= WARNING_FLOOR {
"warning"
} else {
"notice"
};
let title = format!("CodeLore hotspot — score {:.2}", row.hotspot_score);
let message = format!(
"Hotspot: {} revisions, cognitive {:.0}, cognitive-health {:.1}, score {:.2}",
row.revisions, row.cognitive, row.cognitive_health, row.hotspot_score
);
writeln!(
w,
"::{level} file={file},title={title}::{message}",
level = level,
file = escape_property(&row.path),
title = escape_property(&title),
message = escape_message(&message),
)
.map_err(CodeLoreError::Io)?;
}
Ok(())
}
pub fn write_gate_violations_gha<W: Write>(
violations: &[crate::quality_gates::GateViolation],
w: &mut W,
) -> Result<()> {
for v in violations {
let title = format!("CodeLore gate: {}", v.gate);
let message = format!("{} = {} (threshold {})", v.gate, v.actual, v.threshold);
if v.path.starts_with('(') {
writeln!(
w,
"::error title={title}::{message}",
title = escape_property(&title),
message = escape_message(&message),
)
} else {
writeln!(
w,
"::error file={file},title={title}::{message}",
file = escape_property(&v.path),
title = escape_property(&title),
message = escape_message(&message),
)
}
.map_err(CodeLoreError::Io)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn row(path: &str, score: f64) -> HotspotRow {
HotspotRow {
path: path.into(),
revisions: 12,
cognitive: 25.0,
cognitive_health: 65.0,
hotspot_score: score,
mi: None,
mi_rank: None,
ai_pct: None,
hotspot_score_anchored: None,
}
}
#[test]
fn high_score_emits_error_level() {
let mut buf = Vec::new();
write_hotspots_gha(&[row("src/main.rs", 8.5)], &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.starts_with("::error file=src/main.rs"), "got: {out}");
assert!(out.contains("score 8.50"));
}
#[test]
fn medium_score_emits_warning_level() {
let mut buf = Vec::new();
write_hotspots_gha(&[row("src/lib.rs", 5.0)], &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.starts_with("::warning file=src/lib.rs"));
}
#[test]
fn low_score_emits_notice_level() {
let mut buf = Vec::new();
write_hotspots_gha(&[row("src/util.rs", 1.0)], &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.starts_with("::notice file=src/util.rs"));
}
#[test]
fn path_with_special_chars_is_escaped_in_properties() {
let mut buf = Vec::new();
write_hotspots_gha(&[row("path/with comma,colon:.rs", 5.0)], &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("path/with comma%2Ccolon%3A.rs"), "got: {out}");
}
#[test]
fn newline_in_message_is_escaped() {
let mut buf = Vec::new();
let r = row("a\nb.rs", 5.0);
write_hotspots_gha(&[r], &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("a%0Ab.rs"));
assert_eq!(out.matches('\n').count(), 1);
}
#[test]
fn empty_input_emits_no_lines() {
let mut buf = Vec::new();
write_hotspots_gha(&[], &mut buf).unwrap();
assert!(buf.is_empty());
}
#[test]
fn gate_violations_anchor_files_and_repo_wide_scopes() {
use crate::quality_gates::GateViolation;
let violations = vec![
GateViolation {
gate: "hotspot_score_max".into(),
path: "src/a.rs".into(),
actual: "9.3".into(),
threshold: "5.0".into(),
},
GateViolation {
gate: "max_dependency_cycles".into(),
path: "(repo-wide)".into(),
actual: "1".into(),
threshold: "0".into(),
},
];
let mut buf = Vec::new();
write_gate_violations_gha(&violations, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(
out.contains("::error file=src/a.rs,title=CodeLore gate%3A hotspot_score_max::"),
"file-anchored annotation: {out}"
);
assert!(
out.contains("::error title=CodeLore gate%3A max_dependency_cycles::"),
"fileless repo-wide annotation: {out}"
);
assert!(
!out.lines().nth(1).unwrap().contains("file="),
"repo-wide line must NOT carry file=: {out}"
);
}
#[test]
fn gate_violations_empty_emits_no_lines() {
let mut buf = Vec::new();
write_gate_violations_gha(&[], &mut buf).unwrap();
assert!(buf.is_empty());
}
}