forge-guard 0.2.1

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! `forge-guard sbom` — generate Software Bill of Materials (SBOM).

use crate::sbom::{self, SbomFormat};

use super::SbomArgs;
use anyhow::Result;
use colored::*;
use std::path::Path;

/// Generate an SBOM for the current project.
pub fn run(args: &SbomArgs) -> Result<()> {
    eprintln!("{}", "📦 Forge Guard — SBOM Generator".bold());
    eprintln!(
        "   Format:     {}",
        match args.format.as_str() {
            "cyclonedx" | "cyc" | "cdx" => "CycloneDX 1.6",
            "spdx" => "SPDX 2.3",
            other => other,
        }
    );

    let format = match args.format.to_lowercase().as_str() {
        "cyclonedx" | "cyc" | "cdx" => SbomFormat::CycloneDX,
        "spdx" => SbomFormat::SPDX,
        other => anyhow::bail!(
            "Unsupported SBOM format: {}. Use 'cyclonedx' or 'spdx'.",
            other
        ),
    };

    let project_root = Path::new(".");
    let project_name = discover_project_name(project_root);
    let project_version = env!("CARGO_PKG_VERSION");

    eprintln!("   Project:    {}", project_name);
    eprintln!("   Root:       {}", project_root.display());

    let sbom_json = sbom::generate_sbom(project_root, &project_name, project_version, format)?;

    // Count components for the summary
    let doc = sbom::build_sbom(project_root, &project_name, project_version);
    eprintln!("   Components: {}", doc.components.len().to_string().bold());

    // Write to file or stdout
    if let Some(output_path) = &args.output {
        std::fs::write(output_path, &sbom_json)?;
        println!(
            "{} SBOM written to {}",
            "".green().bold(),
            output_path.display().to_string().bold()
        );
    } else {
        println!("{}", sbom_json);
    }

    // CI/CD export for supply-chain compliance
    if args.ci {
        write_sbom_workflow(&args.shared.project)?;
    }

    Ok(())
}

/// Write `.github/workflows/sbom.yml` for automated SBOM publishing.
fn write_sbom_workflow(project_root: &std::path::Path) -> Result<()> {
    let generator = crate::ci::CiGenerator::new("github");
    let workflow = generator.generate_sbom_workflow();
    let out_dir = project_root.join(".github").join("workflows");
    std::fs::create_dir_all(&out_dir)?;
    let out_path = out_dir.join("sbom.yml");
    std::fs::write(&out_path, &workflow)?;
    println!(
        "{} SBOM workflow written to {}",
        "".green().bold(),
        out_path.display().to_string().bold()
    );
    Ok(())
}

fn discover_project_name(project_root: &Path) -> String {
    // Try foundry.toml for a [project] name field
    let foundry_path = project_root.join("foundry.toml");
    if let Ok(content) = std::fs::read_to_string(&foundry_path) {
        let mut in_profile_default = false;
        for line in content.lines() {
            let line = line.trim();
            if line.starts_with("[profile.default]") || line.starts_with("[project]") {
                in_profile_default = true;
                continue;
            }
            if in_profile_default {
                if line.starts_with('[') {
                    in_profile_default = false;
                    continue;
                }
                if let Some(name) = line.strip_prefix("name") {
                    if let Some(eq) = name.find('=') {
                        let val = name[eq + 1..].trim().trim_matches('"').trim_matches('\'');
                        if !val.is_empty() {
                            return val.to_string();
                        }
                    }
                }
            }
        }
    }

    // Also check forge-guard.toml
    let config_path = project_root.join("forge-guard.toml");
    if let Ok(content) = std::fs::read_to_string(&config_path) {
        for line in content.lines() {
            let line = line.trim();
            if let Some(name) = line.strip_prefix("name") {
                if let Some(eq) = name.find('=') {
                    let val = name[eq + 1..].trim().trim_matches('"').trim_matches('\'');
                    if !val.is_empty() {
                        return val.to_string();
                    }
                }
            }
        }
    }

    // Fallback: project root directory name
    project_root
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| "unknown-project".into())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_sbom_args_defaults() {
        let args = SbomArgs {
            shared: super::super::SharedFlags::default(),
            format: "cyclonedx".into(),
            output: None,
            ci: false,
        };
        assert_eq!(args.format, "cyclonedx");
        assert!(args.output.is_none());
        assert!(!args.ci);
    }

    #[test]
    fn test_sbom_args_spdx_with_output() {
        let args = SbomArgs {
            shared: super::super::SharedFlags::default(),
            format: "spdx".into(),
            output: Some(std::path::PathBuf::from("sbom.spdx.json")),
            ci: true,
        };
        assert_eq!(args.format, "spdx");
        assert_eq!(
            args.output.as_deref(),
            Some(std::path::Path::new("sbom.spdx.json"))
        );
        assert!(args.ci);
    }

    #[test]
    fn test_write_sbom_workflow_creates_file() {
        let dir = tempfile::tempdir().unwrap();

        let result = write_sbom_workflow(dir.path());

        assert!(result.is_ok());
        let path = dir
            .path()
            .join(".github")
            .join("workflows")
            .join("sbom.yml");
        assert!(path.exists(), "sbom.yml should be written");
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("name: SBOM Generation"));
        assert!(content.contains("forge-guard sbom --format cyclonedx"));
        assert!(content.contains("forge-guard sbom --format spdx"));
    }

    #[test]
    fn test_format_aliases() {
        assert!(matches!(
            match "cyclonedx" {
                "cyclonedx" | "cyc" | "cdx" => SbomFormat::CycloneDX,
                _ => SbomFormat::SPDX,
            },
            SbomFormat::CycloneDX
        ));
        assert!(matches!(
            match "cdx" {
                "cyclonedx" | "cyc" | "cdx" => SbomFormat::CycloneDX,
                _ => SbomFormat::SPDX,
            },
            SbomFormat::CycloneDX
        ));
        assert!(matches!(
            match "spdx" {
                "spdx" => SbomFormat::SPDX,
                _ => SbomFormat::CycloneDX,
            },
            SbomFormat::SPDX
        ));
    }

    #[test]
    fn test_invalid_format_rejected() {
        let result = match "invalid" {
            "cyclonedx" | "cyc" | "cdx" => Ok(SbomFormat::CycloneDX),
            "spdx" => Ok(SbomFormat::SPDX),
            other => Err(format!("Unsupported format: {}", other)),
        };
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Unsupported"));
    }

    #[test]
    fn test_discover_project_name_fallback_to_unknown() {
        // A non-existent path should return the current directory name or unknown
        let name = discover_project_name(Path::new("/nonexistent/path"));
        // Should not panic, returns either current dir name or "unknown-project"
        assert!(!name.is_empty());
    }

    #[test]
    fn test_discover_project_name_from_foundry_toml() {
        let dir = std::env::temp_dir().join(format!("sbom-cli-test-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        std::fs::write(
            dir.join("foundry.toml"),
            "[profile.default]\n\
             src = 'src'\n\
             name = 'my-forge-project'\n",
        )
        .unwrap();

        let name = discover_project_name(&dir);
        assert_eq!(name, "my-forge-project");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_discover_project_name_from_forge_guard_toml() {
        let dir = std::env::temp_dir().join(format!("sbom-cli-test2-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        std::fs::write(
            dir.join("forge-guard.toml"),
            "name = 'forge-guard-audit'\n\
             chain = 'base'\n",
        )
        .unwrap();

        let name = discover_project_name(&dir);
        assert_eq!(name, "forge-guard-audit");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_discover_project_name_prefers_foundry_toml() {
        let dir = std::env::temp_dir().join(format!("sbom-cli-test3-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        std::fs::write(
            dir.join("foundry.toml"),
            "[profile.default]\n\
             name = 'from-foundry'\n",
        )
        .unwrap();
        std::fs::write(dir.join("forge-guard.toml"), "name = 'from-forge-guard'\n").unwrap();

        let name = discover_project_name(&dir);
        // foundry.toml is checked first
        assert_eq!(name, "from-foundry");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_discover_project_name_no_project_files() {
        let dir = std::env::temp_dir().join(format!("sbom-cli-test4-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let name = discover_project_name(&dir);
        // Should use directory name when no config files
        assert_eq!(name, format!("sbom-cli-test4-{}", std::process::id()));

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_discover_project_name_ignores_other_sections() {
        let dir = std::env::temp_dir().join(format!("sbom-cli-test5-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        std::fs::write(
            dir.join("foundry.toml"),
            "[profile.ci]\n\
             name = 'should-not-match'\n\
             [profile.default]\n\
             name = 'correct-name'\n",
        )
        .unwrap();

        let name = discover_project_name(&dir);
        assert_eq!(name, "correct-name");

        let _ = std::fs::remove_dir_all(&dir);
    }
}