repotoire 0.5.3

Graph-powered code analysis CLI. 106 detectors for security, architecture, and code quality.
Documentation
//! Output and formatting functions for the analyze command
//!
//! This module contains all output-related logic:
//! - Formatting reports (text, JSON, SARIF, etc.)
//! - Filtering and pagination
//! - Caching results
//! - Threshold checks for CI/CD

use crate::models::{Finding, FindingsSummary, HealthReport, Severity};
use crate::reporters;
use anyhow::Result;
use console::style;
use std::path::{Path, PathBuf};

/// Normalize a path to be relative
fn normalize_path(path: &Path) -> String {
    let path_str = path.display().to_string();
    if let Some(stripped) = path_str.strip_prefix("/tmp/") {
        if let Some(pos) = stripped.find('/') {
            return stripped[pos + 1..].to_string();
        }
    }
    if let Ok(home) = std::env::var("HOME") {
        if let Some(stripped) = path_str.strip_prefix(&home) {
            return stripped.trim_start_matches('/').to_string();
        }
    }
    path_str
}

/// Filter findings by severity and limit
pub(crate) fn filter_findings(
    findings: &mut Vec<Finding>,
    severity: Option<Severity>,
    top: Option<usize>,
) {
    if let Some(min) = severity {
        findings.retain(|f| f.severity >= min);
    }

    findings.sort_by(|a, b| b.severity.cmp(&a.severity));

    if let Some(n) = top {
        findings.truncate(n);
    }
}

/// Paginate findings
pub(crate) fn paginate_findings(
    mut findings: Vec<Finding>,
    page: usize,
    per_page: usize,
) -> (Vec<Finding>, Option<(usize, usize, usize, usize)>) {
    // Sort for deterministic output: severity (desc), then file, then line (#47)
    findings.sort_by(|a, b| {
        (b.severity as u8)
            .cmp(&(a.severity as u8))
            .then_with(|| {
                let a_file = a
                    .affected_files
                    .first()
                    .map(|f| f.to_string_lossy().to_string())
                    .unwrap_or_default();
                let b_file = b
                    .affected_files
                    .first()
                    .map(|f| f.to_string_lossy().to_string())
                    .unwrap_or_default();
                a_file.cmp(&b_file)
            })
            .then_with(|| a.line_start.cmp(&b.line_start))
            .then_with(|| a.detector.cmp(&b.detector))
            .then_with(|| a.title.cmp(&b.title))
    });

    let displayed_findings = findings.len();

    if per_page > 0 {
        let total_pages = displayed_findings.div_ceil(per_page);
        let page = page.max(1).min(total_pages.max(1));
        let start = (page - 1) * per_page;
        let end = (start + per_page).min(displayed_findings);
        let paginated: Vec<_> = findings[start..end].to_vec();
        (
            paginated,
            Some((page, total_pages, per_page, displayed_findings)),
        )
    } else {
        (findings, None)
    }
}

/// Format and output results
pub(crate) fn format_and_output(
    report: &HealthReport,
    all_findings: &[Finding],
    format: reporters::OutputFormat,
    output_path: Option<&Path>,
    repotoire_dir: &Path,
    pagination_info: Option<(usize, usize, usize, usize)>,
    _displayed_findings: usize,
    no_emoji: bool,
) -> Result<()> {
    use reporters::OutputFormat;

    // For file-based export formats (SARIF, HTML, Markdown), use ALL findings
    // to avoid truncating to page size. Pagination is for terminal display only.
    // Use all findings for file-based exports; JSON only when writing to file (#58)
    let use_all = matches!(format, OutputFormat::Sarif | OutputFormat::Html | OutputFormat::Markdown)
        || (format == OutputFormat::Json && output_path.is_some());
    let report_for_output = if use_all && !all_findings.is_empty() {
        let mut full_report = report.clone();
        full_report.findings = all_findings.to_vec();
        full_report.findings_summary = FindingsSummary::from_findings(all_findings);
        full_report
    } else {
        // For JSON stdout / text: ensure findings_summary matches the
        // actual findings array (which may be paginated)
        let mut r = report.clone();
        r.findings_summary = FindingsSummary::from_findings(&r.findings);
        r
    };

    let output_str = reporters::report_with_format(&report_for_output, format)?;

    // Only write to file if --output was explicitly provided (#59)
    let write_to_file = output_path.is_some();

    if write_to_file {
        let out_path = if let Some(p) = output_path {
            p.to_path_buf()
        } else {
            let ext = reporters::file_extension(format);
            repotoire_dir.join(format!("report.{}", ext))
        };

        std::fs::write(&out_path, &output_str)?;
        let file_icon = if no_emoji { "" } else { "📄 " };
        // Use stderr for machine-readable formats to keep stdout clean
        eprintln!(
            "\n{}Report written to: {}",
            style(file_icon).bold(),
            style(out_path.display()).cyan()
        );
    } else {
        // For machine-readable formats, skip leading newline to keep stdout clean
        if !matches!(format, OutputFormat::Json | OutputFormat::Sarif) {
            println!();
        }
        println!("{}", output_str);
    }

    // Cache results
    cache_results(repotoire_dir, report, all_findings)?;

    // Show pagination info (suppress for machine-readable and file-based formats)
    let quiet_mode = matches!(format, OutputFormat::Json | OutputFormat::Sarif | OutputFormat::Html | OutputFormat::Markdown)
        || output_path.is_some();
    if let Some((current_page, total_pages, per_page, total)) =
        pagination_info.filter(|_| !quiet_mode)
    {
        let page_icon = if no_emoji { "" } else { "📑 " };
        println!(
            "\n{}Showing page {} of {} ({} findings per page, {} total)",
            style(page_icon).bold(),
            style(current_page).cyan(),
            style(total_pages).cyan(),
            style(per_page).dim(),
            style(total).cyan(),
        );
        if current_page < total_pages {
            println!(
                "   Use {} to see more",
                style(format!("--page {}", current_page + 1)).yellow()
            );
        }
    }

    Ok(())
}

/// Check if fail threshold is met
pub(crate) fn check_fail_threshold(fail_on: Option<Severity>, report: &HealthReport) -> Result<()> {
    if let Some(threshold) = fail_on {
        let should_fail = match threshold {
            Severity::Critical => report.findings_summary.critical > 0,
            Severity::High => report.findings_summary.critical > 0 || report.findings_summary.high > 0,
            Severity::Medium => {
                report.findings_summary.critical > 0
                    || report.findings_summary.high > 0
                    || report.findings_summary.medium > 0
            }
            Severity::Low => {
                report.findings_summary.critical > 0
                    || report.findings_summary.high > 0
                    || report.findings_summary.medium > 0
                    || report.findings_summary.low > 0
            }
            Severity::Info => {
                report.findings_summary.critical > 0
                    || report.findings_summary.high > 0
                    || report.findings_summary.medium > 0
                    || report.findings_summary.low > 0
            }
        };
        if should_fail {
            // Return error instead of process::exit to allow cleanup (#19)
            anyhow::bail!("Failing due to --fail-on={} threshold", threshold);
        }
    }
    Ok(())
}

/// Load findings from a specific JSON file path.
/// Returns None if the file doesn't exist or can't be parsed.
pub fn load_cached_findings_from(path: &Path) -> Option<Vec<Finding>> {
    load_findings_from_file(path)
}

/// Load post-processed findings from last_findings.json cache
/// Returns None if the cache file doesn't exist or can't be parsed
pub fn load_cached_findings(repotoire_dir: &Path) -> Option<Vec<Finding>> {
    load_findings_from_file(&repotoire_dir.join("last_findings.json"))
}

/// Load findings from a specific JSON file path.
/// Shared implementation for both `load_cached_findings` and `load_cached_findings_from`.
fn load_findings_from_file(path: &Path) -> Option<Vec<Finding>> {
    let data = std::fs::read_to_string(path).ok()?;
    let json: serde_json::Value = serde_json::from_str(&data).ok()?;

    // Version check: reject stale findings from different binary version
    let cached_version = json.get("version").and_then(|v| v.as_str()).unwrap_or("");
    if cached_version != env!("CARGO_PKG_VERSION") {
        tracing::debug!(
            "Findings cache version mismatch ({} vs {}), ignoring",
            cached_version,
            env!("CARGO_PKG_VERSION")
        );
        return None;
    }

    let findings_arr = json.get("findings")?.as_array()?;

    let mut findings = Vec::new();
    for f in findings_arr {
        let severity = f.get("severity")?.as_str()?
            .parse::<Severity>()
            .unwrap_or(Severity::Info);

        let affected_files: Vec<PathBuf> = f
            .get("affected_files")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str().map(PathBuf::from))
                    .collect()
            })
            .unwrap_or_default();

        findings.push(Finding {
            id: f
                .get("id")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            detector: f
                .get("detector")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            title: f
                .get("title")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            description: f
                .get("description")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            severity,
            affected_files,
            line_start: f
                .get("line_start")
                .and_then(|v| v.as_u64())
                .map(|v| v as u32),
            line_end: f.get("line_end").and_then(|v| v.as_u64()).map(|v| v as u32),
            suggested_fix: f
                .get("suggested_fix")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
            category: f
                .get("category")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
            cwe_id: f
                .get("cwe_id")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
            why_it_matters: f
                .get("why_it_matters")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
            confidence: f.get("confidence").and_then(|v| v.as_f64()),
            ..Default::default()
        });
    }

    tracing::debug!(
        "Loaded {} post-processed findings from {}",
        findings.len(),
        path.display()
    );
    Some(findings)
}

/// Cache analysis results for other commands.
///
/// Before writing new results, snapshots the current cache as baseline
/// for `repotoire diff` to compare against.
pub fn cache_results(
    repotoire_dir: &Path,
    report: &HealthReport,
    all_findings: &[Finding],
) -> Result<()> {
    use std::fs;

    // Snapshot current cache as diff baseline (before overwriting)
    let findings_cache = repotoire_dir.join("last_findings.json");
    let health_cache = repotoire_dir.join("last_health.json");
    if findings_cache.exists() {
        let _ = fs::copy(&findings_cache, repotoire_dir.join("baseline_findings.json"));
    }
    if health_cache.exists() {
        let _ = fs::copy(&health_cache, repotoire_dir.join("baseline_health.json"));
    }

    let health_cache = repotoire_dir.join("last_health.json");
    let health_json = serde_json::json!({
        "health_score": report.overall_score,
        "structure_score": report.structure_score,
        "quality_score": report.quality_score,
        "architecture_score": report.architecture_score,
        "grade": report.grade,
        "total_files": report.total_files,
        "total_functions": report.total_functions,
        "total_classes": report.total_classes,
        "total_loc": report.total_loc,
    });
    fs::write(&health_cache, serde_json::to_string_pretty(&health_json)?)?;

    let findings_cache = repotoire_dir.join("last_findings.json");
    let findings_json = serde_json::json!({
        "version": env!("CARGO_PKG_VERSION"),
        "findings": all_findings.iter().map(|f| {
            serde_json::json!({
                "id": f.id,
                "detector": f.detector,
                "title": f.title,
                "description": f.description,
                "severity": f.severity.to_string(),
                "affected_files": f.affected_files.iter().map(|p| normalize_path(p)).collect::<Vec<_>>(),
                "line_start": f.line_start,
                "line_end": f.line_end,
                "suggested_fix": f.suggested_fix,
                "category": f.category,
                "cwe_id": f.cwe_id,
                "why_it_matters": f.why_it_matters,
                "confidence": f.confidence,
                "threshold_metadata": &f.threshold_metadata,
            })
        }).collect::<Vec<_>>()
    });
    fs::write(
        &findings_cache,
        serde_json::to_string(&findings_json)?,
    )?;

    tracing::debug!("Cached analysis results to {}", repotoire_dir.display());
    Ok(())
}