use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::rules::results::{AuditResults, Finding, Severity};
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct FindingKey {
pub rule_id: String,
pub category: String,
pub message: String,
pub location: Option<String>,
}
impl FindingKey {
pub fn from_finding(finding: &Finding) -> Self {
Self {
rule_id: finding.rule_id.clone(),
category: finding.category.clone(),
message: finding.message.clone(),
location: finding.location.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategoryDiff {
pub category: String,
pub base_count: usize,
pub head_count: usize,
pub diff: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompareReport {
pub base_ref: String,
pub head_ref: String,
pub base_findings: Vec<Finding>,
pub head_findings: Vec<Finding>,
pub added_findings: Vec<Finding>,
pub removed_findings: Vec<Finding>,
pub unchanged_findings: Vec<Finding>,
pub base_score: i64,
pub head_score: i64,
pub score_diff: i64,
pub category_diffs: Vec<CategoryDiff>,
}
impl CompareReport {
pub fn has_regressions(&self) -> bool {
!self.added_findings.is_empty()
}
#[allow(dead_code)]
pub fn has_improvements(&self) -> bool {
!self.removed_findings.is_empty()
}
}
pub fn compute_score(results: &AuditResults) -> i64 {
let critical = results.count_by_severity(Severity::Critical) as i64;
let warning = results.count_by_severity(Severity::Warning) as i64;
let info = results.count_by_severity(Severity::Info) as i64;
critical * 10 + warning * 3 + info
}
pub fn compare_results(
base: &AuditResults,
head: &AuditResults,
base_ref: &str,
head_ref: &str,
) -> CompareReport {
let base_keys: HashSet<FindingKey> = base
.findings()
.iter()
.map(FindingKey::from_finding)
.collect();
let head_keys: HashSet<FindingKey> = head
.findings()
.iter()
.map(FindingKey::from_finding)
.collect();
let added_findings: Vec<Finding> = head
.findings()
.iter()
.filter(|f| !base_keys.contains(&FindingKey::from_finding(f)))
.cloned()
.collect();
let removed_findings: Vec<Finding> = base
.findings()
.iter()
.filter(|f| !head_keys.contains(&FindingKey::from_finding(f)))
.cloned()
.collect();
let unchanged_findings: Vec<Finding> = head
.findings()
.iter()
.filter(|f| base_keys.contains(&FindingKey::from_finding(f)))
.cloned()
.collect();
let base_score = compute_score(base);
let head_score = compute_score(head);
let score_diff = head_score - base_score;
let mut all_categories: Vec<String> = Vec::new();
for f in base.findings().iter().chain(head.findings().iter()) {
if !all_categories.contains(&f.category) {
all_categories.push(f.category.clone());
}
}
all_categories.sort();
let category_diffs: Vec<CategoryDiff> = all_categories
.into_iter()
.map(|cat| {
let base_count = base.findings_by_category(&cat).count();
let head_count = head.findings_by_category(&cat).count();
CategoryDiff {
category: cat,
base_count,
head_count,
diff: head_count as i64 - base_count as i64,
}
})
.collect();
CompareReport {
base_ref: base_ref.to_string(),
head_ref: head_ref.to_string(),
base_findings: base.findings().to_vec(),
head_findings: head.findings().to_vec(),
added_findings,
removed_findings,
unchanged_findings,
base_score,
head_score,
score_diff,
category_diffs,
}
}
pub fn format_terminal(report: &CompareReport) -> String {
use colored::Colorize;
let mut output = String::new();
output.push_str(&format!(
"\n{}\n\n",
"RepoLens Audit Comparison".cyan().bold()
));
output.push_str(&format!(
" {} {}\n",
"Base:".dimmed(),
report.base_ref.white().bold()
));
output.push_str(&format!(
" {} {}\n",
"Head:".dimmed(),
report.head_ref.white().bold()
));
output.push_str(&format!("\n{}\n", "━".repeat(50).dimmed()));
output.push_str(&format!(" {}\n\n", "SCORE".bold()));
let score_arrow = if report.score_diff < 0 {
format!("{}", format!("{} (improved)", report.score_diff).green())
} else if report.score_diff > 0 {
format!("{}", format!("+{} (regressed)", report.score_diff).red())
} else {
format!("{}", "0 (no change)".dimmed())
};
output.push_str(&format!(
" {} -> {} ({})\n",
report.base_score.to_string().white(),
report.head_score.to_string().white(),
score_arrow,
));
output.push_str(&format!("\n{}\n", "━".repeat(50).dimmed()));
output.push_str(&format!(
" {} ({})\n\n",
"NEW ISSUES".red().bold(),
report.added_findings.len()
));
if report.added_findings.is_empty() {
output.push_str(&format!(" {}\n", "No new issues.".green()));
} else {
for finding in &report.added_findings {
let severity_tag = match finding.severity {
Severity::Critical => "CRITICAL".red().bold().to_string(),
Severity::Warning => "WARNING".yellow().bold().to_string(),
Severity::Info => "INFO".blue().bold().to_string(),
};
output.push_str(&format!(
" {} [{}] [{}] {}\n",
"+".red(),
finding.rule_id.cyan(),
severity_tag,
finding.message
));
if let Some(loc) = &finding.location {
output.push_str(&format!(" {} {}\n", "└─".dimmed(), loc.dimmed()));
}
}
}
output.push_str(&format!("\n{}\n", "━".repeat(50).dimmed()));
output.push_str(&format!(
" {} ({})\n\n",
"RESOLVED ISSUES".green().bold(),
report.removed_findings.len()
));
if report.removed_findings.is_empty() {
output.push_str(&format!(" {}\n", "No resolved issues.".dimmed()));
} else {
for finding in &report.removed_findings {
let severity_tag = match finding.severity {
Severity::Critical => "CRITICAL".red().bold().to_string(),
Severity::Warning => "WARNING".yellow().bold().to_string(),
Severity::Info => "INFO".blue().bold().to_string(),
};
output.push_str(&format!(
" {} [{}] [{}] {}\n",
"-".green(),
finding.rule_id.cyan(),
severity_tag,
finding.message
));
if let Some(loc) = &finding.location {
output.push_str(&format!(" {} {}\n", "└─".dimmed(), loc.dimmed()));
}
}
}
if !report.category_diffs.is_empty() {
output.push_str(&format!("\n{}\n", "━".repeat(50).dimmed()));
output.push_str(&format!(" {}\n\n", "CATEGORY BREAKDOWN".bold()));
output.push_str(&format!(
" {:<15} {:>6} {:>6} {:>8}\n",
"Category", "Base", "Head", "Diff"
));
output.push_str(&format!(" {}\n", "─".repeat(40)));
for cat in &report.category_diffs {
let diff_str = if cat.diff > 0 {
format!("+{}", cat.diff).red().to_string()
} else if cat.diff < 0 {
format!("{}", cat.diff).green().to_string()
} else {
"0".dimmed().to_string()
};
output.push_str(&format!(
" {:<15} {:>6} {:>6} {:>8}\n",
cat.category, cat.base_count, cat.head_count, diff_str
));
}
}
output.push_str(&format!("\n{}\n", "━".repeat(50).dimmed()));
output.push_str(&format!(
" {} {} unchanged issue(s)\n\n",
"Unchanged:".dimmed(),
report.unchanged_findings.len()
));
output
}
pub fn format_json(report: &CompareReport) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(report)
}
pub fn format_markdown(report: &CompareReport) -> String {
let mut output = String::new();
output.push_str("# RepoLens Audit Comparison\n\n");
output.push_str(&format!("**Base:** {}\n", report.base_ref));
output.push_str(&format!("**Head:** {}\n\n", report.head_ref));
output.push_str("## Score\n\n");
let trend = if report.score_diff < 0 {
format!("{} (improved)", report.score_diff)
} else if report.score_diff > 0 {
format!("+{} (regressed)", report.score_diff)
} else {
"0 (no change)".to_string()
};
output.push_str(&format!(
"| Base | Head | Diff |\n|------|------|------|\n| {} | {} | {} |\n\n",
report.base_score, report.head_score, trend
));
output.push_str(&format!(
"## New Issues ({})\n\n",
report.added_findings.len()
));
if report.added_findings.is_empty() {
output.push_str("No new issues.\n\n");
} else {
output.push_str("| Rule | Severity | Message | Location |\n");
output.push_str("|------|----------|---------|----------|\n");
for f in &report.added_findings {
let sev = match f.severity {
Severity::Critical => "Critical",
Severity::Warning => "Warning",
Severity::Info => "Info",
};
let loc = f.location.as_deref().unwrap_or("-");
output.push_str(&format!(
"| {} | {} | {} | {} |\n",
f.rule_id, sev, f.message, loc
));
}
output.push('\n');
}
output.push_str(&format!(
"## Resolved Issues ({})\n\n",
report.removed_findings.len()
));
if report.removed_findings.is_empty() {
output.push_str("No resolved issues.\n\n");
} else {
output.push_str("| Rule | Severity | Message | Location |\n");
output.push_str("|------|----------|---------|----------|\n");
for f in &report.removed_findings {
let sev = match f.severity {
Severity::Critical => "Critical",
Severity::Warning => "Warning",
Severity::Info => "Info",
};
let loc = f.location.as_deref().unwrap_or("-");
output.push_str(&format!(
"| {} | {} | {} | {} |\n",
f.rule_id, sev, f.message, loc
));
}
output.push('\n');
}
if !report.category_diffs.is_empty() {
output.push_str("## Category Breakdown\n\n");
output.push_str("| Category | Base | Head | Diff |\n");
output.push_str("|----------|------|------|------|\n");
for cat in &report.category_diffs {
let diff_str = if cat.diff > 0 {
format!("+{}", cat.diff)
} else {
format!("{}", cat.diff)
};
output.push_str(&format!(
"| {} | {} | {} | {} |\n",
cat.category, cat.base_count, cat.head_count, diff_str
));
}
output.push('\n');
}
output.push_str(&format!(
"**Unchanged issues:** {}\n\n",
report.unchanged_findings.len()
));
output.push_str("---\n\n");
output.push_str("*Report generated by [RepoLens](https://github.com/systm-d/repolens)*\n");
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::results::{AuditResults, Finding, Severity};
fn make_finding(rule_id: &str, category: &str, severity: Severity, message: &str) -> Finding {
Finding::new(rule_id, category, severity, message)
}
fn make_finding_with_location(
rule_id: &str,
category: &str,
severity: Severity,
message: &str,
location: &str,
) -> Finding {
Finding::new(rule_id, category, severity, message).with_location(location)
}
fn make_results(name: &str, findings: Vec<Finding>) -> AuditResults {
let mut results = AuditResults::new(name, "opensource");
results.add_findings(findings);
results
}
#[test]
fn test_compare_identical_results() {
let findings = vec![
make_finding("SEC001", "secrets", Severity::Critical, "Secret found"),
make_finding("DOC001", "docs", Severity::Warning, "README missing"),
];
let base = make_results("repo", findings.clone());
let head = make_results("repo", findings);
let report = compare_results(&base, &head, "v1.0", "v1.1");
assert!(report.added_findings.is_empty());
assert!(report.removed_findings.is_empty());
assert_eq!(report.unchanged_findings.len(), 2);
assert_eq!(report.score_diff, 0);
assert!(!report.has_regressions());
assert!(!report.has_improvements());
}
#[test]
fn test_compare_with_added_findings() {
let base = make_results(
"repo",
vec![make_finding(
"DOC001",
"docs",
Severity::Warning,
"README missing",
)],
);
let head = make_results(
"repo",
vec![
make_finding("DOC001", "docs", Severity::Warning, "README missing"),
make_finding("SEC001", "secrets", Severity::Critical, "Secret found"),
],
);
let report = compare_results(&base, &head, "base", "head");
assert_eq!(report.added_findings.len(), 1);
assert_eq!(report.added_findings[0].rule_id, "SEC001");
assert!(report.removed_findings.is_empty());
assert_eq!(report.unchanged_findings.len(), 1);
assert!(report.has_regressions());
assert!(!report.has_improvements());
assert!(report.score_diff > 0); }
#[test]
fn test_compare_with_removed_findings() {
let base = make_results(
"repo",
vec![
make_finding("DOC001", "docs", Severity::Warning, "README missing"),
make_finding("SEC001", "secrets", Severity::Critical, "Secret found"),
],
);
let head = make_results(
"repo",
vec![make_finding(
"DOC001",
"docs",
Severity::Warning,
"README missing",
)],
);
let report = compare_results(&base, &head, "before", "after");
assert!(report.added_findings.is_empty());
assert_eq!(report.removed_findings.len(), 1);
assert_eq!(report.removed_findings[0].rule_id, "SEC001");
assert_eq!(report.unchanged_findings.len(), 1);
assert!(!report.has_regressions());
assert!(report.has_improvements());
assert!(report.score_diff < 0); }
#[test]
fn test_compare_with_mixed_changes() {
let base = make_results(
"repo",
vec![
make_finding("SEC001", "secrets", Severity::Critical, "Secret found"),
make_finding("DOC001", "docs", Severity::Warning, "README missing"),
make_finding(
"INFO001",
"quality",
Severity::Info,
"Consider adding tests",
),
],
);
let head = make_results(
"repo",
vec![
make_finding("DOC001", "docs", Severity::Warning, "README missing"),
make_finding("SEC002", "secrets", Severity::Warning, "Weak password"),
make_finding("WF001", "workflows", Severity::Info, "No CI configured"),
],
);
let report = compare_results(&base, &head, "v1", "v2");
assert_eq!(report.added_findings.len(), 2);
assert_eq!(report.removed_findings.len(), 2);
assert_eq!(report.unchanged_findings.len(), 1);
assert!(report.has_regressions());
assert!(report.has_improvements());
}
#[test]
fn test_score_diff() {
let base = make_results(
"repo",
vec![
make_finding("SEC001", "secrets", Severity::Critical, "Secret found"),
make_finding("DOC001", "docs", Severity::Warning, "README missing"),
],
);
let head = make_results(
"repo",
vec![
make_finding("DOC001", "docs", Severity::Warning, "README missing"),
make_finding("INFO001", "quality", Severity::Info, "Consider tests"),
],
);
let report = compare_results(&base, &head, "old", "new");
assert_eq!(report.base_score, 13);
assert_eq!(report.head_score, 4);
assert_eq!(report.score_diff, -9); }
#[test]
fn test_compute_score() {
let results = make_results(
"repo",
vec![
make_finding("C1", "test", Severity::Critical, "Critical"),
make_finding("C2", "test", Severity::Critical, "Critical 2"),
make_finding("W1", "test", Severity::Warning, "Warning"),
make_finding("I1", "test", Severity::Info, "Info"),
],
);
assert_eq!(compute_score(&results), 24);
}
#[test]
fn test_compute_score_empty() {
let results = make_results("repo", vec![]);
assert_eq!(compute_score(&results), 0);
}
#[test]
fn test_format_json() {
let base = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "v1", "v2");
let json_str = format_json(&report).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["base_ref"], "v1");
assert_eq!(parsed["head_ref"], "v2");
assert_eq!(parsed["score_diff"], -10);
assert_eq!(parsed["removed_findings"].as_array().unwrap().len(), 1);
assert!(parsed["added_findings"].as_array().unwrap().is_empty());
}
#[test]
fn test_fail_on_regression_true() {
let base = make_results("repo", vec![]);
let head = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let report = compare_results(&base, &head, "base", "head");
assert!(report.has_regressions());
}
#[test]
fn test_fail_on_regression_false() {
let base = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "base", "head");
assert!(!report.has_regressions());
}
#[test]
fn test_category_diffs() {
let base = make_results(
"repo",
vec![
make_finding("SEC001", "secrets", Severity::Critical, "Secret 1"),
make_finding("SEC002", "secrets", Severity::Warning, "Secret 2"),
make_finding("DOC001", "docs", Severity::Warning, "Doc issue"),
],
);
let head = make_results(
"repo",
vec![
make_finding("SEC001", "secrets", Severity::Critical, "Secret 1"),
make_finding("WF001", "workflows", Severity::Info, "No CI"),
],
);
let report = compare_results(&base, &head, "v1", "v2");
assert_eq!(report.category_diffs.len(), 3);
let docs = report
.category_diffs
.iter()
.find(|c| c.category == "docs")
.unwrap();
assert_eq!(docs.base_count, 1);
assert_eq!(docs.head_count, 0);
assert_eq!(docs.diff, -1);
let secrets = report
.category_diffs
.iter()
.find(|c| c.category == "secrets")
.unwrap();
assert_eq!(secrets.base_count, 2);
assert_eq!(secrets.head_count, 1);
assert_eq!(secrets.diff, -1);
let workflows = report
.category_diffs
.iter()
.find(|c| c.category == "workflows")
.unwrap();
assert_eq!(workflows.base_count, 0);
assert_eq!(workflows.head_count, 1);
assert_eq!(workflows.diff, 1);
}
#[test]
fn test_finding_key_from_finding() {
let finding =
make_finding_with_location("SEC001", "secrets", Severity::Critical, "Secret", "a.rs");
let key = FindingKey::from_finding(&finding);
assert_eq!(key.rule_id, "SEC001");
assert_eq!(key.category, "secrets");
assert_eq!(key.message, "Secret");
assert_eq!(key.location, Some("a.rs".to_string()));
}
#[test]
fn test_finding_key_equality() {
let f1 = make_finding("SEC001", "secrets", Severity::Critical, "Secret");
let f2 = make_finding("SEC001", "secrets", Severity::Warning, "Secret");
assert_eq!(FindingKey::from_finding(&f1), FindingKey::from_finding(&f2));
}
#[test]
fn test_finding_key_inequality_different_message() {
let f1 = make_finding("SEC001", "secrets", Severity::Critical, "Secret A");
let f2 = make_finding("SEC001", "secrets", Severity::Critical, "Secret B");
assert_ne!(FindingKey::from_finding(&f1), FindingKey::from_finding(&f2));
}
#[test]
fn test_format_terminal_with_changes() {
let base = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let head = make_results(
"repo",
vec![make_finding(
"DOC001",
"docs",
Severity::Warning,
"README missing",
)],
);
let report = compare_results(&base, &head, "v1.0", "v2.0");
let output = format_terminal(&report);
assert!(output.contains("v1.0"));
assert!(output.contains("v2.0"));
assert!(output.contains("NEW ISSUES"));
assert!(output.contains("RESOLVED ISSUES"));
assert!(output.contains("DOC001"));
assert!(output.contains("SEC001"));
}
#[test]
fn test_format_terminal_no_changes() {
let findings = vec![make_finding(
"DOC001",
"docs",
Severity::Warning,
"README missing",
)];
let base = make_results("repo", findings.clone());
let head = make_results("repo", findings);
let report = compare_results(&base, &head, "a", "b");
let output = format_terminal(&report);
assert!(output.contains("No new issues"));
assert!(output.contains("No resolved issues"));
assert!(output.contains("0 (no change)"));
}
#[test]
fn test_format_terminal_improvement() {
let base = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "before", "after");
let output = format_terminal(&report);
assert!(output.contains("improved"));
}
#[test]
fn test_format_terminal_regression() {
let base = make_results("repo", vec![]);
let head = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let report = compare_results(&base, &head, "before", "after");
let output = format_terminal(&report);
assert!(output.contains("regressed"));
}
#[test]
fn test_format_terminal_with_location() {
let base = make_results("repo", vec![]);
let head = make_results(
"repo",
vec![make_finding_with_location(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
"src/config.rs:42",
)],
);
let report = compare_results(&base, &head, "a", "b");
let output = format_terminal(&report);
assert!(output.contains("src/config.rs:42"));
}
#[test]
fn test_format_markdown_with_changes() {
let base = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let head = make_results(
"repo",
vec![make_finding(
"DOC001",
"docs",
Severity::Warning,
"README missing",
)],
);
let report = compare_results(&base, &head, "v1", "v2");
let output = format_markdown(&report);
assert!(output.contains("# RepoLens Audit Comparison"));
assert!(output.contains("**Base:** v1"));
assert!(output.contains("**Head:** v2"));
assert!(output.contains("## New Issues (1)"));
assert!(output.contains("## Resolved Issues (1)"));
assert!(output.contains("DOC001"));
assert!(output.contains("SEC001"));
assert!(output.contains("## Category Breakdown"));
}
#[test]
fn test_format_markdown_no_issues() {
let base = make_results("repo", vec![]);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "a", "b");
let output = format_markdown(&report);
assert!(output.contains("No new issues"));
assert!(output.contains("No resolved issues"));
assert!(output.contains("0 (no change)"));
}
#[test]
fn test_format_markdown_regression_score() {
let base = make_results("repo", vec![]);
let head = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let report = compare_results(&base, &head, "a", "b");
let output = format_markdown(&report);
assert!(output.contains("+10 (regressed)"));
}
#[test]
fn test_format_markdown_improved_score() {
let base = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "a", "b");
let output = format_markdown(&report);
assert!(output.contains("-10 (improved)"));
}
#[test]
fn test_format_markdown_with_location() {
let base = make_results("repo", vec![]);
let head = make_results(
"repo",
vec![make_finding_with_location(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
"src/main.rs:10",
)],
);
let report = compare_results(&base, &head, "a", "b");
let output = format_markdown(&report);
assert!(output.contains("src/main.rs:10"));
}
#[test]
fn test_format_markdown_no_location() {
let base = make_results("repo", vec![]);
let head = make_results(
"repo",
vec![make_finding(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
)],
);
let report = compare_results(&base, &head, "a", "b");
let output = format_markdown(&report);
assert!(output.contains("| - |"));
}
#[test]
fn test_compare_empty_results() {
let base = make_results("repo", vec![]);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "empty1", "empty2");
assert!(report.added_findings.is_empty());
assert!(report.removed_findings.is_empty());
assert!(report.unchanged_findings.is_empty());
assert_eq!(report.base_score, 0);
assert_eq!(report.head_score, 0);
assert_eq!(report.score_diff, 0);
assert!(report.category_diffs.is_empty());
assert!(!report.has_regressions());
assert!(!report.has_improvements());
}
#[test]
fn test_compare_report_refs() {
let base = make_results("repo", vec![]);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "my-base", "my-head");
assert_eq!(report.base_ref, "my-base");
assert_eq!(report.head_ref, "my-head");
}
#[test]
fn test_format_terminal_all_severities() {
let base = make_results("repo", vec![]);
let head = make_results(
"repo",
vec![
make_finding("C1", "test", Severity::Critical, "Critical issue"),
make_finding("W1", "test", Severity::Warning, "Warning issue"),
make_finding("I1", "test", Severity::Info, "Info issue"),
],
);
let report = compare_results(&base, &head, "a", "b");
let output = format_terminal(&report);
assert!(output.contains("CRITICAL"));
assert!(output.contains("WARNING"));
assert!(output.contains("INFO"));
}
#[test]
fn test_format_terminal_all_severities_resolved() {
let base = make_results(
"repo",
vec![
make_finding("C1", "test", Severity::Critical, "Critical issue"),
make_finding("W1", "test", Severity::Warning, "Warning issue"),
make_finding("I1", "test", Severity::Info, "Info issue"),
],
);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "a", "b");
let output = format_terminal(&report);
assert!(output.contains("RESOLVED ISSUES"));
assert!(output.contains("C1"));
assert!(output.contains("W1"));
assert!(output.contains("I1"));
}
#[test]
fn test_format_terminal_resolved_with_location() {
let base = make_results(
"repo",
vec![make_finding_with_location(
"SEC001",
"secrets",
Severity::Critical,
"Secret found",
"src/lib.rs:5",
)],
);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "a", "b");
let output = format_terminal(&report);
assert!(output.contains("src/lib.rs:5"));
}
#[test]
fn test_format_terminal_category_positive_diff() {
let base = make_results("repo", vec![]);
let head = make_results(
"repo",
vec![
make_finding("SEC001", "secrets", Severity::Critical, "Secret 1"),
make_finding("SEC002", "secrets", Severity::Warning, "Secret 2"),
],
);
let report = compare_results(&base, &head, "a", "b");
let output = format_terminal(&report);
assert!(output.contains("CATEGORY BREAKDOWN"));
assert!(output.contains("secrets"));
}
#[test]
fn test_format_markdown_new_issues_all_severities() {
let base = make_results("repo", vec![]);
let head = make_results(
"repo",
vec![
make_finding("C1", "test", Severity::Critical, "Critical issue"),
make_finding("W1", "test", Severity::Warning, "Warning issue"),
make_finding("I1", "test", Severity::Info, "Info issue"),
],
);
let report = compare_results(&base, &head, "a", "b");
let output = format_markdown(&report);
assert!(output.contains("| C1 | Critical |"));
assert!(output.contains("| W1 | Warning |"));
assert!(output.contains("| I1 | Info |"));
}
#[test]
fn test_format_markdown_resolved_issues_all_severities() {
let base = make_results(
"repo",
vec![
make_finding("C1", "test", Severity::Critical, "Critical resolved"),
make_finding("W1", "test", Severity::Warning, "Warning resolved"),
make_finding("I1", "test", Severity::Info, "Info resolved"),
],
);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "a", "b");
let output = format_markdown(&report);
assert!(output.contains("## Resolved Issues (3)"));
assert!(output.contains("| C1 | Critical | Critical resolved"));
assert!(output.contains("| W1 | Warning | Warning resolved"));
assert!(output.contains("| I1 | Info | Info resolved"));
}
#[test]
fn test_format_markdown_footer() {
let base = make_results("repo", vec![]);
let head = make_results("repo", vec![]);
let report = compare_results(&base, &head, "a", "b");
let output = format_markdown(&report);
assert!(output.contains("---"));
assert!(output.contains("*Report generated by [RepoLens]"));
}
}