use crate::sbom::{self, SbomFormat};
use super::SbomArgs;
use anyhow::Result;
use colored::*;
use std::path::Path;
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)?;
let doc = sbom::build_sbom(project_root, &project_name, project_version);
eprintln!(" Components: {}", doc.components.len().to_string().bold());
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);
}
if args.ci {
write_sbom_workflow(&args.shared.project)?;
}
Ok(())
}
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 {
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();
}
}
}
}
}
}
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();
}
}
}
}
}
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() {
let name = discover_project_name(Path::new("/nonexistent/path"));
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);
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);
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);
}
}