use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const SPDX_ID: &str = "Apache-2.0";
pub const COPYRIGHT: &str = "Copyright 2026 kodephp contributors";
pub const LICENSE_URL: &str = "https://www.apache.org/licenses/LICENSE-2.0";
pub const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
pub const THIRD_PARTY_INVENTORY: &str = include_str!("../THIRD_PARTY_LICENSES.md");
pub const APACHE_CLAUSES: &[(&str, &str)] = &[
(
"permissions",
"commercial use, modification, distribution, patent use, private use",
),
("conditions", "license and copyright notice, state changes"),
("limitations", "liability, trademark use, warranty"),
];
const RESOURCE_DIRS: &[&str] = &[
concat!(env!("CARGO_MANIFEST_DIR"), "/../.."),
"/usr/local/share/ntfs-mac",
"/opt/homebrew/share/ntfs-mac",
"/Applications/ntfs-mac.app/Contents/Resources",
".",
];
pub fn resource_path(file: &str) -> Option<PathBuf> {
RESOURCE_DIRS
.iter()
.map(|dir| Path::new(dir).join(file))
.find(|candidate| candidate.is_file())
}
pub fn license_file() -> Option<PathBuf> {
resource_path("LICENSE")
}
pub fn notice_file() -> Option<PathBuf> {
resource_path("NOTICE")
}
pub fn display_path(path: &Path) -> String {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out.display().to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThirdPartyCounts {
pub total: usize,
pub cli: usize,
pub gui: usize,
}
pub fn third_party_counts() -> Option<ThirdPartyCounts> {
let line = THIRD_PARTY_INVENTORY
.lines()
.find(|line| line.starts_with("- Crates:"))?;
let mut numbers = line
.split(|c: char| !c.is_ascii_digit())
.filter(|part| !part.is_empty())
.filter_map(|part| part.parse::<usize>().ok());
Some(ThirdPartyCounts {
total: numbers.next()?,
cli: numbers.next()?,
gui: numbers.next()?,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseSummary {
pub project: String,
pub version: String,
pub spdx: String,
pub copyright: String,
pub repository: String,
pub license_url: String,
pub license_file: Option<String>,
pub notice_file: Option<String>,
pub third_party: Option<ThirdPartyCounts>,
}
pub fn summary() -> LicenseSummary {
LicenseSummary {
project: crate::NAME.to_string(),
version: crate::VERSION.to_string(),
spdx: SPDX_ID.to_string(),
copyright: COPYRIGHT.to_string(),
repository: REPOSITORY.to_string(),
license_url: LICENSE_URL.to_string(),
license_file: license_file().map(|p| display_path(&p)),
notice_file: notice_file().map(|p| display_path(&p)),
third_party: third_party_counts(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spdx_id_matches_the_cargo_manifest() {
assert_eq!(SPDX_ID, env!("CARGO_PKG_LICENSE"));
}
#[test]
fn inventory_is_embedded() {
assert!(!THIRD_PARTY_INVENTORY.is_empty());
assert!(THIRD_PARTY_INVENTORY.contains("Apache License, Version 2.0"));
assert!(THIRD_PARTY_INVENTORY.contains("# Third-party licenses"));
}
#[test]
fn third_party_counts_parse() {
let counts = third_party_counts().expect("inventory should carry a `- Crates:` line");
assert!(counts.total > 0, "total should be non-zero: {counts:?}");
assert!(counts.cli > 0, "cli should be non-zero: {counts:?}");
assert!(counts.gui > 0, "gui should be non-zero: {counts:?}");
assert!(counts.total >= counts.cli, "{counts:?}");
assert!(counts.total >= counts.gui, "{counts:?}");
}
#[test]
fn resource_lookup_resolves_the_source_tree() {
let found = resource_path("Cargo.toml").expect("repo root Cargo.toml should be found");
assert_eq!(found.file_name().unwrap(), "Cargo.toml");
}
#[test]
fn license_and_notice_are_findable_from_the_source_tree() {
let license = license_file().expect("LICENSE should be locatable from the source tree");
assert_eq!(license.file_name().unwrap(), "LICENSE");
let notice = notice_file().expect("NOTICE should be locatable from the source tree");
assert_eq!(notice.file_name().unwrap(), "NOTICE");
}
#[test]
fn summary_is_self_consistent() {
let summary = summary();
assert_eq!(summary.project, crate::NAME);
assert_eq!(summary.version, crate::VERSION);
assert_eq!(summary.spdx, SPDX_ID);
assert_eq!(summary.repository, REPOSITORY);
assert!(summary.license_file.is_some());
assert!(summary.third_party.is_some());
}
#[test]
fn apache_clauses_cover_the_three_groups() {
let kinds: Vec<&str> = APACHE_CLAUSES.iter().map(|(kind, _)| *kind).collect();
assert_eq!(kinds, vec!["permissions", "conditions", "limitations"]);
}
#[test]
fn display_path_collapses_dot_segments() {
assert_eq!(display_path(Path::new("/a/b/../c")), "/a/c");
assert_eq!(display_path(Path::new("/a/./b/../../c")), "/c");
assert_eq!(display_path(Path::new("/a/b/")), "/a/b");
assert_eq!(display_path(Path::new("relative/./x")), "relative/x");
}
#[test]
fn summary_paths_are_normalized() {
let summary = summary();
let license = summary.license_file.expect("LICENSE should resolve");
assert!(
!license.contains("/../"),
"path should be normalized, got {license}"
);
assert!(license.ends_with("/LICENSE"), "got {license}");
}
}