use anyhow::Result;
use clap::Parser;
use owo_colors::OwoColorize;
use std::process::exit;
use crate::cli::{AuditArgs, Cli, Commands, ExportArgs, OutputFormat, TraceArgs, WhyArgs};
use crate::policy::{CompatibilityReport, CompatibilityStatus};
use crate::reporter::{
AuditReporter, CycloneDxReporter, JsonReporter, NoticeReporter, SarifReporter, TableReporter,
TreeReporter,
};
use crate::resolver::{self, NpmOnlineResolver};
pub async fn run() -> Result<()> {
let args = Cli::parse();
match args.command {
Commands::Audit(audit_args) => handle_audit(audit_args).await?,
Commands::Trace(trace_args) => handle_trace(trace_args).await?,
Commands::Why(why_args) => handle_why(why_args).await?,
Commands::Export(export_args) => handle_export(export_args).await?,
}
Ok(())
}
async fn handle_audit(args: AuditArgs) -> Result<()> {
let project_dir = std::fs::canonicalize(&args.path)
.map_err(|_| anyhow::anyhow!("Directory '{}' does not exist", args.path.display()))?;
let graph = resolver::resolve_auto(&project_dir)?;
let report = CompatibilityReport::evaluate(&args.outbound, &graph, args.prod_only);
match args.format {
OutputFormat::Audit => {
AuditReporter::render_terminal(&report, &graph);
}
OutputFormat::Table => {
TableReporter::render(&graph, args.prod_only);
}
OutputFormat::Tree => {
TreeReporter::render(&graph);
}
OutputFormat::Json => {
let json_str = JsonReporter::render(&report, &graph)?;
println!("{}", json_str);
}
OutputFormat::Markdown => {
let md = NoticeReporter::generate_markdown(&graph, args.prod_only);
println!("{}", md);
}
OutputFormat::CycloneDx => {
println!("{}", CycloneDxReporter::render(&graph));
}
OutputFormat::Sarif => {
println!("{}", SarifReporter::render(&report));
}
}
let mut exit_code = 0;
if args.fail_on_incompatible && report.status == CompatibilityStatus::Incompatible {
eprintln!(
"{}",
"[ERROR] Incompatible license constraints detected in dependencies."
.bold()
.red()
);
exit_code = 1;
}
if args.fail_on_unknown
&& (report.status == CompatibilityStatus::NeedsReview
|| report.obligations.unknown_license_count > 0)
{
eprintln!(
"{}",
"[WARN] Unknown licenses require manual review before passing audit."
.bold()
.yellow()
);
if exit_code == 0 {
exit_code = 2;
}
}
if exit_code != 0 {
exit(exit_code);
}
Ok(())
}
async fn handle_trace(args: TraceArgs) -> Result<()> {
let target = args.target.trim();
let path_obj = std::path::Path::new(target);
let graph = if target == "." || path_obj.exists() {
let project_dir = std::fs::canonicalize(path_obj)
.map_err(|_| anyhow::anyhow!("Directory '{}' does not exist", path_obj.display()))?;
println!("Tracing local project at '{}'...", project_dir.display());
resolver::resolve_auto(&project_dir)?
} else if target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("git@")
{
if target.starts_with('-') {
anyhow::bail!("Invalid git repository URL '{}'", target);
}
println!(
"Cloning remote git repository '{}'...",
target.bold().cyan()
);
let temp_dir = std::env::temp_dir().join(format!(
"license-trace-git-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
));
let status = std::process::Command::new("git")
.args([
"clone",
"--depth",
"1",
"--",
target,
temp_dir.to_str().unwrap(),
])
.output()?;
if !status.status.success() {
anyhow::bail!(
"Failed to clone git repository: {}",
String::from_utf8_lossy(&status.stderr)
);
}
let res = resolver::resolve_auto(&temp_dir);
let _ = std::fs::remove_dir_all(&temp_dir); res?
} else {
let (pkg_name, version) = parse_pkg_spec(target);
println!(
"Resolving dependency tree for {} via registry API...",
target.bold().cyan()
);
let resolver = NpmOnlineResolver::new(args.max_depth);
match resolver.resolve_package(pkg_name, version.as_deref()).await {
Ok(g) => g,
Err(e) => anyhow::bail!("Failed to resolve package: {}", e),
}
};
let report = CompatibilityReport::evaluate(&args.outbound, &graph, args.prod_only);
match args.format {
OutputFormat::Audit => {
AuditReporter::render_terminal(&report, &graph);
}
OutputFormat::Table => {
TableReporter::render(&graph, args.prod_only);
}
OutputFormat::Tree => {
TreeReporter::render(&graph);
}
OutputFormat::Json => {
let json_str = JsonReporter::render(&report, &graph)?;
println!("{}", json_str);
}
OutputFormat::Markdown => {
let md = NoticeReporter::generate_markdown(&graph, args.prod_only);
println!("{}", md);
}
OutputFormat::CycloneDx => {
println!("{}", CycloneDxReporter::render(&graph));
}
OutputFormat::Sarif => {
println!("{}", SarifReporter::render(&report));
}
}
let mut exit_code = 0;
if args.fail_on_incompatible && report.status == CompatibilityStatus::Incompatible {
eprintln!(
"{}",
"[ERROR] Incompatible license constraints detected in dependencies."
.bold()
.red()
);
exit_code = 1;
}
if args.fail_on_unknown
&& (report.status == CompatibilityStatus::NeedsReview
|| report.obligations.unknown_license_count > 0)
{
eprintln!(
"{}",
"[WARN] Unknown licenses require manual review before passing audit."
.bold()
.yellow()
);
if exit_code == 0 {
exit_code = 2;
}
}
if exit_code != 0 {
exit(exit_code);
}
Ok(())
}
async fn handle_export(args: ExportArgs) -> Result<()> {
let project_dir = std::fs::canonicalize(&args.path)
.map_err(|_| anyhow::anyhow!("Directory '{}' does not exist", args.path.display()))?;
println!(
"Generating third-party notices for project at '{}'...",
project_dir.display()
);
let graph = resolver::resolve_auto(&project_dir)?;
let content = NoticeReporter::generate_markdown(&graph, args.prod_only);
if let Some(out_path) = args.output {
std::fs::write(&out_path, &content)?;
println!(
"{} Successfully exported third-party notices to '{}'",
"[SUCCESS]".green().bold(),
out_path.display().to_string().cyan()
);
} else {
println!("{}", content);
}
Ok(())
}
async fn handle_why(args: WhyArgs) -> Result<()> {
let project_dir = std::fs::canonicalize(&args.path)
.map_err(|_| anyhow::anyhow!("Directory '{}' does not exist", args.path.display()))?;
let graph = resolver::resolve_auto(&project_dir)?;
let paths = graph.find_all_paths_to(&args.package);
println!();
println!(
"Dependency path search for '{}':",
args.package.bold().cyan()
);
println!();
if paths.is_empty() {
println!(
" No dependency paths found reaching '{}'. (The package is not present in graph)",
args.package.yellow()
);
} else {
println!(
" Found {} path(s) from root [{}]:",
paths.len().to_string().bold().green(),
graph.root_id.to_string_repr().bold()
);
println!();
for (i, path) in paths.iter().enumerate() {
let path_str = path
.iter()
.enumerate()
.map(|(idx, id)| {
if idx == 0 {
id.name.bold().cyan().to_string()
} else if idx == path.len() - 1 {
id.to_string_repr().bold().yellow().to_string()
} else {
id.to_string_repr().white().to_string()
}
})
.collect::<Vec<_>>()
.join(" ➔ ");
println!(" Route {:02}: {}", i + 1, path_str);
}
}
println!();
Ok(())
}
fn parse_pkg_spec(pkg: &str) -> (&str, Option<String>) {
let trimmed = pkg.trim();
if let Some(stripped) = trimmed.strip_prefix('@') {
if let Some(second_at_idx) = stripped.find('@') {
let split_idx = 1 + second_at_idx;
(
&trimmed[..split_idx],
Some(trimmed[split_idx + 1..].to_string()),
)
} else {
(trimmed, None)
}
} else if let Some((name, ver)) = trimmed.split_once('@') {
(name, Some(ver.to_string()))
} else {
(trimmed, None)
}
}