use anyhow::{Context, Result};
use std::path::Path;
use std::time::Instant;
use crate::models::{Finding, FindingsSummary, Grade, HealthReport, Severity};
use console::style;
use serde_json::json;
use super::diff_hunks::{Attribution, DiffHunks};
fn findings_match(a: &Finding, b: &Finding) -> bool {
a.detector == b.detector
&& a.affected_files.first() == b.affected_files.first()
&& match (a.line_start, b.line_start) {
(Some(la), Some(lb)) => la.abs_diff(lb) <= 3,
(None, None) => true,
_ => false,
}
}
#[derive(Debug)]
struct DiffResult {
base_ref: String,
head_ref: String,
new_findings: Vec<Finding>,
fixed_findings: Vec<Finding>,
score_before: Option<f64>,
score_after: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct AttributedFinding {
pub finding: Finding,
pub attribution: Attribution,
}
#[derive(Debug)]
pub struct SmartDiffResult {
pub base_ref: String,
pub head_ref: String,
pub files_changed: usize,
pub new_findings: Vec<AttributedFinding>,
pub all_new_count: usize,
pub fixed_findings: Vec<Finding>,
pub score_before: Option<f64>,
pub score_after: Option<f64>,
}
impl SmartDiffResult {
pub fn score_delta(&self) -> Option<f64> {
match (self.score_before, self.score_after) {
(Some(before), Some(after)) => Some(after - before),
_ => None,
}
}
pub fn findings_only(&self) -> Vec<Finding> {
self.new_findings
.iter()
.map(|af| af.finding.clone())
.collect()
}
pub fn hunk_findings(&self) -> Vec<Finding> {
self.new_findings
.iter()
.filter(|af| af.attribution == Attribution::InChangedHunk)
.map(|af| af.finding.clone())
.collect()
}
}
fn diff_findings(
baseline: &[Finding],
head: &[Finding],
base_ref: &str,
head_ref: &str,
score_before: Option<f64>,
score_after: Option<f64>,
) -> DiffResult {
let new_findings: Vec<Finding> = head
.iter()
.filter(|h| !baseline.iter().any(|b| findings_match(b, h)))
.cloned()
.collect();
let fixed_findings: Vec<Finding> = baseline
.iter()
.filter(|b| !head.iter().any(|h| findings_match(b, h)))
.cloned()
.collect();
DiffResult {
base_ref: base_ref.to_string(),
head_ref: head_ref.to_string(),
new_findings,
fixed_findings,
score_before,
score_after,
}
}
fn severity_icon(severity: &Severity) -> &'static str {
match severity {
Severity::Critical => "[C]",
Severity::High => "[H]",
Severity::Medium => "[M]",
Severity::Low => "[L]",
Severity::Info => "[I]",
}
}
fn format_finding_line(out: &mut String, finding: &Finding, _no_emoji: bool) {
let file = finding
.affected_files
.first()
.map(|p| p.display().to_string())
.unwrap_or_default();
let line = finding
.line_start
.map(|l| format!(":{l}"))
.unwrap_or_default();
out.push_str(&format!(
" {} {:<40} {}{}\n",
severity_icon(&finding.severity),
&finding.title.chars().take(40).collect::<String>(),
file,
line
));
}
pub fn format_text(result: &SmartDiffResult, no_emoji: bool) -> String {
let mut out = String::new();
out.push_str(&format!(
"Repotoire Diff: {}..{} ({} files changed)\n\n",
result.base_ref, result.head_ref, result.files_changed,
));
let hunk_findings: Vec<_> = result
.new_findings
.iter()
.filter(|af| af.attribution == Attribution::InChangedHunk)
.collect();
let file_findings: Vec<_> = result
.new_findings
.iter()
.filter(|af| af.attribution == Attribution::InChangedFile)
.collect();
let unrelated_findings: Vec<_> = result
.new_findings
.iter()
.filter(|af| af.attribution == Attribution::InUnchangedFile)
.collect();
if result.new_findings.is_empty() && result.all_new_count > 0 {
let check = if no_emoji { "[ok]" } else { "\u{2705}" };
out.push_str(&format!(
" {} {}\n",
check,
style("No new findings in your changes").green()
));
let info = if no_emoji { "i" } else { "\u{2139}\u{fe0f}" };
out.push_str(&format!(
" {} {} finding{} in other files (use --all to see)\n\n",
style(info).dim(),
result.all_new_count,
if result.all_new_count == 1 { "" } else { "s" }
));
} else if result.new_findings.is_empty() {
let check = if no_emoji { "[ok]" } else { "\u{2705}" };
out.push_str(&format!(
" {} {}\n\n",
check,
style("No new findings").green()
));
} else {
if !hunk_findings.is_empty() {
out.push_str(&format!(
"{}\n",
style(format!(
"YOUR CHANGES ({} finding{})",
hunk_findings.len(),
if hunk_findings.len() == 1 { "" } else { "s" }
))
.bold()
));
for af in &hunk_findings {
format_finding_line(&mut out, &af.finding, no_emoji);
}
out.push('\n');
}
if !file_findings.is_empty() {
out.push_str(&format!(
"{}\n",
style(format!(
"PRE-EXISTING ({} in changed files)",
file_findings.len()
))
.dim()
));
for af in &file_findings {
format_finding_line(&mut out, &af.finding, no_emoji);
}
out.push('\n');
}
if !unrelated_findings.is_empty() {
out.push_str(&format!(
"{}\n",
style(format!(
"UNRELATED ({} in unchanged files)",
unrelated_findings.len()
))
.dim()
));
for af in &unrelated_findings {
format_finding_line(&mut out, &af.finding, no_emoji);
}
out.push('\n');
}
}
if let (Some(before), Some(after)) = (result.score_before, result.score_after) {
let delta = after - before;
let delta_str = if delta >= 0.0 {
style(format!("+{:.1}", delta)).green().to_string()
} else {
style(format!("{:.1}", delta)).red().to_string()
};
out.push_str(&format!(
"Score: {:.1} \u{2192} {:.1} ({})\n",
before, after, delta_str,
));
}
if !result.fixed_findings.is_empty() {
let prefix = if no_emoji { "" } else { "\u{2728} " };
out.push_str(&format!(
"{}{} finding{} fixed\n",
prefix,
result.fixed_findings.len(),
if result.fixed_findings.len() == 1 {
""
} else {
"s"
}
));
}
out
}
pub fn format_markdown(result: &SmartDiffResult) -> String {
let mut out = String::new();
out.push_str(&format!(
"# Repotoire Diff: {}..{} ({} files changed)\n\n",
result.base_ref, result.head_ref, result.files_changed,
));
let hunk_findings: Vec<_> = result
.new_findings
.iter()
.filter(|af| af.attribution == Attribution::InChangedHunk)
.collect();
let file_findings: Vec<_> = result
.new_findings
.iter()
.filter(|af| af.attribution == Attribution::InChangedFile)
.collect();
let unrelated_findings: Vec<_> = result
.new_findings
.iter()
.filter(|af| af.attribution == Attribution::InUnchangedFile)
.collect();
if result.new_findings.is_empty() && result.all_new_count > 0 {
out.push_str("**No new findings in your changes.**\n\n");
out.push_str(&format!(
"> {} finding{} in other files (use `--all` to see)\n\n",
result.all_new_count,
if result.all_new_count == 1 { "" } else { "s" }
));
} else if result.new_findings.is_empty() {
out.push_str("**No new findings.**\n\n");
} else {
if !hunk_findings.is_empty() {
out.push_str(&format!(
"## Your Changes ({} finding{})\n\n",
hunk_findings.len(),
if hunk_findings.len() == 1 { "" } else { "s" }
));
out.push_str("| Severity | Finding | Location |\n");
out.push_str("|----------|---------|----------|\n");
for af in &hunk_findings {
format_markdown_row(&mut out, &af.finding);
}
out.push('\n');
}
if !file_findings.is_empty() {
out.push_str(&format!(
"## Pre-existing ({} in changed files)\n\n",
file_findings.len()
));
out.push_str("| Severity | Finding | Location |\n");
out.push_str("|----------|---------|----------|\n");
for af in &file_findings {
format_markdown_row(&mut out, &af.finding);
}
out.push('\n');
}
if !unrelated_findings.is_empty() {
out.push_str(&format!(
"## Unrelated ({} in unchanged files)\n\n",
unrelated_findings.len()
));
out.push_str("| Severity | Finding | Location |\n");
out.push_str("|----------|---------|----------|\n");
for af in &unrelated_findings {
format_markdown_row(&mut out, &af.finding);
}
out.push('\n');
}
}
if let (Some(before), Some(after)) = (result.score_before, result.score_after) {
let delta = after - before;
let sign = if delta >= 0.0 { "+" } else { "" };
out.push_str(&format!(
"**Score:** {:.1} \u{2192} {:.1} ({}{:.1})\n\n",
before, after, sign, delta,
));
}
if !result.fixed_findings.is_empty() {
out.push_str(&format!(
"{} finding{} fixed\n",
result.fixed_findings.len(),
if result.fixed_findings.len() == 1 {
""
} else {
"s"
}
));
}
out
}
fn format_markdown_row(out: &mut String, finding: &Finding) {
let file = finding
.affected_files
.first()
.map(|p| p.display().to_string())
.unwrap_or_default();
let line = finding
.line_start
.map(|l| format!(":{l}"))
.unwrap_or_default();
let title: String = finding
.title
.chars()
.take(60)
.collect::<String>()
.replace('|', "\\|");
out.push_str(&format!(
"| {} | {} | `{}{}` |\n",
finding.severity, title, file, line
));
}
pub fn format_json(result: &SmartDiffResult) -> String {
let all_findings = result.findings_only();
let new_summary = FindingsSummary::from_findings(&all_findings);
let fixed_summary = FindingsSummary::from_findings(&result.fixed_findings);
let score_delta = result.score_delta();
let new_findings_json: Vec<serde_json::Value> = result
.new_findings
.iter()
.map(|af| {
json!({
"detector": af.finding.detector,
"severity": af.finding.severity.to_string(),
"title": af.finding.title,
"description": af.finding.description,
"file": af.finding.affected_files.first().map(|p| p.display().to_string()).unwrap_or_default(),
"line": af.finding.line_start,
"attribution": match af.attribution {
Attribution::InChangedHunk => "in_changed_hunk",
Attribution::InChangedFile => "in_changed_file",
Attribution::InUnchangedFile => "in_unchanged_file",
},
})
})
.collect();
let fixed_findings_json: Vec<serde_json::Value> = result
.fixed_findings
.iter()
.map(|f| {
json!({
"detector": f.detector,
"severity": f.severity.to_string(),
"title": f.title,
"file": f.affected_files.first().map(|p| p.display().to_string()).unwrap_or_default(),
"line": f.line_start,
})
})
.collect();
let output = json!({
"base_ref": result.base_ref,
"head_ref": result.head_ref,
"files_changed": result.files_changed,
"total_new_findings": result.all_new_count,
"new_findings": new_findings_json,
"fixed_findings": fixed_findings_json,
"score_before": result.score_before,
"score_after": result.score_after,
"score_delta": score_delta,
"summary": {
"new": {
"critical": new_summary.critical,
"high": new_summary.high,
"medium": new_summary.medium,
"low": new_summary.low,
},
"fixed": {
"critical": fixed_summary.critical,
"high": fixed_summary.high,
"medium": fixed_summary.medium,
"low": fixed_summary.low,
},
},
});
serde_json::to_string_pretty(&output).expect("JSON serialization should not fail")
}
pub fn format_sarif(result: &SmartDiffResult) -> anyhow::Result<String> {
let hunk_findings = result.hunk_findings();
let report = HealthReport {
overall_score: result.score_after.unwrap_or(0.0),
grade: Grade::default(),
structure_score: 0.0,
quality_score: 0.0,
architecture_score: None,
findings_summary: FindingsSummary::from_findings(&hunk_findings),
findings: hunk_findings,
total_files: 0,
total_functions: 0,
total_classes: 0,
total_loc: 0,
};
crate::reporters::report_with_format(&report, crate::reporters::OutputFormat::Sarif)
}
fn run_inline_analysis(repo_path: &Path, repotoire_dir: &Path) -> Result<()> {
let session_dir = repotoire_dir.join("session");
let mut engine = match crate::engine::AnalysisEngine::load(&session_dir, repo_path, false) {
Ok(e) => e,
Err(_) => crate::engine::AnalysisEngine::new(repo_path, false)?,
};
let config = crate::engine::AnalysisConfig::default();
let result = engine.analyze(&config)?;
let findings_summary = FindingsSummary::from_findings(&result.findings);
let report = crate::models::HealthReport {
overall_score: result.score.overall,
grade: result.score.grade,
structure_score: result.score.breakdown.structure.final_score,
quality_score: result.score.breakdown.quality.final_score,
architecture_score: Some(result.score.breakdown.architecture.final_score),
findings: result.findings.clone(),
findings_summary,
total_files: result.stats.files_analyzed,
total_functions: result.stats.total_functions,
total_classes: result.stats.total_classes,
total_loc: result.stats.total_loc,
};
super::analyze::output::cache_results(repotoire_dir, &report, &result.findings)?;
if let Err(e) = engine.save(&session_dir) {
tracing::debug!("Failed to save session after inline analysis: {e}");
}
Ok(())
}
fn load_baseline_and_head(
repotoire_dir: &Path,
_repo_path: &Path,
_base_ref: Option<&str>,
) -> Result<(Vec<Finding>, Vec<Finding>, Option<f64>, Option<f64>)> {
let baseline_path = repotoire_dir.join("baseline_findings.json");
let baseline = if baseline_path.exists() {
use super::analyze::output::CacheLoadOutcome;
let outcome = super::analyze::output::load_cached_findings_outcome_from(&baseline_path);
match outcome {
CacheLoadOutcome::Findings(v) => v,
outcome @ CacheLoadOutcome::Corrupt { .. } => {
if let Some(msg) = outcome.user_warning() {
println!("{}", msg);
}
println!(
" Treating as first-run (no baseline); all findings will appear as new. \
Delete the corrupt file or run `repotoire analyze` twice to rebuild it."
);
Vec::new()
}
CacheLoadOutcome::Missing | CacheLoadOutcome::VersionMismatch { .. } => Vec::new(),
}
} else {
Vec::new()
};
let score_before = load_score_from(&if baseline_path.exists() {
repotoire_dir.join("baseline_health.json")
} else {
repotoire_dir.join("last_health.json")
});
let head = {
use super::analyze::output::CacheLoadOutcome;
let last_findings = repotoire_dir.join("last_findings.json");
let outcome = super::analyze::output::load_cached_findings_outcome_from(&last_findings);
match outcome {
CacheLoadOutcome::Findings(v) => v,
outcome @ CacheLoadOutcome::Corrupt { .. } => {
if let Some(msg) = outcome.user_warning() {
println!("{}", msg);
}
anyhow::bail!(
"current findings cache is corrupt; re-run `repotoire analyze` to regenerate it"
);
}
CacheLoadOutcome::Missing | CacheLoadOutcome::VersionMismatch { .. } => {
anyhow::bail!(
"No current analysis found. Run 'repotoire analyze' to generate findings."
);
}
}
};
let score_after = load_cached_score(repotoire_dir);
Ok((baseline, head, score_before, score_after))
}
fn send_diff_telemetry(
telemetry: &crate::telemetry::Telemetry,
repo_path: &Path,
result: &SmartDiffResult,
) {
if let crate::telemetry::Telemetry::Active(ref state) = *telemetry {
if let Some(distinct_id) = &state.distinct_id {
let repo_id = crate::telemetry::config::compute_repo_id(repo_path);
let event = crate::telemetry::events::DiffRun {
repo_id,
score_before: result.score_before.unwrap_or(0.0),
score_after: result.score_after.unwrap_or(0.0),
score_delta: result.score_delta().unwrap_or(0.0),
findings_added: result.all_new_count as u64,
findings_removed: result.fixed_findings.len() as u64,
version: env!("CARGO_PKG_VERSION").to_string(),
..Default::default()
};
let props = serde_json::to_value(&event).unwrap_or_default();
crate::telemetry::posthog::capture_queued("diff_run", distinct_id, props);
}
}
}
fn emit_output(
result: &SmartDiffResult,
format: crate::reporters::OutputFormat,
no_emoji: bool,
output: Option<&Path>,
start: Instant,
) -> Result<()> {
use crate::reporters::OutputFormat;
let output_str = match format {
OutputFormat::Json => format_json(result),
OutputFormat::Sarif => format_sarif(result)?,
OutputFormat::Markdown => format_markdown(result),
_ => format_text(result, no_emoji),
};
if let Some(out_path) = output {
std::fs::write(out_path, &output_str)?;
eprintln!("Report written to: {}", out_path.display());
} else {
println!("{}", output_str);
}
if !matches!(
format,
OutputFormat::Json | OutputFormat::Sarif | OutputFormat::Markdown
) {
let elapsed = start.elapsed();
let prefix = if no_emoji { "" } else { "✨ " };
eprintln!(
"{}Diff complete in {:.2}s ({} new, {} fixed)",
prefix,
elapsed.as_secs_f64(),
result.new_findings.len(),
result.fixed_findings.len()
);
}
Ok(())
}
fn check_fail_threshold(fail_on: Option<Severity>, result: &SmartDiffResult) -> Result<()> {
if let Some(threshold) = fail_on {
let hunk_findings = result.hunk_findings();
let new_summary = FindingsSummary::from_findings(&hunk_findings);
let should_fail = match threshold {
Severity::Critical => new_summary.critical > 0,
Severity::High => new_summary.critical > 0 || new_summary.high > 0,
Severity::Medium => {
new_summary.critical > 0 || new_summary.high > 0 || new_summary.medium > 0
}
Severity::Low | Severity::Info => {
new_summary.critical > 0
|| new_summary.high > 0
|| new_summary.medium > 0
|| new_summary.low > 0
}
};
if should_fail {
anyhow::bail!(
"Failing due to --fail-on={}: {} new finding(s) in changed hunks",
threshold,
hunk_findings.len()
);
}
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct SmartDiffOptions {
pub allow_inline_analysis: bool,
pub emit_telemetry: bool,
}
impl Default for SmartDiffOptions {
fn default() -> Self {
Self {
allow_inline_analysis: true,
emit_telemetry: true,
}
}
}
pub(crate) fn compute_smart_diff(
repo_path: &Path,
base_ref: Option<&str>,
all: bool,
changed: bool,
working_tree: bool,
options: SmartDiffOptions,
telemetry: &crate::telemetry::Telemetry,
) -> Result<Option<SmartDiffResult>> {
let repo_path = repo_path
.canonicalize()
.context("Cannot resolve repository path")?;
if !repo_path.join(".git").exists() {
anyhow::bail!(
"diff requires a git repository (no .git found in {})",
repo_path.display()
);
}
let repotoire_dir = crate::cache::paths::cache_dir(&repo_path);
let load_result = load_baseline_and_head(&repotoire_dir, &repo_path, base_ref);
let (baseline, head, score_before, score_after) = match load_result {
Ok(t) => t,
Err(e) if e.to_string().contains("findings cache is corrupt") => return Err(e),
Err(e) => {
tracing::debug!("load_baseline_and_head failed: {e}");
if !options.allow_inline_analysis {
return Ok(None);
}
let repotoire_dir = crate::cache::ensure_cache_dir(&repo_path)
.context("Failed to create cache directory")?;
eprintln!("No cached analysis found, running analysis...");
run_inline_analysis(&repo_path, &repotoire_dir)?;
load_baseline_and_head(&repotoire_dir, &repo_path, base_ref)
.context("Analysis completed but could not load findings")?
}
};
let base_label = base_ref.unwrap_or(if working_tree { "HEAD" } else { "cached" });
let head_label = if working_tree { "working-tree" } else { "HEAD" };
let raw_diff = diff_findings(
&baseline,
&head,
base_label,
head_label,
score_before,
score_after,
);
let effective_base = base_ref.unwrap_or(if working_tree { "HEAD" } else { "HEAD~1" });
let hunks = if working_tree {
DiffHunks::from_git_diff_worktree(&repo_path, effective_base)
} else {
DiffHunks::from_git_diff(&repo_path, effective_base)
}
.unwrap_or_else(|e| {
tracing::debug!("git diff -U0 failed: {e}, attributing all as InUnchangedFile");
DiffHunks::parse_diff("")
});
let all_attributed: Vec<AttributedFinding> = raw_diff
.new_findings
.into_iter()
.map(|f| {
let attr = f
.affected_files
.first()
.map(|path| hunks.attribute(path, f.line_start))
.unwrap_or(Attribution::InUnchangedFile);
AttributedFinding {
finding: f,
attribution: attr,
}
})
.collect();
let all_new_count = all_attributed.len();
let filtered: Vec<AttributedFinding> = if all {
all_attributed
} else if changed {
all_attributed
.into_iter()
.filter(|af| af.attribution != Attribution::InUnchangedFile)
.collect()
} else {
all_attributed
.into_iter()
.filter(|af| af.attribution == Attribution::InChangedHunk)
.collect()
};
let result = SmartDiffResult {
base_ref: raw_diff.base_ref,
head_ref: raw_diff.head_ref,
files_changed: hunks.changed_file_count(),
new_findings: filtered,
all_new_count,
fixed_findings: raw_diff.fixed_findings,
score_before: raw_diff.score_before,
score_after: raw_diff.score_after,
};
if options.emit_telemetry {
send_diff_telemetry(telemetry, &repo_path, &result);
}
Ok(Some(result))
}
pub struct RunArgs<'a> {
pub repo_path: &'a Path,
pub base_ref: Option<String>,
pub format: crate::reporters::OutputFormat,
pub fail_on: Option<crate::models::Severity>,
pub no_emoji: bool,
pub output: Option<&'a Path>,
pub all: bool,
pub changed: bool,
pub working_tree: bool,
pub telemetry: &'a crate::telemetry::Telemetry,
}
pub fn run(args: RunArgs<'_>) -> Result<()> {
let RunArgs {
repo_path,
base_ref,
format,
fail_on,
no_emoji,
output,
all,
changed,
working_tree,
telemetry,
} = args;
let start = Instant::now();
let result = compute_smart_diff(
repo_path,
base_ref.as_deref(),
all,
changed,
working_tree,
SmartDiffOptions::default(),
telemetry,
)?;
let result =
result.expect("compute_smart_diff returns Some when allow_inline_analysis is true");
emit_output(&result, format, no_emoji, output, start)?;
check_fail_threshold(fail_on, &result)?;
Ok(())
}
fn load_cached_score(repotoire_dir: &Path) -> Option<f64> {
load_score_from(&repotoire_dir.join("last_health.json"))
}
fn load_score_from(path: &Path) -> Option<f64> {
let data = std::fs::read_to_string(path).ok()?;
let json_val: serde_json::Value = serde_json::from_str(&data).ok()?;
json_val.get("health_score").and_then(|v| v.as_f64())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Severity;
use std::path::PathBuf;
fn make_finding(detector: &str, file: &str, line: Option<u32>) -> Finding {
Finding {
detector: detector.to_string(),
affected_files: vec![PathBuf::from(file)],
line_start: line,
severity: Severity::Medium,
title: "test".to_string(),
..Default::default()
}
}
#[test]
fn test_exact_match() {
let a = make_finding("dead_code", "src/foo.rs", Some(10));
let b = make_finding("dead_code", "src/foo.rs", Some(10));
assert!(findings_match(&a, &b));
}
#[test]
fn test_fuzzy_line_match_within_tolerance() {
let a = make_finding("dead_code", "src/foo.rs", Some(10));
let b = make_finding("dead_code", "src/foo.rs", Some(13)); assert!(findings_match(&a, &b));
let c = make_finding("dead_code", "src/foo.rs", Some(7)); assert!(findings_match(&a, &c));
}
#[test]
fn test_fuzzy_line_beyond_tolerance() {
let a = make_finding("dead_code", "src/foo.rs", Some(10));
let b = make_finding("dead_code", "src/foo.rs", Some(14)); assert!(!findings_match(&a, &b));
}
#[test]
fn test_different_detector_no_match() {
let a = make_finding("dead_code", "src/foo.rs", Some(10));
let b = make_finding("magic_number", "src/foo.rs", Some(10));
assert!(!findings_match(&a, &b));
}
#[test]
fn test_different_file_no_match() {
let a = make_finding("dead_code", "src/foo.rs", Some(10));
let b = make_finding("dead_code", "src/bar.rs", Some(10));
assert!(!findings_match(&a, &b));
}
#[test]
fn test_file_level_findings_match() {
let a = make_finding("circular_dependency", "src/foo.rs", None);
let b = make_finding("circular_dependency", "src/foo.rs", None);
assert!(findings_match(&a, &b));
}
#[test]
fn test_line_vs_no_line_no_match() {
let a = make_finding("dead_code", "src/foo.rs", Some(10));
let b = make_finding("dead_code", "src/foo.rs", None);
assert!(!findings_match(&a, &b));
}
#[test]
fn test_diff_new_and_fixed() {
let baseline = vec![
make_finding("dead_code", "src/foo.rs", Some(10)),
make_finding("magic_number", "src/bar.rs", Some(20)),
];
let head = vec![
make_finding("dead_code", "src/foo.rs", Some(11)), make_finding("xss", "src/web.rs", Some(5)), ];
let result = diff_findings(&baseline, &head, "main", "HEAD", Some(96.0), Some(95.5));
assert_eq!(result.new_findings.len(), 1);
assert_eq!(result.new_findings[0].detector, "xss");
assert_eq!(result.fixed_findings.len(), 1);
assert_eq!(result.fixed_findings[0].detector, "magic_number");
let delta = result.score_after.unwrap() - result.score_before.unwrap();
assert!((delta - (-0.5)).abs() < f64::EPSILON);
}
#[test]
fn test_diff_no_changes() {
let findings = vec![make_finding("dead_code", "src/foo.rs", Some(10))];
let result = diff_findings(&findings, &findings, "main", "HEAD", None, None);
assert!(result.new_findings.is_empty());
assert!(result.fixed_findings.is_empty());
assert!(result.score_before.is_none());
assert!(result.score_after.is_none());
}
#[test]
fn test_format_json_structure() {
let result = SmartDiffResult {
base_ref: "main".to_string(),
head_ref: "HEAD".to_string(),
files_changed: 2,
new_findings: vec![AttributedFinding {
finding: make_finding("xss", "src/web.rs", Some(5)),
attribution: Attribution::InChangedHunk,
}],
all_new_count: 1,
fixed_findings: vec![make_finding("dead_code", "src/old.rs", Some(10))],
score_before: Some(96.0),
score_after: Some(95.5),
};
let json_str = format_json(&result);
let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
assert_eq!(parsed["base_ref"], "main");
assert_eq!(parsed["head_ref"], "HEAD");
assert_eq!(parsed["files_changed"], 2);
assert_eq!(parsed["new_findings"].as_array().unwrap().len(), 1);
assert_eq!(parsed["new_findings"][0]["attribution"], "in_changed_hunk");
assert_eq!(parsed["fixed_findings"].as_array().unwrap().len(), 1);
assert_eq!(parsed["score_delta"], -0.5);
}
#[test]
fn test_format_text_no_new_findings() {
let result = SmartDiffResult {
base_ref: "main".to_string(),
head_ref: "HEAD".to_string(),
files_changed: 1,
new_findings: vec![],
all_new_count: 0,
fixed_findings: vec![],
score_before: Some(97.0),
score_after: Some(97.0),
};
let text = format_text(&result, true);
assert!(text.contains("No new findings"));
assert!(!text.contains("--all"));
}
#[test]
fn test_format_text_filtered_hint() {
let result = SmartDiffResult {
base_ref: "main".to_string(),
head_ref: "HEAD".to_string(),
files_changed: 1,
new_findings: vec![],
all_new_count: 5,
fixed_findings: vec![],
score_before: Some(90.0),
score_after: Some(88.0),
};
let text = format_text(&result, true);
assert!(text.contains("No new findings in your changes"));
assert!(text.contains("5 findings in other files"));
assert!(text.contains("--all"));
}
#[test]
fn test_format_json_total_new_findings() {
let result = SmartDiffResult {
base_ref: "main".to_string(),
head_ref: "HEAD".to_string(),
files_changed: 1,
new_findings: vec![],
all_new_count: 3,
fixed_findings: vec![],
score_before: Some(90.0),
score_after: Some(90.0),
};
let json_str = format_json(&result);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["total_new_findings"], 3);
}
#[test]
fn test_format_markdown_structure() {
let result = SmartDiffResult {
base_ref: "main".to_string(),
head_ref: "HEAD".to_string(),
files_changed: 1,
new_findings: vec![AttributedFinding {
finding: make_finding("xss", "src/web.rs", Some(5)),
attribution: Attribution::InChangedHunk,
}],
all_new_count: 1,
fixed_findings: vec![],
score_before: Some(90.0),
score_after: Some(88.0),
};
let md = format_markdown(&result);
assert!(md.starts_with("# Repotoire Diff:"));
assert!(md.contains("## Your Changes"));
assert!(md.contains("| Severity |"));
assert!(md.contains("`src/web.rs:5`"));
assert!(!md.contains("\x1b[")); }
#[test]
fn test_format_markdown_no_findings() {
let result = SmartDiffResult {
base_ref: "main".to_string(),
head_ref: "HEAD".to_string(),
files_changed: 1,
new_findings: vec![],
all_new_count: 0,
fixed_findings: vec![],
score_before: Some(90.0),
score_after: Some(90.0),
};
let md = format_markdown(&result);
assert!(md.contains("**No new findings.**"));
}
#[test]
fn test_format_markdown_filtered_hint() {
let result = SmartDiffResult {
base_ref: "main".to_string(),
head_ref: "HEAD".to_string(),
files_changed: 2,
new_findings: vec![],
all_new_count: 4,
fixed_findings: vec![],
score_before: Some(90.0),
score_after: Some(88.0),
};
let md = format_markdown(&result);
assert!(md.contains("**No new findings in your changes.**"));
assert!(md.contains("4 finding"));
assert!(md.contains("`--all`"));
}
}