ferrous-forge 1.9.4

System-wide Rust development standards enforcer
Documentation
//! License validation checking

use crate::Result;
use std::path::Path;
use std::time::Instant;
use tokio::fs;

use super::SafetyCheck;
use crate::safety::{CheckType, report::CheckResult};

/// License check implementation
pub struct LicenseCheck;

impl SafetyCheck for LicenseCheck {
    async fn run(project_path: &Path) -> Result<CheckResult> {
        run(project_path).await
    }

    fn name() -> &'static str {
        "license"
    }

    fn description() -> &'static str {
        "Validates license compatibility and presence"
    }
}

/// Validate license configuration
///
/// # Errors
///
/// Returns an error if `Cargo.toml` cannot be read or parsed.
pub async fn run(project_path: &Path) -> Result<CheckResult> {
    let start = Instant::now();
    let mut result = CheckResult::new(CheckType::License);

    let manifest = match load_cargo_manifest(project_path).await {
        Ok(manifest) => manifest,
        Err(e) => {
            result.add_error(&e.to_string());
            result.set_duration(start.elapsed());
            return Ok(result);
        }
    };

    validate_license_fields(&manifest, project_path, &mut result);
    validate_metadata_fields(&manifest, &mut result);

    result.set_duration(start.elapsed());
    Ok(result)
}

/// Load and parse Cargo.toml manifest
async fn load_cargo_manifest(project_path: &Path) -> Result<toml::Value> {
    let cargo_toml_path = project_path.join("Cargo.toml");

    if !cargo_toml_path.exists() {
        return Err(crate::Error::io("Cargo.toml not found".to_string()));
    }

    let contents = fs::read_to_string(&cargo_toml_path).await?;
    let manifest: toml::Value = toml::from_str(&contents)
        .map_err(|e| crate::Error::parse(format!("Failed to parse Cargo.toml: {}", e)))?;

    Ok(manifest)
}

/// Validate license-related fields
fn validate_license_fields(manifest: &toml::Value, project_path: &Path, result: &mut CheckResult) {
    let license = manifest
        .get("package")
        .and_then(|p| p.get("license"))
        .and_then(|l| l.as_str());

    let license_file = manifest
        .get("package")
        .and_then(|p| p.get("license-file"))
        .and_then(|l| l.as_str());

    if license.is_none() && license_file.is_none() {
        result.add_error("No license specified in Cargo.toml");
        result.add_suggestion("Add 'license = \"MIT OR Apache-2.0\"' to [package] section");
        result.add_suggestion("Or add 'license-file = \"LICENSE\"' if using custom license");
    } else if let Some(license_str) = license {
        validate_license_string(license_str, result);
        check_license_file_exists(license_str, project_path, result);
    }
}

/// Validate license string against approved licenses
fn validate_license_string(license_str: &str, result: &mut CheckResult) {
    let approved_licenses = [
        "MIT",
        "Apache-2.0",
        "MIT OR Apache-2.0",
        "Apache-2.0 OR MIT",
        "BSD-3-Clause",
        "BSD-2-Clause",
        "ISC",
        "MPL-2.0",
    ];

    if !approved_licenses
        .iter()
        .any(|&approved| license_str.contains(approved))
    {
        result.add_error(format!("Uncommon license detected: {}", license_str));
        result.add_suggestion("Consider using a standard license like 'MIT OR Apache-2.0'");
        result.add_context("This may cause issues with some package managers");
    } else {
        result.add_context(format!("License: {}", license_str));
    }
}

/// Check if license file exists for licenses that require it
fn check_license_file_exists(license_str: &str, project_path: &Path, result: &mut CheckResult) {
    if license_str.contains("MIT") || license_str.contains("Apache") {
        let license_files = [
            "LICENSE",
            "LICENSE.txt",
            "LICENSE.md",
            "LICENSE-MIT",
            "LICENSE-APACHE",
        ];

        let has_license_file = license_files
            .iter()
            .any(|&file| project_path.join(file).exists());

        if !has_license_file {
            result.add_error("License specified but no LICENSE file found");
            result.add_suggestion("Create a LICENSE file with the license text");
        }
    }
}

/// Validate other metadata fields
fn validate_metadata_fields(manifest: &toml::Value, result: &mut CheckResult) {
    let description = manifest
        .get("package")
        .and_then(|p| p.get("description"))
        .and_then(|d| d.as_str());

    if description.is_none_or(|d| d.trim().is_empty()) {
        result.add_error("Missing or empty description in Cargo.toml");
        result.add_suggestion("Add a clear description of what your crate does");
    }

    let repository = manifest
        .get("package")
        .and_then(|p| p.get("repository"))
        .and_then(|r| r.as_str());

    if repository.is_none() {
        result.add_error("Missing repository URL in Cargo.toml");
        result.add_suggestion("Add 'repository = \"https://github.com/user/repo\"' to [package]");
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[tokio::test]
    async fn test_license_check_with_valid_license() {
        let temp_dir = TempDir::new().unwrap();

        let cargo_toml = r#"
[package]
name = "test"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "A test crate"
repository = "https://github.com/user/test"
"#;

        fs::write(temp_dir.path().join("Cargo.toml"), cargo_toml)
            .await
            .unwrap();

        // Create LICENSE file
        fs::write(
            temp_dir.path().join("LICENSE"),
            "MIT License\n\nCopyright (c) 2025",
        )
        .await
        .unwrap();

        let result = run(temp_dir.path()).await.unwrap();

        // Debug: Print what errors were found
        if !result.passed {
            for error in &result.errors {
                println!("License test error: {}", error);
            }
        }

        // Should pass with valid license
        assert!(result.passed);
    }

    #[test]
    fn test_license_check_struct() {
        assert_eq!(LicenseCheck::name(), "license");
        assert!(!LicenseCheck::description().is_empty());
    }
}