use crate::cli;
use crate::debug::{log, log_debug, FeludaResult, LogLevel};
use crate::languages::{
c::analyze_c_licenses, cpp::analyze_cpp_licenses, dotnet::analyze_dotnet_licenses,
go::analyze_go_licenses, node::analyze_js_licenses_with_no_local,
python::analyze_python_licenses, r::analyze_r_licenses,
rust::analyze_rust_licenses_with_no_local,
};
use crate::languages::{Language, CPP_PATHS, C_PATHS, DOTNET_PATHS, PYTHON_PATHS, R_PATHS};
use crate::licenses::{
detect_project_license, is_license_compatible, LicenseCompatibility, LicenseInfo,
};
use cargo_metadata::MetadataCommand;
use rayon::prelude::*;
use std::path::{Path, PathBuf};
#[derive(Debug)]
struct ProjectRoot {
pub path: PathBuf,
pub project_type: Language,
}
fn find_project_roots(root_path: impl AsRef<Path>) -> FeludaResult<Vec<ProjectRoot>> {
let mut project_roots = Vec::new();
let root = root_path.as_ref();
log(
LogLevel::Info,
&format!("Scanning for project files in: {}", root.display()),
);
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.filter_map(|e| e.ok()) {
if let Ok(file_type) = entry.file_type() {
if !file_type.is_file() {
continue;
}
} else {
continue;
}
let path = entry.path();
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if let Some(project_type) = Language::from_file_name(file_name) {
log(
LogLevel::Info,
&format!(
"Found project file: {} ({:?})",
path.display(),
project_type
),
);
project_roots.push(ProjectRoot {
path: root.to_path_buf(),
project_type,
});
}
}
}
log(
LogLevel::Info,
&format!("Found {} project roots", project_roots.len()),
);
log_debug("Project roots", &project_roots);
Ok(project_roots)
}
fn check_which_c_file_exists(project_path: impl AsRef<Path>) -> Option<String> {
for &path in C_PATHS.iter() {
let full_path = Path::new(project_path.as_ref()).join(path);
if full_path.exists() {
log(
LogLevel::Info,
&format!("Found C project file: {}", full_path.display()),
);
return Some(path.to_string());
}
}
log(
LogLevel::Warn,
&format!(
"No C project file found in: {}",
project_path.as_ref().display()
),
);
None
}
fn check_which_cpp_file_exists(project_path: impl AsRef<Path>) -> Option<String> {
for &path in CPP_PATHS.iter() {
let full_path = Path::new(project_path.as_ref()).join(path);
if full_path.exists() {
log(
LogLevel::Info,
&format!("Found C++ project file: {}", full_path.display()),
);
return Some(path.to_string());
}
}
log(
LogLevel::Warn,
&format!(
"No C++ project file found in: {}",
project_path.as_ref().display()
),
);
None
}
fn check_which_python_file_exists(project_path: impl AsRef<Path>) -> Option<String> {
for &path in PYTHON_PATHS.iter() {
let full_path = Path::new(project_path.as_ref()).join(path);
if full_path.exists() {
log(
LogLevel::Info,
&format!("Found Python project file: {}", full_path.display()),
);
return Some(path.to_string());
}
}
log(
LogLevel::Warn,
&format!(
"No Python project file found in: {}",
project_path.as_ref().display()
),
);
None
}
fn check_which_r_file_exists(project_path: impl AsRef<Path>) -> Option<String> {
for &path in R_PATHS.iter() {
let full_path = Path::new(project_path.as_ref()).join(path);
if full_path.exists() {
log(
LogLevel::Info,
&format!("Found R project file: {}", full_path.display()),
);
return Some(path.to_string());
}
}
log(
LogLevel::Warn,
&format!(
"No R project file found in: {}",
project_path.as_ref().display()
),
);
None
}
fn check_which_dotnet_file_exists(project_path: impl AsRef<Path>) -> Option<String> {
for &path in DOTNET_PATHS.iter() {
if path.starts_with('.') {
if let Ok(entries) = std::fs::read_dir(project_path.as_ref()) {
for entry in entries.filter_map(|e| e.ok()) {
if let Some(file_name) = entry.file_name().to_str() {
if file_name.ends_with(path) {
log(
LogLevel::Info,
&format!("Found .NET project file: {}", entry.path().display()),
);
return Some(file_name.to_string());
}
}
}
}
} else {
let full_path = Path::new(project_path.as_ref()).join(path);
if full_path.exists() {
log(
LogLevel::Info,
&format!("Found .NET project file: {}", full_path.display()),
);
return Some(path.to_string());
}
}
}
log(
LogLevel::Warn,
&format!(
"No .NET project file found in: {}",
project_path.as_ref().display()
),
);
None
}
pub fn parse_root(
root_path: impl AsRef<Path>,
language: Option<&str>,
strict: bool,
no_local: bool,
) -> FeludaResult<Vec<LicenseInfo>> {
let mut config = crate::config::load_config()?;
config.strict = strict;
parse_root_with_config(root_path, language, &config, no_local)
}
pub fn parse_root_with_config(
root_path: impl AsRef<Path>,
language: Option<&str>,
config: &crate::config::FeludaConfig,
no_local: bool,
) -> FeludaResult<Vec<LicenseInfo>> {
log(
LogLevel::Info,
&format!("Parsing root path: {}", root_path.as_ref().display()),
);
if let Some(lang) = language {
log(LogLevel::Info, &format!("Filtering by language: {lang}"));
}
let project_roots = find_project_roots(&root_path)?;
if project_roots.is_empty() {
log(
LogLevel::Warn,
"No project files found in the specified path",
);
println!(
"❌ No supported project files found.\n\
Feluda supports: C, C++, .NET, Rust, Node.js, Go, Python, R"
);
return Ok(Vec::new());
}
let licenses: Vec<LicenseInfo> = project_roots
.into_par_iter()
.filter_map(|root| {
if let Some(language) = language {
if !matches_language(root.project_type, language) {
log(
LogLevel::Info,
&format!(
"Skipping {:?} project (language filter: {})",
root.project_type, language
),
);
return None;
}
}
match parse_dependencies(&root, config, no_local) {
Ok(deps) => {
log(
LogLevel::Info,
&format!(
"Found {} dependencies in {}",
deps.len(),
root.path.display()
),
);
Some(deps)
}
Err(err) => {
log(
LogLevel::Error,
&format!(
"Error parsing dependencies in {}: {}",
root.path.display(),
err
),
);
None
}
}
})
.flatten()
.collect();
log(
LogLevel::Info,
&format!("Total dependencies found: {}", licenses.len()),
);
let mut licenses = licenses;
let ignored_count = licenses.len();
licenses.retain(|license| !crate::licenses::is_license_ignored(license.license.as_deref()));
let filtered_count = licenses.len();
if ignored_count != filtered_count {
log(
LogLevel::Info,
&format!(
"Filtered out {} ignored licenses, {} remaining",
ignored_count - filtered_count,
filtered_count
),
);
}
let ignored_count = licenses.len();
licenses.retain(|dep| {
!config
.dependencies
.should_ignore_dependency(&dep.name, Some(&dep.version))
});
let filtered_count = licenses.len();
if ignored_count != filtered_count {
log(
LogLevel::Info,
&format!(
"Filtered out {} ignored dependencies, {} remaining",
ignored_count - filtered_count,
filtered_count
),
);
}
let project_license =
detect_project_license(root_path.as_ref().to_str().unwrap_or("")).unwrap_or(None);
set_license_compatibility(&mut licenses, &project_license);
Ok(licenses)
}
fn set_license_compatibility(licenses: &mut [LicenseInfo], project_license: &Option<String>) {
for license in licenses {
license.compatibility = match (project_license, &license.license) {
(Some(proj_license), Some(dep_license)) => {
is_license_compatible(dep_license, proj_license, false)
}
_ => LicenseCompatibility::Unknown,
};
}
}
fn matches_language(project_type: Language, language: &str) -> bool {
matches!(
(project_type, language.to_lowercase().as_str()),
(Language::C(_), "c")
| (Language::Cpp(_), "cpp" | "c++")
| (
Language::DotNet(_),
"dotnet" | ".net" | "csharp" | "c#" | "fsharp" | "f#"
)
| (Language::Rust(_), "rust")
| (Language::Node(_), "node")
| (Language::Go(_), "go")
| (Language::Python(_), "python")
| (Language::R(_), "r")
)
}
fn parse_dependencies(
root: &ProjectRoot,
config: &crate::config::FeludaConfig,
no_local: bool,
) -> FeludaResult<Vec<LicenseInfo>> {
let project_path = &root.path;
let project_type = root.project_type;
let licenses = cli::with_spinner(&format!("🔎: {}", project_path.display()), |indicator| {
match project_type {
Language::Rust(_) => {
let project_path = Path::new(project_path).join("Cargo.toml");
log(
LogLevel::Info,
&format!("Parsing Rust project: {}", project_path.display()),
);
indicator.update_progress("analyzing Cargo.toml");
match MetadataCommand::new()
.manifest_path(Path::new(&project_path))
.exec()
{
Ok(metadata) => {
log(
LogLevel::Info,
&format!("Found {} packages in Rust project", metadata.packages.len()),
);
indicator.update_progress(&format!(
"found {} packages",
metadata.packages.len()
));
analyze_rust_licenses_with_no_local(metadata.packages, no_local)
}
Err(err) => {
log(
LogLevel::Error,
&format!("Failed to fetch cargo metadata: {err}"),
);
Vec::new()
}
}
}
Language::Node(_) => {
let project_path = Path::new(project_path).join("package.json");
log(
LogLevel::Info,
&format!("Parsing Node.js project: {}", project_path.display()),
);
indicator.update_progress("analyzing package.json");
match project_path.to_str() {
Some(path_str) => {
let deps = analyze_js_licenses_with_no_local(path_str, no_local);
indicator.update_progress(&format!("found {} dependencies", deps.len()));
deps
}
None => {
log(LogLevel::Error, "Failed to convert Node.js path to string");
Vec::new()
}
}
}
Language::Go(_) => {
let project_path = Path::new(project_path).join("go.mod");
log(
LogLevel::Info,
&format!("Parsing Go project: {}", project_path.display()),
);
indicator.update_progress("analyzing go.mod");
match project_path.to_str() {
Some(path_str) => {
let deps = analyze_go_licenses(path_str, config);
indicator.update_progress(&format!("found {} dependencies", deps.len()));
deps
}
None => {
log(LogLevel::Error, "Failed to convert Go path to string");
Vec::new()
}
}
}
Language::Python(_) => match check_which_python_file_exists(project_path) {
Some(python_package_file) => {
let project_path = Path::new(project_path).join(&python_package_file);
log(
LogLevel::Info,
&format!("Parsing Python project: {}", project_path.display()),
);
indicator.update_progress(&format!("analyzing {python_package_file}"));
match project_path.to_str() {
Some(path_str) => {
let deps = analyze_python_licenses(path_str, config);
indicator
.update_progress(&format!("found {} dependencies", deps.len()));
deps
}
None => {
log(LogLevel::Error, "Failed to convert Python path to string");
Vec::new()
}
}
}
None => {
log(LogLevel::Error, "Python package file not found");
Vec::new()
}
},
Language::C(_) => match check_which_c_file_exists(project_path) {
Some(c_build_file) => {
let project_path = Path::new(project_path).join(&c_build_file);
log(
LogLevel::Info,
&format!("Parsing C project: {}", project_path.display()),
);
indicator.update_progress(&format!("analyzing {c_build_file}"));
match project_path.to_str() {
Some(path_str) => {
let deps = analyze_c_licenses(path_str, config);
indicator
.update_progress(&format!("found {} dependencies", deps.len()));
deps
}
None => {
log(LogLevel::Error, "Failed to convert C path to string");
Vec::new()
}
}
}
None => {
log(LogLevel::Error, "C build file not found");
Vec::new()
}
},
Language::Cpp(_) => match check_which_cpp_file_exists(project_path) {
Some(cpp_build_file) => {
let project_path = Path::new(project_path).join(&cpp_build_file);
log(
LogLevel::Info,
&format!("Parsing C++ project: {}", project_path.display()),
);
indicator.update_progress(&format!("analyzing {cpp_build_file}"));
match project_path.to_str() {
Some(path_str) => {
let deps = analyze_cpp_licenses(path_str, config);
indicator
.update_progress(&format!("found {} dependencies", deps.len()));
deps
}
None => {
log(LogLevel::Error, "Failed to convert C++ path to string");
Vec::new()
}
}
}
None => {
log(LogLevel::Error, "C++ build file not found");
Vec::new()
}
},
Language::DotNet(_) => match check_which_dotnet_file_exists(project_path) {
Some(dotnet_project_file) => {
let project_path = Path::new(project_path).join(&dotnet_project_file);
log(
LogLevel::Info,
&format!("Parsing .NET project: {}", project_path.display()),
);
indicator.update_progress(&format!("analyzing {dotnet_project_file}"));
match project_path.to_str() {
Some(path_str) => {
let deps = analyze_dotnet_licenses(path_str, config);
indicator
.update_progress(&format!("found {} dependencies", deps.len()));
deps
}
None => {
log(LogLevel::Error, "Failed to convert .NET path to string");
Vec::new()
}
}
}
None => {
log(LogLevel::Error, ".NET project file not found");
Vec::new()
}
},
Language::R(_) => match check_which_r_file_exists(project_path) {
Some(r_package_file) => {
let project_path = Path::new(project_path).join(&r_package_file);
log(
LogLevel::Info,
&format!("Parsing R project: {}", project_path.display()),
);
indicator.update_progress(&format!("analyzing {r_package_file}"));
match project_path.to_str() {
Some(path_str) => {
let deps = analyze_r_licenses(path_str, config);
indicator
.update_progress(&format!("found {} dependencies", deps.len()));
deps
}
None => {
log(LogLevel::Error, "Failed to convert R path to string");
Vec::new()
}
}
}
None => {
log(LogLevel::Error, "R package file not found");
Vec::new()
}
},
}
});
Ok(licenses)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matches_language() {
assert!(matches_language(Language::C(&C_PATHS), "c"));
assert!(matches_language(Language::C(&C_PATHS), "C"));
assert!(matches_language(Language::Cpp(&CPP_PATHS), "cpp"));
assert!(matches_language(Language::Cpp(&CPP_PATHS), "c++"));
assert!(matches_language(Language::Cpp(&CPP_PATHS), "CPP"));
assert!(matches_language(Language::Rust("Cargo.toml"), "rust"));
assert!(matches_language(Language::Rust("Cargo.toml"), "RUST"));
assert!(matches_language(Language::Rust("Cargo.toml"), "Rust"));
assert!(matches_language(Language::Node("package.json"), "node"));
assert!(matches_language(Language::Node("package.json"), "NODE"));
assert!(matches_language(Language::Node("package.json"), "Node"));
assert!(matches_language(Language::Go("go.mod"), "go"));
assert!(matches_language(Language::Go("go.mod"), "GO"));
assert!(matches_language(Language::Go("go.mod"), "Go"));
assert!(matches_language(Language::Python(&PYTHON_PATHS), "python"));
assert!(matches_language(Language::Python(&PYTHON_PATHS), "PYTHON"));
assert!(matches_language(Language::Python(&PYTHON_PATHS), "Python"));
assert!(!matches_language(Language::Rust("Cargo.toml"), "node"));
assert!(!matches_language(Language::Node("package.json"), "python"));
assert!(!matches_language(Language::Go("go.mod"), "rust"));
assert!(!matches_language(Language::Python(&PYTHON_PATHS), "go"));
assert!(!matches_language(Language::C(&C_PATHS), "cpp"));
assert!(!matches_language(Language::Cpp(&CPP_PATHS), "c"));
assert!(!matches_language(Language::Rust("Cargo.toml"), "java"));
assert!(!matches_language(Language::Node("package.json"), "java"));
}
#[test]
fn test_check_which_python_file_exists() {
let temp_dir = tempfile::TempDir::new().unwrap();
let result = check_which_python_file_exists(temp_dir.path());
assert_eq!(result, None);
std::fs::write(temp_dir.path().join("requirements.txt"), "requests==2.28.1").unwrap();
let result = check_which_python_file_exists(temp_dir.path());
assert_eq!(result, Some("requirements.txt".to_string()));
std::fs::write(
temp_dir.path().join("pyproject.toml"),
"[project]\nname = \"test\"",
)
.unwrap();
std::fs::write(temp_dir.path().join("Pipfile.lock"), "{}").unwrap();
let result = check_which_python_file_exists(temp_dir.path());
assert_eq!(result, Some("requirements.txt".to_string()));
}
#[test]
fn test_find_project_roots_empty_directory() {
let temp_dir = tempfile::TempDir::new().unwrap();
let result = find_project_roots(temp_dir.path().to_str().unwrap()).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_find_project_roots_single_project() {
let temp_dir = tempfile::TempDir::new().unwrap();
let root_path = temp_dir.path();
std::fs::write(root_path.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
let result = find_project_roots(root_path.to_str().unwrap()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].project_type, Language::Rust("Cargo.toml"));
assert_eq!(result[0].path, root_path);
}
#[test]
fn test_parse_root_with_language_filter() {
let temp_dir = tempfile::TempDir::new().unwrap();
let root_path = temp_dir.path();
std::fs::write(root_path.join("package.json"), r#"{"name": "test"}"#).unwrap();
std::fs::write(root_path.join("go.mod"), "module test").unwrap();
std::fs::write(root_path.join("requirements.txt"), "# No dependencies").unwrap();
let result = parse_root(root_path, Some("node"), false, false);
assert!(result.is_ok());
let result = parse_root(root_path, Some("go"), false, false);
assert!(result.is_ok());
let result = parse_root(root_path, Some("python"), false, false);
assert!(result.is_ok());
let result = parse_root(root_path, Some("java"), false, false);
assert!(result.is_ok());
let licenses = result.unwrap();
assert!(licenses.is_empty());
let result = parse_root(root_path, Some("NODE"), false, false);
assert!(result.is_ok());
let result = parse_root(root_path, Some("Python"), false, false);
assert!(result.is_ok());
}
#[test]
fn test_parse_root_no_projects() {
let temp_dir = tempfile::TempDir::new().unwrap();
let result = parse_root(temp_dir.path(), None, false, false).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_root_all_languages() {
let temp_dir = tempfile::TempDir::new().unwrap();
let root_path = temp_dir.path();
std::fs::write(
root_path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"",
)
.unwrap();
std::fs::write(
root_path.join("package.json"),
r#"{"name": "test", "version": "1.0.0"}"#,
)
.unwrap();
std::fs::write(root_path.join("go.mod"), "module test\n\ngo 1.19").unwrap();
std::fs::write(root_path.join("requirements.txt"), "# No dependencies").unwrap();
let result = parse_root(root_path, None, false, false);
assert!(result.is_ok());
}
#[test]
fn test_project_root_debug() {
let project_root = ProjectRoot {
path: std::path::PathBuf::from("/test/path"),
project_type: Language::Rust("Cargo.toml"),
};
let debug_str = format!("{project_root:?}");
assert!(debug_str.contains("/test/path"));
assert!(debug_str.contains("Rust"));
assert!(debug_str.contains("Cargo.toml"));
}
#[test]
fn test_find_project_roots_nested_projects() {
let temp_dir = tempfile::TempDir::new().unwrap();
let root_path = temp_dir.path();
let rust_dir = root_path.join("rust_project");
let node_dir = root_path.join("node_project").join("nested");
std::fs::create_dir_all(&rust_dir).unwrap();
std::fs::create_dir_all(&node_dir).unwrap();
std::fs::write(rust_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
std::fs::write(node_dir.join("package.json"), "{}").unwrap();
std::fs::write(root_path.join("go.mod"), "module test").unwrap();
let result = find_project_roots(root_path.to_str().unwrap()).unwrap();
assert_eq!(result.len(), 1);
let project_types: Vec<_> = result.iter().map(|r| r.project_type).collect();
assert!(project_types.contains(&Language::Go("go.mod")));
}
#[test]
fn test_parse_dependencies_error_handling() {
let temp_dir = tempfile::TempDir::new().unwrap();
let rust_project_root = ProjectRoot {
path: temp_dir.path().to_path_buf(),
project_type: Language::Rust("Cargo.toml"),
};
std::fs::write(
temp_dir.path().join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n[dependencies]\nserde = \"1.0\"",
)
.unwrap();
let config = crate::config::FeludaConfig::default();
let result = parse_dependencies(&rust_project_root, &config, false);
assert!(result.is_ok());
let licenses = result.unwrap();
assert!(licenses.is_empty());
}
#[test]
fn test_parse_dependencies_node_invalid_json() {
let temp_dir = tempfile::TempDir::new().unwrap();
let node_project_root = ProjectRoot {
path: temp_dir.path().to_path_buf(),
project_type: Language::Node("package.json"),
};
std::fs::write(temp_dir.path().join("package.json"), "invalid json content").unwrap();
let config = crate::config::FeludaConfig::default();
let result = parse_dependencies(&node_project_root, &config, false);
assert!(result.is_ok());
let licenses = result.unwrap();
assert!(licenses.is_empty());
}
#[test]
fn test_parse_dependencies_python_no_dependencies() {
let temp_dir = tempfile::TempDir::new().unwrap();
let python_project_root = ProjectRoot {
path: temp_dir.path().to_path_buf(),
project_type: Language::Python(&PYTHON_PATHS),
};
std::fs::write(temp_dir.path().join("requirements.txt"), "").unwrap();
let config = crate::config::FeludaConfig::default();
let result = parse_dependencies(&python_project_root, &config, false);
assert!(result.is_ok());
let licenses = result.unwrap();
assert!(licenses.is_empty());
}
#[test]
fn test_parse_root_invalid_path() {
let result = parse_root("/definitely/nonexistent/path", None, false, false);
assert!(result.is_ok());
let licenses = result.unwrap();
assert!(licenses.is_empty());
}
}