use cargo_metadata::{Metadata, Package, PackageId};
use rayon::prelude::*;
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use crate::debug::{log, log_debug, log_error, LogLevel};
use crate::licenses::{
fetch_licenses_from_github, is_license_restrictive, LicenseCompatibility, LicenseInfo,
};
#[allow(dead_code)]
pub fn analyze_rust_licenses(packages: Vec<Package>) -> Vec<LicenseInfo> {
let config = crate::config::load_config().unwrap_or_default();
analyze_rust_licenses_with_config(packages, &config, false)
}
pub fn analyze_rust_licenses_with_metadata(
metadata: Metadata,
config: &crate::config::FeludaConfig,
no_local: bool,
) -> Vec<LicenseInfo> {
let workspace_members: HashSet<PackageId> =
metadata.workspace_members.iter().cloned().collect();
let is_workspace = workspace_members.len() > 1;
log(
LogLevel::Info,
&format!(
"Cargo metadata: {} workspace members, {} total packages",
workspace_members.len(),
metadata.packages.len()
),
);
if !is_workspace {
log(
LogLevel::Info,
"Single-crate project; no workspace attribution",
);
return analyze_rust_licenses_with_config(metadata.packages, config, no_local);
}
let attribution = build_workspace_attribution(&metadata, &workspace_members);
log_debug("Workspace attribution map", &attribution);
let dep_packages: Vec<Package> = metadata
.packages
.into_iter()
.filter(|p| !workspace_members.contains(&p.id))
.collect();
log(
LogLevel::Info,
&format!(
"Analyzing {} non-workspace deps across workspace",
dep_packages.len()
),
);
let mut infos = analyze_rust_licenses_with_config(dep_packages, config, no_local);
for info in &mut infos {
if let Some(member_names) = attribution.get(&(info.name.clone(), info.version.clone())) {
if !member_names.is_empty() {
info.sub_project =
Some(member_names.iter().cloned().collect::<Vec<_>>().join(", "));
}
}
}
infos
}
fn build_workspace_attribution(
metadata: &Metadata,
workspace_members: &HashSet<PackageId>,
) -> HashMap<(String, String), BTreeSet<String>> {
let mut attribution: HashMap<(String, String), BTreeSet<String>> = HashMap::new();
let resolve = match &metadata.resolve {
Some(r) => r,
None => {
log(LogLevel::Warn, "No resolve graph in cargo metadata");
return attribution;
}
};
let nodes_by_id: HashMap<&PackageId, &cargo_metadata::Node> =
resolve.nodes.iter().map(|n| (&n.id, n)).collect();
let pkg_by_id: HashMap<&PackageId, &Package> =
metadata.packages.iter().map(|p| (&p.id, p)).collect();
for member_id in workspace_members {
let member_name = match pkg_by_id.get(member_id) {
Some(p) => p.name.to_string(),
None => continue,
};
let mut visited: HashSet<&PackageId> = HashSet::new();
let mut queue: VecDeque<&PackageId> = VecDeque::new();
queue.push_back(member_id);
visited.insert(member_id);
while let Some(id) = queue.pop_front() {
let node = match nodes_by_id.get(id) {
Some(n) => *n,
None => continue,
};
for dep_id in &node.dependencies {
if !visited.insert(dep_id) {
continue;
}
queue.push_back(dep_id);
if workspace_members.contains(dep_id) {
continue;
}
if let Some(pkg) = pkg_by_id.get(dep_id) {
attribution
.entry((pkg.name.to_string(), pkg.version.to_string()))
.or_default()
.insert(member_name.clone());
}
}
}
}
attribution
}
pub fn analyze_rust_licenses_with_config(
packages: Vec<Package>,
config: &crate::config::FeludaConfig,
no_local: bool,
) -> Vec<LicenseInfo> {
if packages.is_empty() {
log(
LogLevel::Warn,
"No Rust packages found for license analysis",
);
return vec![];
}
log(
LogLevel::Info,
&format!("Analyzing licenses for {} Rust packages", packages.len()),
);
let known_licenses = match fetch_licenses_from_github() {
Ok(licenses) => {
log(
LogLevel::Info,
&format!("Fetched {} known licenses from GitHub", licenses.len()),
);
licenses
}
Err(err) => {
log_error("Failed to fetch licenses from GitHub", &err);
HashMap::new()
}
};
packages
.par_iter()
.map(|package| {
log(
LogLevel::Info,
&format!("Analyzing package: {} ({})", package.name, package.version),
);
let license = package.license.clone().or_else(|| {
if no_local {
None
} else {
get_license_from_manifest(&package.manifest_path)
}
});
let is_restrictive = is_license_restrictive(&license, &known_licenses, config.strict);
if is_restrictive {
log(
LogLevel::Warn,
&format!(
"Restrictive license found: {:?} for {}",
license, package.name
),
);
}
LicenseInfo {
name: package.name.to_string(),
version: package.version.to_string(),
license,
is_restrictive,
compatibility: LicenseCompatibility::Unknown,
osi_status: match &package.license {
Some(license) => crate::licenses::get_osi_status(license),
None => crate::licenses::OsiStatus::Unknown,
},
sub_project: None,
}
})
.collect()
}
fn get_license_from_manifest<P: AsRef<std::path::Path>>(manifest_path: P) -> Option<String> {
use std::fs;
use toml::Value;
let manifest_path = manifest_path.as_ref();
log(
crate::debug::LogLevel::Info,
&format!("Checking manifest for license: {}", manifest_path.display()),
);
if !manifest_path.exists() {
return None;
}
match fs::read_to_string(manifest_path) {
Ok(content) => match toml::from_str::<Value>(&content) {
Ok(manifest) => manifest
.get("package")
.and_then(|pkg| pkg.get("license"))
.and_then(|license| license.as_str())
.map(|s| {
log(
crate::debug::LogLevel::Info,
&format!("Found license in manifest: {s}"),
);
s.to_string()
}),
Err(_) => None,
},
Err(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn test_analyze_rust_licenses_empty() {
let packages = vec![];
let result = analyze_rust_licenses(packages);
assert!(result.is_empty());
}
#[test]
fn test_license_restrictive_with_default_config() {
temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || {
let dir = setup();
std::env::set_current_dir(dir.path()).unwrap();
let known_licenses = HashMap::new();
assert!(is_license_restrictive(
&Some("GPL-3.0".to_string()),
&known_licenses,
false
));
assert!(!is_license_restrictive(
&Some("MIT".to_string()),
&known_licenses,
false
));
});
}
#[test]
fn test_license_restrictive_no_license() {
temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || {
let dir = setup();
std::env::set_current_dir(dir.path()).unwrap();
let known_licenses = HashMap::new();
assert!(is_license_restrictive(
&Some("No License".to_string()),
&known_licenses,
false
));
});
}
#[test]
fn test_get_license_from_manifest() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
let manifest_content = r#"[package]
name = "test-crate"
version = "0.1.0"
license = "MIT"
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
let result = get_license_from_manifest(&manifest_path);
assert_eq!(result, Some("MIT".to_string()));
}
#[test]
fn test_get_license_from_manifest_apache() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
let manifest_content = r#"[package]
name = "test-crate"
version = "0.1.0"
license = "Apache-2.0"
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
let result = get_license_from_manifest(&manifest_path);
assert_eq!(result, Some("Apache-2.0".to_string()));
}
#[test]
fn test_get_license_from_manifest_missing() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
let manifest_content = r#"[package]
name = "test-crate"
version = "0.1.0"
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
let result = get_license_from_manifest(&manifest_path);
assert_eq!(result, None);
}
#[test]
fn test_get_license_from_manifest_not_found() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("nonexistent.toml");
let result = get_license_from_manifest(&manifest_path);
assert_eq!(result, None);
}
}