cargo-upkeep 0.1.7

Unified Rust project maintenance CLI (cargo subcommand)
use cargo_metadata::MetadataCommand;

use crate::cli::commands::deps;
use crate::core::analyzers::{
    audit::run_audit, clippy::run_clippy, unsafe_code::run_unsafe, unused::run_unused,
};
use crate::core::error::{ErrorCode, Result, UpkeepError};
use crate::core::output::{
    print_json, AuditOutput, ClippyOutput, DepsOutput, QualityOutput, UnsafeOutput, UnusedOutput,
};
use crate::core::scorers::quality::{
    score_quality, ClippySummary, DependencyFreshness, MsrvStatus, QualityInputs, SecuritySummary,
    UnsafeSummary, UnusedSummary,
};

pub async fn run(json: bool) -> Result<()> {
    let deps_future = deps::analyze(false);
    let audit_future = run_blocking("audit", run_audit);
    let clippy_future = run_clippy();
    let msrv_future = check_msrv();
    let unused_future = run_unused();
    let unsafe_future = run_unsafe();

    let (deps_result, audit_result, clippy_result, msrv_result, unused_result, unsafe_result) = tokio::join!(
        deps_future,
        audit_future,
        clippy_future,
        msrv_future,
        unused_future,
        unsafe_future
    );

    let output = build_quality_output(
        deps_result,
        audit_result,
        clippy_result,
        msrv_result,
        unused_result,
        unsafe_result,
    );

    emit_output(json, &output)
}

fn emit_output(json: bool, output: &QualityOutput) -> Result<()> {
    if json {
        print_json(output)
    } else {
        println!("{output}");
        Ok(())
    }
}

fn build_quality_output(
    deps_result: Result<DepsOutput>,
    audit_result: Result<AuditOutput>,
    clippy_result: Result<ClippyOutput>,
    msrv_result: Result<MsrvStatus>,
    unused_result: Result<UnusedOutput>,
    unsafe_result: Result<UnsafeOutput>,
) -> QualityOutput {
    let mut extra_recommendations = Vec::new();

    let dependency_freshness = match deps_result {
        Ok(deps_output) => DependencyFreshness {
            total: deps_output.total,
            outdated: deps_output.outdated,
        },
        Err(err) => {
            extra_recommendations.push(format!("Dependency freshness unavailable: {err}"));
            DependencyFreshness {
                total: 0,
                outdated: 0,
            }
        }
    };

    let security = match audit_result {
        Ok(output) => SecuritySummary {
            critical: output.summary.critical,
            high: output.summary.high,
            moderate: output.summary.moderate,
            low: output.summary.low,
        },
        Err(err) => {
            extra_recommendations.push(format!("Security scan unavailable: {err}"));
            SecuritySummary {
                critical: 0,
                high: 0,
                moderate: 0,
                low: 0,
            }
        }
    };

    let clippy = match clippy_result {
        Ok(output) => Some(ClippySummary {
            warnings: output.warnings,
            errors: output.errors,
        }),
        Err(err) => {
            extra_recommendations.push(format!("Clippy unavailable: {err}"));
            None
        }
    };

    let unused = match unused_result {
        Ok(output) => Some(UnusedSummary {
            unused_count: output.unused.len(),
        }),
        Err(err) => {
            extra_recommendations.push(format!("Unused dependencies unavailable: {err}"));
            None
        }
    };

    let unsafe_code = match unsafe_result {
        Ok(output) => Some(UnsafeSummary {
            total_unsafe: output.summary.total_unsafe,
        }),
        Err(err) => {
            extra_recommendations.push(format!("Unsafe code scan unavailable: {err}"));
            None
        }
    };

    let msrv = match msrv_result {
        Ok(status) => status,
        Err(err) => {
            extra_recommendations.push(format!("MSRV check unavailable: {err}"));
            MsrvStatus::Missing
        }
    };

    let mut output = score_quality(&QualityInputs {
        dependency_freshness,
        security,
        unused,
        unsafe_code,
        clippy,
        msrv,
    });

    output.recommendations.extend(extra_recommendations);

    output
}

async fn check_msrv() -> Result<MsrvStatus> {
    let metadata = run_blocking("cargo metadata", || {
        MetadataCommand::new().exec().map_err(|err| {
            UpkeepError::context(ErrorCode::Metadata, "failed to load cargo metadata", err)
        })
    })
    .await?;

    // For virtual workspaces (no root package), return Missing for graceful degradation
    let Some(root) = metadata.root_package() else {
        return Ok(MsrvStatus::Missing);
    };

    match root.rust_version.as_ref() {
        Some(_) => Ok(MsrvStatus::Valid),
        None => Ok(MsrvStatus::Missing),
    }
}

async fn run_blocking<T, F>(label: &str, func: F) -> Result<T>
where
    T: Send + 'static,
    F: FnOnce() -> Result<T> + Send + 'static,
{
    tokio::task::spawn_blocking(func).await.map_err(|err| {
        UpkeepError::message(ErrorCode::TaskFailed, format!("{label} task failed: {err}"))
    })?
}

#[cfg(test)]
mod tests {
    use super::{build_quality_output, check_msrv, run_blocking, MsrvStatus};
    use crate::core::error::{ErrorCode, UpkeepError};
    use crate::core::output::{Grade, MetricScore, QualityOutput};
    use serde_json::Value;

    fn err() -> UpkeepError {
        UpkeepError::message(ErrorCode::TaskFailed, "boom")
    }

    #[tokio::test]
    async fn run_blocking_returns_ok_value() {
        let value = run_blocking("ok", || Ok(42)).await.unwrap();
        assert_eq!(value, 42);
    }

    #[tokio::test]
    async fn run_blocking_propagates_inner_error() {
        let err = run_blocking::<u8, _>("fail", || {
            Err(UpkeepError::message(ErrorCode::InvalidData, "nope"))
        })
        .await
        .unwrap_err();

        assert_eq!(err.code(), ErrorCode::InvalidData);
    }

    #[tokio::test]
    async fn check_msrv_returns_valid_when_set() {
        let status = check_msrv().await.unwrap();
        assert!(matches!(
            status,
            crate::core::scorers::quality::MsrvStatus::Valid
        ));
    }

    #[test]
    fn build_quality_output_adds_recommendations_for_failures() {
        let output = build_quality_output(
            Err(err()),
            Err(err()),
            Err(err()),
            Ok(MsrvStatus::Valid),
            Err(err()),
            Err(err()),
        );

        assert_eq!(
            output.recommendations,
            vec![
                "Dependency freshness unavailable: boom".to_string(),
                "Security scan unavailable: boom".to_string(),
                "Clippy unavailable: boom".to_string(),
                "Unused dependencies unavailable: boom".to_string(),
                "Unsafe code scan unavailable: boom".to_string(),
            ]
        );
    }

    #[test]
    fn emit_output_json_shape() {
        let output = QualityOutput {
            score: 92.5,
            grade: Grade::A,
            breakdown: vec![MetricScore {
                name: "Security".to_string(),
                score: 90.0,
                weight: 0.25,
            }],
            recommendations: vec!["Address advisories".to_string()],
        };

        let value = serde_json::to_value(&output).expect("serialize");
        assert_eq!(value["grade"], Value::String("A".into()));
        assert_eq!(
            value["breakdown"][0]["name"],
            Value::String("Security".into())
        );
    }
}