repopilot 0.9.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::doctor::model::{DoctorCheck, DoctorReport, DoctorStatus};
use crate::output::OutputFormat;

pub fn render_doctor_report(
    report: &DoctorReport,
    format: OutputFormat,
) -> Result<String, serde_json::Error> {
    match format {
        OutputFormat::Console => Ok(render_console(report)),
        OutputFormat::Markdown => Ok(render_markdown(report)),
        OutputFormat::Json | OutputFormat::Html | OutputFormat::Sarif => {
            serde_json::to_string_pretty(report)
        }
    }
}

fn render_console(report: &DoctorReport) -> String {
    let mut output = String::new();

    output.push_str("RepoPilot Doctor\n\n");
    output.push_str(&format!("Root: {}\n\n", report.root_path));

    output.push_str("Project:\n");
    output.push_str(&format!(
        " Languages: {}\n",
        format_list(&report.project.languages)
    ));
    output.push_str(&format!(
        " Frameworks: {}\n",
        format_list(&report.project.frameworks)
    ));
    output.push_str(&format!(
        " Package managers: {}\n",
        format_list(&report.project.package_managers)
    ));
    output.push_str(&format!(
        " React Native: {}\n\n",
        if report.project.react_native_detected {
            "detected"
        } else {
            "not detected"
        }
    ));

    output.push_str("Audit scope:\n");
    output.push_str(&format!(
        " Files discovered: {}\n",
        report.scan.files_discovered
    ));
    output.push_str(&format!(
        " Files analyzed: {}\n",
        report.scan.files_analyzed
    ));
    output.push_str(&format!(
        " Files skipped (.repopilotignore): {}\n",
        report.scan.files_skipped_repopilotignore
    ));
    output.push_str(&format!(
        " Files skipped (limit): {}\n",
        report.scan.files_skipped_by_limit
    ));
    output.push_str(&format!(
        " Files skipped (low-signal): {}\n",
        report.scan.files_skipped_low_signal
    ));
    output.push_str(&format!(
        " Binary files skipped: {}\n",
        report.scan.binary_files_skipped
    ));
    output.push_str(&format!(
        " Large files skipped: {}\n\n",
        report.scan.large_files_skipped
    ));

    output.push_str("Checks:\n");
    for check in &report.checks {
        output.push_str(&format_check_line(check));
    }

    output.push_str("\nRecommendations:\n");
    for recommendation in &report.recommendations {
        output.push_str(&format!(" - {recommendation}\n"));
    }

    output.push_str("\nSuggested next steps:\n");
    for (index, step) in report.next_steps.iter().enumerate() {
        output.push_str(&format!(" {}. {}\n", index + 1, step.command));
        output.push_str(&format!("    {}\n", step.reason));
    }

    output.push_str("\nRecommended next command:\n");
    output.push_str(&format!(" {}\n", report.next_command));

    output
}

fn render_markdown(report: &DoctorReport) -> String {
    let mut output = String::new();

    output.push_str("# RepoPilot Doctor\n\n");
    output.push_str(&format!("- **Root:** `{}`\n", report.root_path));
    output.push_str(&format!(
        "- **Languages:** {}\n",
        markdown_list(&report.project.languages)
    ));
    output.push_str(&format!(
        "- **Frameworks:** {}\n",
        markdown_list(&report.project.frameworks)
    ));
    output.push_str(&format!(
        "- **Package managers:** {}\n",
        markdown_list(&report.project.package_managers)
    ));
    output.push_str(&format!(
        "- **React Native:** {}\n\n",
        if report.project.react_native_detected {
            "detected"
        } else {
            "not detected"
        }
    ));

    output.push_str("## Audit scope\n\n");
    output.push_str(&format!(
        "- **Files discovered:** {}\n",
        report.scan.files_discovered
    ));
    output.push_str(&format!(
        "- **Files analyzed:** {}\n",
        report.scan.files_analyzed
    ));
    output.push_str(&format!(
        "- **Skipped by `.repopilotignore`:** {}\n",
        report.scan.files_skipped_repopilotignore
    ));
    output.push_str(&format!(
        "- **Skipped by limit:** {}\n",
        report.scan.files_skipped_by_limit
    ));
    output.push_str(&format!(
        "- **Skipped low-signal files:** {}\n",
        report.scan.files_skipped_low_signal
    ));
    output.push_str(&format!(
        "- **Binary files skipped:** {}\n",
        report.scan.binary_files_skipped
    ));
    output.push_str(&format!(
        "- **Large files skipped:** {}\n\n",
        report.scan.large_files_skipped
    ));

    output.push_str("## Checks\n\n");
    for check in &report.checks {
        output.push_str(&format!(
            "- {} **{}** — {}\n  - {}\n",
            check.status.icon(),
            check.title,
            markdown_status(check.status),
            check.detail
        ));
    }

    output.push_str("\n## Recommendations\n\n");
    for recommendation in &report.recommendations {
        output.push_str(&format!("- {recommendation}\n"));
    }

    output.push_str("\n## Suggested next steps\n\n");
    for (index, step) in report.next_steps.iter().enumerate() {
        output.push_str(&format!("{}. `{}`\n", index + 1, step.command));
        output.push_str(&format!("   - {}\n", step.reason));
    }

    output.push_str("\n## Recommended next command\n\n");
    output.push_str("```bash\n");
    output.push_str(&report.next_command);
    output.push('\n');
    output.push_str("```\n");

    output
}

fn format_check_line(check: &DoctorCheck) -> String {
    format!(
        " {} {:<4} {}{}\n",
        check.status.icon(),
        check.status.label(),
        check.title,
        check.detail
    )
}

fn format_list(values: &[String]) -> String {
    if values.is_empty() {
        "none".to_string()
    } else {
        values.join(", ")
    }
}

fn markdown_list(values: &[String]) -> String {
    if values.is_empty() {
        "none".to_string()
    } else {
        values
            .iter()
            .map(|value| format!("`{value}`"))
            .collect::<Vec<_>>()
            .join(", ")
    }
}

fn markdown_status(status: DoctorStatus) -> &'static str {
    match status {
        DoctorStatus::Pass => "pass",
        DoctorStatus::Warn => "warning",
        DoctorStatus::Fail => "failed",
    }
}