use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use toml::Value as TomlValue;
use crate::cache;
use crate::cli;
use crate::config;
use crate::debug::{log, log_debug, log_error, FeludaResult, LogLevel};
static GITHUB_TOKEN: OnceLock<Option<String>> = OnceLock::new();
pub fn set_github_token(token: Option<String>) {
let _ = GITHUB_TOKEN.set(token);
}
fn get_github_token() -> Option<&'static str> {
GITHUB_TOKEN.get().and_then(|t| t.as_deref())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LicenseCompatibility {
Compatible,
Incompatible,
Unknown,
}
impl std::fmt::Display for LicenseCompatibility {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Compatible => write!(f, "Compatible"),
Self::Incompatible => write!(f, "Incompatible"),
Self::Unknown => write!(f, "Unknown"),
}
}
}
#[derive(Deserialize, Debug, Clone)]
struct LicenseCompatibilityMatrix {
#[serde(rename = "MIT")]
mit: Option<LicenseEntry>,
#[serde(rename = "Apache-2_0")]
apache_2_0: Option<LicenseEntry>,
#[serde(rename = "GPL-3_0")]
gpl_3_0: Option<LicenseEntry>,
#[serde(rename = "GPL-2_0")]
gpl_2_0: Option<LicenseEntry>,
#[serde(rename = "AGPL-3_0")]
agpl_3_0: Option<LicenseEntry>,
#[serde(rename = "LGPL-3_0")]
lgpl_3_0: Option<LicenseEntry>,
#[serde(rename = "LGPL-2_1")]
lgpl_2_1: Option<LicenseEntry>,
#[serde(rename = "MPL-2_0")]
mpl_2_0: Option<LicenseEntry>,
#[serde(rename = "BSD-3-Clause")]
bsd_3_clause: Option<LicenseEntry>,
#[serde(rename = "BSD-2-Clause")]
bsd_2_clause: Option<LicenseEntry>,
#[serde(rename = "ISC")]
isc: Option<LicenseEntry>,
#[serde(rename = "_0BSD")]
bsd_0: Option<LicenseEntry>,
#[serde(rename = "Unlicense")]
unlicense: Option<LicenseEntry>,
#[serde(rename = "WTFPL")]
wtfpl: Option<LicenseEntry>,
}
#[derive(Deserialize, Debug, Clone)]
struct LicenseEntry {
compatible_with: Vec<String>,
}
#[cfg(not(test))]
static COMPATIBILITY_MATRIX: OnceLock<HashMap<String, Vec<String>>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OsiStatus {
Approved,
NotApproved,
Unknown,
}
impl std::fmt::Display for OsiStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Approved => write!(f, "approved"),
Self::NotApproved => write!(f, "not-approved"),
Self::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsiLicenseInfo {
pub id: String,
pub name: String,
pub status: OsiStatus,
}
#[derive(Serialize, Debug, Clone)]
pub struct LicenseInfo {
pub name: String, pub version: String, pub license: Option<String>, pub is_restrictive: bool, pub compatibility: LicenseCompatibility, pub osi_status: OsiStatus, }
impl LicenseInfo {
pub fn get_license(&self) -> String {
match &self.license {
Some(license_name) => String::from(license_name),
None => String::from("No License"),
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn version(&self) -> &str {
&self.version
}
pub fn is_restrictive(&self) -> &bool {
&self.is_restrictive
}
pub fn compatibility(&self) -> &LicenseCompatibility {
&self.compatibility
}
pub fn osi_status(&self) -> &OsiStatus {
&self.osi_status
}
#[allow(dead_code)]
pub fn osi_info(&self) -> Option<OsiLicenseInfo> {
self.license.as_ref().map(|license| OsiLicenseInfo {
id: license.clone(),
name: license.clone(),
status: self.osi_status,
})
}
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct License {
pub title: String, pub spdx_id: String, pub permissions: Vec<String>, pub conditions: Vec<String>, pub limitations: Vec<String>, }
pub fn fetch_licenses_from_github() -> FeludaResult<HashMap<String, License>> {
log(LogLevel::Info, "Fetching licenses from GitHub Licenses API");
match cache::load_github_licenses_from_cache() {
Ok(Some(cached_licenses)) => {
log(
LogLevel::Info,
&format!("Using cached licenses ({})", cached_licenses.len()),
);
return Ok(cached_licenses);
}
Ok(None) => {
log(LogLevel::Info, "Cache miss or stale, fetching from GitHub");
}
Err(e) => {
log(
LogLevel::Warn,
&format!("Cache read error: {e}, fetching from GitHub"),
);
}
}
let licenses_map = cli::with_spinner("Fetching licenses from GitHub API", |indicator| {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(err) => {
log_error("Failed to create tokio runtime", &err);
return HashMap::new();
}
};
rt.block_on(fetch_licenses_concurrent(indicator))
});
if !licenses_map.is_empty() {
if let Err(e) = cache::save_github_licenses_to_cache(&licenses_map) {
log(LogLevel::Warn, &format!("Failed to save cache: {e}"));
}
} else {
log(
LogLevel::Warn,
"No licenses fetched from GitHub API, cache not saved",
);
}
Ok(licenses_map)
}
async fn fetch_licenses_concurrent(
indicator: &crate::cli::LoadingIndicator,
) -> HashMap<String, License> {
let mut licenses_map = HashMap::new();
let mut client_builder = reqwest::Client::builder()
.user_agent("feluda-license-checker/1.0")
.timeout(Duration::from_secs(30));
if let Some(token) = get_github_token() {
log(
LogLevel::Info,
"Using authenticated GitHub API requests (higher rate limits)",
);
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {token}")
.parse()
.expect("Invalid token format"),
);
client_builder = client_builder.default_headers(headers);
}
let client = match client_builder.build() {
Ok(client) => client,
Err(err) => {
log_error("Failed to create HTTP client", &err);
return licenses_map;
}
};
indicator.update_progress("fetching license list");
let licenses_list_url = "https://api.github.com/licenses";
let response = match client.get(licenses_list_url).send().await {
Ok(response) => response,
Err(err) => {
log_error("Failed to fetch licenses list from GitHub API", &err);
return licenses_map;
}
};
if !response.status().is_success() {
log(
LogLevel::Error,
&format!("GitHub API returned error status: {}", response.status()),
);
return licenses_map;
}
let licenses_list: Vec<serde_json::Value> = match response.json().await {
Ok(list) => list,
Err(err) => {
log_error("Failed to parse licenses list JSON", &err);
return licenses_map;
}
};
let total_licenses = licenses_list.len();
indicator.update_progress(&format!("found {total_licenses} licenses"));
let client = Arc::new(client);
let license_keys: Vec<String> = licenses_list
.iter()
.filter_map(|license_info| {
license_info
.get("key")
.and_then(|k| k.as_str())
.map(|s| s.to_string())
})
.collect();
let mut join_set = tokio::task::JoinSet::new();
for license_key in license_keys {
let client = Arc::clone(&client);
join_set.spawn(async move {
log(
LogLevel::Info,
&format!("Fetching detailed license info: {license_key}"),
);
let license_url = format!("https://api.github.com/licenses/{license_key}");
match client.get(&license_url).send().await {
Ok(license_response) => {
if license_response.status().is_success() {
match license_response.json::<serde_json::Value>().await {
Ok(license_data) => {
let title = license_data
.get("name")
.and_then(|n| n.as_str())
.unwrap_or(&license_key)
.to_string();
let spdx_id = license_data
.get("spdx_id")
.and_then(|s| s.as_str())
.unwrap_or(&license_key)
.to_string();
let permissions = license_data
.get("permissions")
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect()
})
.unwrap_or_default();
let conditions = license_data
.get("conditions")
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect()
})
.unwrap_or_default();
let limitations = license_data
.get("limitations")
.and_then(|l| l.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect()
})
.unwrap_or_default();
let license = License {
title,
spdx_id,
permissions,
conditions,
limitations,
};
let key_to_use = license_data
.get("spdx_id")
.and_then(|s| s.as_str())
.unwrap_or(&license_key);
log(
LogLevel::Info,
&format!("Successfully processed license: {key_to_use}"),
);
Some((key_to_use.to_string(), license))
}
Err(err) => {
log_error(
&format!("Failed to parse license JSON for {license_key}"),
&err,
);
None
}
}
} else {
log(
LogLevel::Error,
&format!(
"Failed to fetch license {}: HTTP {}",
license_key,
license_response.status()
),
);
None
}
}
Err(err) => {
log_error(
&format!("Failed to fetch license details for {license_key}"),
&err,
);
None
}
}
});
}
let mut license_count = 0;
while let Some(result) = join_set.join_next().await {
match result {
Ok(Some((key, license))) => {
licenses_map.insert(key, license);
license_count += 1;
}
Ok(None) => {}
Err(e) => log(
LogLevel::Error,
&format!("License fetch task panicked: {e}"),
),
}
indicator.update_progress(&format!("fetched {license_count}/{total_licenses}"));
}
log(
LogLevel::Info,
&format!("Fetched {license_count} licenses from GitHub API"),
);
licenses_map
}
#[cfg(not(test))]
static OSI_LICENSES: OnceLock<HashMap<String, OsiStatus>> = OnceLock::new();
pub fn fetch_osi_licenses() -> FeludaResult<HashMap<String, OsiStatus>> {
log(LogLevel::Info, "Fetching OSI approved licenses");
let osi_map = cli::with_spinner("Fetching OSI approved licenses", |indicator| {
let client = match reqwest::blocking::Client::builder()
.user_agent("feluda-license-checker/1.0")
.timeout(Duration::from_secs(30))
.build()
{
Ok(client) => client,
Err(err) => {
log_error("Failed to create HTTP client", &err);
return HashMap::new();
}
};
indicator.update_progress("fetching OSI licenses");
let response = match client.get("https://api.opensource.org/licenses/").send() {
Ok(response) => response,
Err(err) => {
log_error("Failed to fetch OSI licenses from API", &err);
return HashMap::new();
}
};
if !response.status().is_success() {
log(
LogLevel::Error,
&format!("OSI API returned error status: {}", response.status()),
);
return HashMap::new();
}
let osi_licenses: Vec<serde_json::Value> = match response.json() {
Ok(licenses) => licenses,
Err(err) => {
log_error("Failed to parse OSI licenses JSON", &err);
return HashMap::new();
}
};
let total_licenses = osi_licenses.len();
indicator.update_progress(&format!("found {total_licenses} OSI licenses"));
let mut osi_map = HashMap::new();
for license_data in osi_licenses {
if let Some(id) = license_data.get("id").and_then(|id| id.as_str()) {
osi_map.insert(id.to_string(), OsiStatus::Approved);
}
}
indicator.update_progress(&format!("processed {total_licenses} OSI licenses"));
log(
LogLevel::Info,
&format!("Fetched {total_licenses} OSI approved licenses"),
);
osi_map
});
Ok(osi_map)
}
fn get_osi_licenses() -> &'static HashMap<String, OsiStatus> {
#[cfg(not(test))]
{
OSI_LICENSES.get_or_init(|| {
fetch_osi_licenses().unwrap_or_else(|e| {
log(LogLevel::Warn, &format!("Failed to load OSI licenses: {e}"));
log(LogLevel::Warn, "Continuing without OSI license information");
HashMap::new()
})
})
}
#[cfg(test)]
{
use std::cell::RefCell;
thread_local! {
static OSI_MAP: RefCell<Option<HashMap<String, OsiStatus>>> = const { RefCell::new(None) };
}
OSI_MAP.with(|m| {
let mut map = m.borrow_mut();
if map.is_none() {
match fetch_osi_licenses() {
Ok(loaded_map) => {
*map = Some(loaded_map);
}
Err(_) => {
*map = Some(HashMap::new());
}
}
}
let leaked: &'static HashMap<String, OsiStatus> =
Box::leak(Box::new(map.as_ref().unwrap().clone()));
leaked
})
}
}
pub fn get_osi_status(license_id: &str) -> OsiStatus {
let normalized_id = normalize_license_id(license_id);
let osi_licenses = get_osi_licenses();
if let Some(status) = osi_licenses.get(&normalized_id) {
return *status;
}
if let Some(status) = osi_licenses.get(license_id) {
return *status;
}
match normalized_id.as_str() {
"MIT" | "Apache-2.0" | "BSD-3-Clause" | "BSD-2-Clause" | "GPL-3.0" | "GPL-2.0"
| "LGPL-3.0" | "LGPL-2.1" | "MPL-2.0" | "ISC" | "0BSD" => OsiStatus::Approved,
"No License" => OsiStatus::NotApproved,
_ => OsiStatus::Unknown,
}
}
pub fn is_license_restrictive(
license: &Option<String>,
known_licenses: &HashMap<String, License>,
strict: bool,
) -> bool {
log(
LogLevel::Info,
&format!("Checking if license is restrictive: {license:?} (strict={strict})"),
);
let config = match config::load_config() {
Ok(cfg) => {
log(LogLevel::Info, "Successfully loaded configuration");
cfg
}
Err(e) => {
log_error("Error loading configuration", &e);
log(LogLevel::Warn, "Using default configuration");
config::FeludaConfig::default()
}
};
if license.as_deref() == Some("No License") {
log(
LogLevel::Warn,
"No license specified, considering as restrictive",
);
return true;
}
if let Some(license_str) = license {
log_debug(
"Checking against known licenses",
&known_licenses.keys().collect::<Vec<_>>(),
);
if let Some(license_data) = known_licenses.get(license_str) {
log_debug("Found license data", license_data);
let conditions = if strict {
vec![
"source-disclosure",
"network-use-disclosure",
"disclose-source",
"same-license",
]
} else {
vec!["source-disclosure", "network-use-disclosure"]
};
let is_restrictive = conditions
.iter()
.any(|&condition| license_data.conditions.contains(&condition.to_string()));
if is_restrictive {
log(
LogLevel::Warn,
&format!("License {license_str} is restrictive due to conditions"),
);
} else {
log(
LogLevel::Info,
&format!("License {license_str} is not restrictive"),
);
}
return is_restrictive;
} else {
let is_restrictive = config
.licenses
.restrictive
.iter()
.any(|restrictive_license| license_str.contains(restrictive_license));
if is_restrictive {
log(
LogLevel::Warn,
&format!("License {license_str} matches restrictive pattern in config"),
);
} else if strict && license_str.contains("Unknown") {
log(
LogLevel::Warn,
&format!(
"License {license_str} is unknown in strict mode, considering restrictive"
),
);
return true;
} else {
log(
LogLevel::Info,
&format!("License {license_str} does not match any restrictive pattern"),
);
}
return is_restrictive;
}
}
if strict {
log(
LogLevel::Warn,
"No license information available in strict mode, considering restrictive",
);
return true;
}
log(LogLevel::Warn, "No license information available");
false
}
pub fn is_license_ignored(license: Option<&str>) -> bool {
log(
LogLevel::Info,
&format!("Checking if license should be ignored: {license:?}"),
);
let config = match config::load_config() {
Ok(cfg) => {
log(LogLevel::Info, "Successfully loaded configuration");
cfg
}
Err(e) => {
log_error("Error loading configuration", &e);
log(LogLevel::Warn, "Using default configuration");
config::FeludaConfig::default()
}
};
if let Some(license_str) = license {
let is_ignored = config
.licenses
.ignore
.iter()
.any(|ignore_license| license_str.contains(ignore_license));
if is_ignored {
log(
LogLevel::Info,
&format!("License {license_str} matches ignore pattern in config"),
);
} else {
log(
LogLevel::Info,
&format!("License {license_str} does not match any ignore pattern"),
);
}
return is_ignored;
}
log(LogLevel::Info, "No license specified, not ignoring");
false
}
const EMBEDDED_LICENSE_COMPATIBILITY_TOML: &str =
include_str!("../config/license_compatibility.toml");
fn load_compatibility_matrix() -> FeludaResult<HashMap<String, Vec<String>>> {
log(
LogLevel::Info,
"Loading license compatibility matrix from TOML file",
);
let config_paths = vec![Path::new(".feluda/license_compatibility.toml").to_path_buf()];
let mut config_content = None;
let mut used_path = None;
for path in &config_paths {
if path.exists() {
log(
LogLevel::Info,
&format!("Found license compatibility config at: {}", path.display()),
);
match fs::read_to_string(path) {
Ok(content) => {
config_content = Some(content);
used_path = Some(path);
break;
}
Err(e) => {
log(
LogLevel::Warn,
&format!("Failed to read {}: {}", path.display(), e),
);
continue;
}
}
}
}
let config_content = match config_content {
Some(content) => content,
None => {
log(
LogLevel::Info,
"No external license compatibility config found, using embedded configuration",
);
EMBEDDED_LICENSE_COMPATIBILITY_TOML.to_string()
}
};
let matrix: LicenseCompatibilityMatrix = toml::from_str(&config_content).map_err(|e| {
let source = match &used_path {
Some(path) => format!("external config file ({})", path.display()),
None => "embedded configuration".to_string(),
};
log(
LogLevel::Error,
&format!("Failed to parse license compatibility {source}: {e}"),
);
std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
})?;
let entries = [
("MIT", &matrix.mit),
("Apache-2.0", &matrix.apache_2_0),
("GPL-3.0", &matrix.gpl_3_0),
("GPL-2.0", &matrix.gpl_2_0),
("AGPL-3.0", &matrix.agpl_3_0),
("LGPL-3.0", &matrix.lgpl_3_0),
("LGPL-2.1", &matrix.lgpl_2_1),
("MPL-2.0", &matrix.mpl_2_0),
("BSD-3-Clause", &matrix.bsd_3_clause),
("BSD-2-Clause", &matrix.bsd_2_clause),
("ISC", &matrix.isc),
("0BSD", &matrix.bsd_0),
("Unlicense", &matrix.unlicense),
("WTFPL", &matrix.wtfpl),
];
let result: HashMap<String, Vec<String>> = entries
.iter()
.filter_map(|(key, option_entry)| {
option_entry
.as_ref()
.map(|entry| (key.to_string(), entry.compatible_with.clone()))
})
.collect();
log(
LogLevel::Info,
&format!("Loaded {} license compatibility entries", result.len()),
);
Ok(result)
}
fn get_compatibility_matrix() -> &'static HashMap<String, Vec<String>> {
#[cfg(not(test))]
{
COMPATIBILITY_MATRIX.get_or_init(|| {
load_compatibility_matrix().unwrap_or_else(|e| {
log(LogLevel::Error, &format!("Failed to load license compatibility matrix: {e}"));
log(LogLevel::Error, "This is a critical error. The application cannot function without license compatibility data.");
std::process::exit(1);
})
})
}
#[cfg(test)]
{
use std::cell::RefCell;
thread_local! {
static MATRIX: RefCell<Option<HashMap<String, Vec<String>>>> = const { RefCell::new(None) };
}
MATRIX.with(|m| {
let mut matrix = m.borrow_mut();
if matrix.is_none() {
match load_compatibility_matrix() {
Ok(loaded_matrix) => {
*matrix = Some(loaded_matrix);
}
Err(e) => {
panic!(
"License compatibility configuration file not found during testing: {e}"
);
}
}
}
let leaked: &'static HashMap<String, Vec<String>> =
Box::leak(Box::new(matrix.as_ref().unwrap().clone()));
leaked
})
}
}
pub fn is_license_compatible(
dependency_license: &str,
project_license: &str,
strict: bool,
) -> LicenseCompatibility {
log(
LogLevel::Info,
&format!(
"Checking if dependency license {dependency_license} is compatible with project license {project_license} (strict={strict})"
),
);
let compatibility_matrix = get_compatibility_matrix();
let norm_dependency_license = normalize_license_id(dependency_license);
let norm_project_license = normalize_license_id(project_license);
log(
LogLevel::Info,
&format!(
"Normalized licenses: dependency={norm_dependency_license}, project={norm_project_license}"
),
);
match compatibility_matrix.get(&norm_project_license) {
Some(compatible_licenses) => {
if compatible_licenses.contains(&norm_dependency_license) {
log(
LogLevel::Info,
&format!(
"License {norm_dependency_license} is compatible with project license {norm_project_license}"
),
);
LicenseCompatibility::Compatible
} else {
log(
LogLevel::Warn,
&format!(
"License {norm_dependency_license} may be incompatible with project license {norm_project_license}"
),
);
LicenseCompatibility::Incompatible
}
}
None => {
if strict {
log(
LogLevel::Warn,
&format!("Unknown compatibility for project license {norm_project_license} in strict mode, marking as incompatible"),
);
LicenseCompatibility::Incompatible
} else {
log(
LogLevel::Warn,
&format!("Unknown compatibility for project license {norm_project_license}"),
);
LicenseCompatibility::Unknown
}
}
}
}
fn normalize_license_id(license_id: &str) -> String {
let trimmed = license_id.trim().to_uppercase();
match trimmed.as_str() {
"MIT" | "MIT LICENSE" => "MIT".to_string(),
"ISC" | "ISC LICENSE" => "ISC".to_string(),
"0BSD" | "BSD-ZERO-CLAUSE" | "BSD ZERO CLAUSE" => "0BSD".to_string(),
"UNLICENSE" | "THE UNLICENSE" => "Unlicense".to_string(),
"WTFPL" | "DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE" => "WTFPL".to_string(),
"ZLIB" | "ZLIB LICENSE" => "Zlib".to_string(),
"CC0" | "CC0-1.0" | "CC0 1.0" | "CREATIVE COMMONS ZERO" => "CC0-1.0".to_string(),
id if id.contains("APACHE") && (id.contains("2.0") || id.contains("2")) => {
"Apache-2.0".to_string()
}
id if id.contains("AGPL") && id.contains("3") => "AGPL-3.0".to_string(),
id if id.contains("AFFERO") && id.contains("GPL") && id.contains("3") => {
"AGPL-3.0".to_string()
}
id if id.contains("GPL") && id.contains("3") && !id.contains("LGPL") => {
"GPL-3.0".to_string()
}
id if id.contains("GPL") && id.contains("2") && !id.contains("LGPL") => {
"GPL-2.0".to_string()
}
id if id.contains("LGPL") && id.contains("3") => "LGPL-3.0".to_string(),
id if id.contains("LGPL") && id.contains("2.1") => "LGPL-2.1".to_string(),
id if id.contains("LGPL") && id.contains("2") && !id.contains("2.1") => {
"LGPL-2.1".to_string()
}
id if id.contains("MPL") && id.contains("2.0") => "MPL-2.0".to_string(),
id if id.contains("BSD") && (id.contains("3") || id.contains("THREE")) => {
"BSD-3-Clause".to_string()
}
id if id.contains("BSD") && (id.contains("2") || id.contains("TWO")) => {
"BSD-2-Clause".to_string()
}
_ => license_id.to_string(),
}
}
pub fn detect_project_license(project_path: &str) -> FeludaResult<Option<String>> {
log(
LogLevel::Info,
&format!("Detecting license for project at path: {project_path}"),
);
let license_paths = [
Path::new(project_path).join("LICENSE"),
Path::new(project_path).join("LICENSE.txt"),
Path::new(project_path).join("LICENSE.md"),
Path::new(project_path).join("license"),
Path::new(project_path).join("COPYING"),
];
for license_path in &license_paths {
if license_path.exists() {
log(
LogLevel::Info,
&format!("Found license file: {}", license_path.display()),
);
match fs::read_to_string(license_path) {
Ok(content) => {
if content.contains("MIT License")
|| content.contains("Permission is hereby granted, free of charge")
{
log(LogLevel::Info, "Detected MIT license");
return Ok(Some("MIT".to_string()));
}
if content.contains("GNU GENERAL PUBLIC LICENSE")
&& content.contains("Version 3")
{
log(LogLevel::Info, "Detected GPL-3.0 license");
return Ok(Some("GPL-3.0".to_string()));
}
if content.contains("Apache License") && content.contains("Version 2.0") {
log(LogLevel::Info, "Detected Apache-2.0 license");
return Ok(Some("Apache-2.0".to_string()));
}
if content.contains("BSD")
&& content.contains("Redistribution and use")
&& content.contains("Neither the name")
{
log(LogLevel::Info, "Detected BSD-3-Clause license");
return Ok(Some("BSD-3-Clause".to_string()));
}
if content.contains("GNU LESSER GENERAL PUBLIC LICENSE")
&& content.contains("Version 3")
{
log(LogLevel::Info, "Detected LGPL-3.0 license");
return Ok(Some("LGPL-3.0".to_string()));
}
if content.contains("Mozilla Public License") && content.contains("Version 2.0")
{
log(LogLevel::Info, "Detected MPL-2.0 license");
return Ok(Some("MPL-2.0".to_string()));
}
log(
LogLevel::Warn,
"License file found but could not determine license type",
);
}
Err(err) => {
log(
LogLevel::Error,
&format!("Failed to read license file: {}", license_path.display()),
);
log_debug("Error details", &err);
}
}
}
}
let package_json_path = Path::new(project_path).join("package.json");
if package_json_path.exists() {
log(
LogLevel::Info,
&format!("Found package.json at {}", package_json_path.display()),
);
match fs::read_to_string(&package_json_path) {
Ok(content) => match serde_json::from_str::<Value>(&content) {
Ok(json) => {
if let Some(license) = json.get("license").and_then(|l| l.as_str()) {
log(
LogLevel::Info,
&format!("Detected license from package.json: {license}"),
);
return Ok(Some(license.to_string()));
}
}
Err(err) => {
log(
LogLevel::Error,
&format!("Failed to parse package.json: {err}"),
);
}
},
Err(err) => {
log(
LogLevel::Error,
&format!(
"Failed to read package.json: {}",
package_json_path.display()
),
);
log_debug("Error details", &err);
}
}
}
let cargo_toml_path = Path::new(project_path).join("Cargo.toml");
if cargo_toml_path.exists() {
log(
LogLevel::Info,
&format!("Found Cargo.toml at {}", cargo_toml_path.display()),
);
match fs::read_to_string(&cargo_toml_path) {
Ok(content) => match toml::from_str::<TomlValue>(&content) {
Ok(toml) => {
if let Some(package) = toml.as_table().and_then(|t| t.get("package")) {
if let Some(license) = package.get("license").and_then(|l| l.as_str()) {
log(
LogLevel::Info,
&format!("Detected license from Cargo.toml: {license}"),
);
return Ok(Some(license.to_string()));
}
}
}
Err(err) => {
log(
LogLevel::Error,
&format!("Failed to parse Cargo.toml: {err}"),
);
}
},
Err(err) => {
log(
LogLevel::Error,
&format!("Failed to read Cargo.toml: {}", cargo_toml_path.display()),
);
log_debug("Error details", &err);
}
}
}
let pyproject_toml_path = Path::new(project_path).join("pyproject.toml");
if pyproject_toml_path.exists() {
log(
LogLevel::Info,
&format!("Found pyproject.toml at {}", pyproject_toml_path.display()),
);
match fs::read_to_string(&pyproject_toml_path) {
Ok(content) => match toml::from_str::<TomlValue>(&content) {
Ok(toml) => {
if let Some(project) = toml.as_table().and_then(|t| t.get("project")) {
if let Some(license_info) = project.get("license") {
if let Some(license) = license_info.as_str() {
log(
LogLevel::Info,
&format!("Detected license from pyproject.toml: {license}"),
);
return Ok(Some(license.to_string()));
} else if let Some(license_table) = license_info.as_table() {
if let Some(license_text) =
license_table.get("text").and_then(|t| t.as_str())
{
log(
LogLevel::Info,
&format!(
"Detected license from pyproject.toml: {license_text}"
),
);
return Ok(Some(license_text.to_string()));
}
}
}
}
}
Err(err) => {
log(
LogLevel::Error,
&format!("Failed to parse pyproject.toml: {err}"),
);
}
},
Err(err) => {
log(
LogLevel::Error,
&format!(
"Failed to read pyproject.toml: {}",
pyproject_toml_path.display()
),
);
log_debug("Error details", &err);
}
}
}
log(LogLevel::Warn, "No license detected for project");
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_license_compatibility_display() {
assert_eq!(LicenseCompatibility::Compatible.to_string(), "Compatible");
assert_eq!(
LicenseCompatibility::Incompatible.to_string(),
"Incompatible"
);
assert_eq!(LicenseCompatibility::Unknown.to_string(), "Unknown");
}
#[test]
fn test_license_info_methods() {
let info = LicenseInfo {
name: "test_package".to_string(),
version: "1.0.0".to_string(),
license: Some("MIT".to_string()),
is_restrictive: false,
compatibility: LicenseCompatibility::Compatible,
osi_status: OsiStatus::Approved,
};
assert_eq!(info.name(), "test_package");
assert_eq!(info.version(), "1.0.0");
assert_eq!(info.get_license(), "MIT");
assert!(!info.is_restrictive());
assert_eq!(info.compatibility(), &LicenseCompatibility::Compatible);
}
#[test]
fn test_license_info_no_license() {
let info = LicenseInfo {
name: "test_package".to_string(),
version: "1.0.0".to_string(),
license: None,
is_restrictive: true,
compatibility: LicenseCompatibility::Unknown,
osi_status: OsiStatus::Unknown,
};
assert_eq!(info.get_license(), "No License");
}
#[test]
fn test_normalize_license_id() {
assert_eq!(normalize_license_id("MIT"), "MIT");
assert_eq!(normalize_license_id("mit"), "MIT");
assert_eq!(normalize_license_id("Apache 2.0"), "Apache-2.0");
assert_eq!(normalize_license_id("APACHE-2.0"), "Apache-2.0");
assert_eq!(normalize_license_id("GPL 3.0"), "GPL-3.0");
assert_eq!(normalize_license_id("gpl-3.0"), "GPL-3.0");
assert_eq!(normalize_license_id("LGPL 3.0"), "LGPL-3.0");
assert_eq!(normalize_license_id("MPL 2.0"), "MPL-2.0");
assert_eq!(normalize_license_id("BSD 3-Clause"), "BSD-3-Clause");
assert_eq!(normalize_license_id("BSD 2-Clause"), "BSD-2-Clause");
assert_eq!(normalize_license_id("Unknown License"), "Unknown License");
assert_eq!(normalize_license_id(" MIT "), "MIT");
}
#[test]
#[ignore] fn test_is_license_compatible_mit_project() {
assert_eq!(
is_license_compatible("MIT", "MIT", false),
LicenseCompatibility::Compatible
);
assert_eq!(
is_license_compatible("BSD-2-Clause", "MIT", false),
LicenseCompatibility::Compatible
);
assert_eq!(
is_license_compatible("BSD-3-Clause", "MIT", false),
LicenseCompatibility::Compatible
);
assert_eq!(
is_license_compatible("Apache-2.0", "MIT", false),
LicenseCompatibility::Compatible
);
assert_eq!(
is_license_compatible("LGPL-3.0", "MIT", false),
LicenseCompatibility::Incompatible
);
assert_eq!(
is_license_compatible("MPL-2.0", "MIT", false),
LicenseCompatibility::Incompatible
);
assert_eq!(
is_license_compatible("GPL-3.0", "MIT", false),
LicenseCompatibility::Incompatible
);
}
#[test]
fn test_detect_project_license_mit_file() {
let temp_dir = TempDir::new().unwrap();
let license_path = temp_dir.path().join("LICENSE");
std::fs::write(
&license_path,
"MIT License\n\nPermission is hereby granted, free of charge...",
)
.unwrap();
let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap();
assert_eq!(result, Some("MIT".to_string()));
}
#[test]
fn test_detect_project_license_no_license() {
let temp_dir = TempDir::new().unwrap();
let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_is_license_ignored_with_no_license() {
assert!(!is_license_ignored(None));
}
#[test]
fn test_is_license_ignored_not_in_ignore_list() {
let result = is_license_ignored(Some("GPL-3.0"));
let _ = result;
}
#[test]
fn test_is_license_ignored_empty_license() {
assert!(!is_license_ignored(Some("")));
}
}