use crate::core::{AuditResult, Finding, ProjectConfig};
use crate::reports;
use super::ReportArgs;
use anyhow::{Context, Result};
use colored::*;
pub fn run(args: &ReportArgs) -> Result<()> {
let config = ProjectConfig::from_default_location();
if args.history {
return history_report(&config);
}
if args.regression {
let result = load_current_result(args, &config)?;
return regression_report(&config, &result);
}
let result = load_current_result(args, &config)?;
let output_dir = std::path::PathBuf::from(&config.report.output_dir);
std::fs::create_dir_all(&output_dir)?;
match args.format.to_lowercase().as_str() {
"json" => {
let output_path = args
.output
.clone()
.unwrap_or_else(|| output_dir.join("audit.json"));
reports::json::write_report(&result, &output_path)?;
println!(
"{} JSON report written to {}",
"✅".green(),
output_path.display()
);
}
"markdown" | "md" => {
let output_path = args
.output
.clone()
.unwrap_or_else(|| output_dir.join("audit.md"));
let md = reports::markdown::generate_report(&result)?;
std::fs::write(&output_path, md)?;
println!(
"{} Markdown report written to {}",
"✅".green(),
output_path.display()
);
}
"html" => {
let output_path = args
.output
.clone()
.unwrap_or_else(|| output_dir.join("audit.html"));
let html = reports::html::generate_report(&result)?;
std::fs::write(&output_path, html)?;
println!(
"{} HTML report written to {}",
"✅".green(),
output_path.display()
);
}
other => {
anyhow::bail!(
"Unsupported format: {}. Use 'json', 'markdown', or 'html'.",
other
);
}
}
if args.shared.json {
println!("{}", serde_json::to_string_pretty(&result)?);
}
Ok(())
}
fn load_current_result(args: &ReportArgs, config: &ProjectConfig) -> Result<AuditResult> {
if let Some(input_path) = &args.input {
let content = std::fs::read_to_string(input_path).context("Failed to read input file")?;
serde_json::from_str::<AuditResult>(&content).context("Failed to parse audit result JSON")
} else {
let cache_dir = config.project_root.join(".forge-guard-cache");
let cache_file = cache_dir.join("last_audit.json");
if cache_file.exists() {
let content =
std::fs::read_to_string(&cache_file).context("Failed to read cached audit")?;
serde_json::from_str::<AuditResult>(&content)
.context("Failed to parse cached audit result")
} else {
anyhow::bail!(
"No audit result found. Run `forge-guard audit` first or specify --input"
);
}
}
}
fn history_report(config: &ProjectConfig) -> Result<()> {
let store = crate::history::HistoryStore::open(config)?;
let project = config.project_root.to_string_lossy().to_string();
let entries = store.trends(&project, 25)?;
if entries.is_empty() {
eprintln!(
"📭 No recorded history for project '{project}'. Run `forge-guard audit --enable-history` to start tracking trends."
);
return Ok(());
}
println!(
"\n{}",
"════════ FORGE GUARD — SCORE TREND HISTORY ════════"
.bright_blue()
.bold()
);
println!("📋 Project: {project}");
println!(
"🗄️ Database: {}\n",
crate::history::db_path(config).display()
);
println!(
" {:<19} {:<12} {:<9} {:<9} Findings",
"Date", "Chain", "Score", "Risk"
);
println!(" {}", "─".repeat(74));
for entry in &entries {
println!("{}", crate::history::format_trend_row(entry));
}
let chronological: Vec<u8> = entries.iter().rev().map(|e| e.overall_score).collect();
if chronological.len() >= 2 {
let first = chronological[0];
let last = *chronological.last().unwrap();
println!(
"\n📈 Score trend: {}",
chronological
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(" → ")
);
let delta = i16::from(last) - i16::from(first);
if delta > 0 {
println!(
"🟢 Security posture improved by +{delta} points since the first recorded audit"
);
} else if delta < 0 {
println!(
"🔴 Security posture declined by {delta} points since the first recorded audit"
);
} else {
println!("⚪ Score unchanged since the first recorded audit");
}
}
println!(
"\n{}",
"═══════════════════════════════════════════════════════".bright_blue()
);
Ok(())
}
fn regression_report(config: &ProjectConfig, result: &AuditResult) -> Result<()> {
use crate::history::{db_path, finding_signature, regression, HistoryStore};
let store = HistoryStore::open(config)?;
let project = result.project_name.clone();
let chain = result.chain.clone();
let Some(previous) = store.latest(&project, &chain)? else {
eprintln!(
"📭 No previous audit recorded for '{project}' (chain: {chain}). Run `forge-guard audit --enable-history` first."
);
return Ok(());
};
let diff = regression(&result.findings, &previous.finding_signatures);
let new_findings: Vec<&Finding> = result
.findings
.iter()
.filter(|f| diff.new_signatures.contains(&finding_signature(f)))
.collect();
println!(
"\n{}",
"════════ FORGE GUARD — REGRESSION REPORT ════════"
.bright_yellow()
.bold()
);
println!("📋 Project: {project}");
println!("⛓️ Chain: {chain}");
println!("🗄️ Database: {}", db_path(config).display());
println!(
"🕐 Previous: {} (score {}/100, {} finding{})",
crate::history::format_timestamp(&previous.timestamp),
previous.overall_score,
previous.total_findings,
if previous.total_findings == 1 {
""
} else {
"s"
}
);
println!(
"📊 Current: {} (score {}/100, {} finding{})",
crate::history::format_timestamp(&result.timestamp),
result.overall_score,
result.summary.total_findings,
if result.summary.total_findings == 1 {
""
} else {
"s"
}
);
println!("\n{}", "── New Findings Since Last Audit ──".bold());
if new_findings.is_empty() {
println!(" ✅ No new findings — posture stable or improved");
} else {
for f in &new_findings {
let location = match (&f.file, f.line) {
(Some(file), Some(line)) => format!("{file}:{line}"),
(Some(file), None) => file.clone(),
(None, _) => "?".into(),
};
println!(
" [{}] {} — {}\n 📄 {}",
f.severity.to_string().red().bold(),
f.title.bold(),
f.recommendation.dimmed(),
location
);
}
}
if !diff.resolved_signatures.is_empty() {
println!("\n{}", "── Resolved Since Last Audit ──".bold().green());
for sig in &diff.resolved_signatures {
let parts: Vec<&str> = sig.splitn(3, '|').collect();
let title = parts.get(2).copied().unwrap_or(sig);
let file = parts.get(1).copied().unwrap_or("?");
println!(" 🟢 {title} ({file})");
}
}
println!(
"\n{}",
"═══════════════════════════════════════════════════════".bright_yellow()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_report_arg_defaults() {
let args = ReportArgs {
shared: super::super::SharedFlags {
chain: "ethereum".into(),
project: std::path::PathBuf::from("."),
json: false,
markdown: false,
html: false,
strict: false,
offline: false,
production: false,
report: false,
parallelism: 4,
},
input: None,
format: "markdown".into(),
output: None,
exploit_paths: false,
summary: false,
history: false,
regression: false,
};
assert!(args.input.is_none());
assert_eq!(args.format, "markdown");
assert!(args.output.is_none());
assert!(!args.exploit_paths);
assert!(!args.summary);
assert!(!args.history);
assert!(!args.regression);
}
#[test]
fn test_report_with_json_format() {
let args = ReportArgs {
shared: super::super::SharedFlags::default(),
input: Some(std::path::PathBuf::from("audit.json")),
format: "json".into(),
output: Some(std::path::PathBuf::from("report.json")),
exploit_paths: true,
summary: false,
history: false,
regression: false,
};
assert_eq!(args.format, "json");
assert!(args.input.is_some());
assert!(args.exploit_paths);
}
#[test]
fn test_report_format_lowercasing() {
let format_upper = "MARKDOWN";
let format_lower = format_upper.to_lowercase();
assert_eq!(format_lower, "markdown");
assert!(matches!(format_lower.as_str(), "json" | "markdown" | "md"));
let format_json = "JSON";
assert_eq!(format_json.to_lowercase(), "json");
let format_md = "MD";
assert_eq!(format_md.to_lowercase(), "md");
}
#[test]
fn test_report_html_format() {
let args = ReportArgs {
shared: super::super::SharedFlags::default(),
input: None,
format: "html".into(),
output: Some(std::path::PathBuf::from("report.html")),
exploit_paths: false,
summary: false,
history: false,
regression: false,
};
assert_eq!(args.format, "html");
assert!(args.output.is_some());
}
#[test]
fn test_report_unsupported_format() {
let format = "pdf";
assert!(!matches!(format, "json" | "markdown" | "md" | "html"));
}
#[test]
fn test_report_output_dir_from_config() {
let config = ProjectConfig::default();
assert_eq!(config.report.output_dir, "reports");
}
}