use crate::config::Extra;
use crate::harvest::{Extras, display_author};
use crate::policy::canonical_text;
use cargo_metadata::semver::Version;
use cargo_metadata::{Package, PackageId};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
pub fn io<T>(path: &Path, r: std::io::Result<T>) -> Result<T, String> {
r.map_err(|e| format!("{}: {e}", path.display()))
}
pub fn is_stale_license(path: &Path, texts: &BTreeMap<&str, String>) -> bool {
path.extension().is_some_and(|x| x == "txt")
&& path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| (canonical_text(s).is_some() || s.starts_with("LicenseRef-")) && !texts.contains_key(s))
}
pub fn is_stale_notice(path: &Path, notices: &BTreeMap<String, String>) -> bool {
path.extension().is_some_and(|x| x == "txt")
&& path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|stem| notice_stem(stem) && !notices.contains_key(stem))
}
fn notice_stem(stem: &str) -> bool {
stem.match_indices('-').any(|(i, _)| Version::parse(&stem[i + 1..]).is_ok())
}
pub struct Resolution<'a> {
pub deps: &'a BTreeSet<&'a PackageId>,
pub pkg_of: &'a BTreeMap<&'a PackageId, &'a Package>,
pub effective: &'a BTreeMap<&'a PackageId, &'a str>,
pub chosen_of: &'a BTreeMap<&'a PackageId, BTreeSet<String>>,
pub by_license: &'a BTreeMap<String, Vec<&'a Package>>,
pub extra_by_license: &'a BTreeMap<String, Vec<&'a Extra>>,
pub extra_chosen: &'a [(&'a Extra, BTreeSet<String>)],
pub used_exceptions: &'a BTreeSet<String>,
pub extras: &'a BTreeMap<&'a PackageId, Extras>,
}
#[derive(Serialize)]
struct Report<'a> {
licenses: Vec<&'a str>, exceptions: Vec<&'a str>, crates: Vec<CrateEntry<'a>>,
extras: Vec<ExtraEntry<'a>>, }
#[derive(Serialize)]
struct CrateEntry<'a> {
name: &'a str,
version: String,
expression: &'a str, licenses: Vec<&'a str>, authors: &'a [String], copyrights: &'a [String], notice: Option<&'a str>, }
#[derive(Serialize)]
struct ExtraEntry<'a> {
name: &'a str,
expression: &'a str,
licenses: Vec<&'a str>, url: Option<&'a str>,
copyright: Option<&'a str>,
}
pub fn render_json(r: &Resolution) -> Result<String, String> {
let crates: Vec<CrateEntry> = r
.deps
.iter()
.filter_map(|id| {
let pkg = r.pkg_of.get(id).copied()?;
let chosen = r.chosen_of.get(*id)?;
let x = r.extras.get(*id);
Some(CrateEntry {
name: pkg.name.as_ref(),
version: pkg.version.to_string(),
expression: r.effective.get(*id).copied().unwrap_or(""),
licenses: chosen.iter().map(String::as_str).collect(),
authors: x.map(|x| x.authors.as_slice()).unwrap_or(&[]),
copyrights: x.map(|x| x.copyrights.as_slice()).unwrap_or(&[]),
notice: x.and_then(|x| x.notice.as_deref()),
})
})
.collect();
let report = Report {
licenses: r
.by_license
.keys()
.chain(r.extra_by_license.keys())
.map(String::as_str)
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
exceptions: r.used_exceptions.iter().map(String::as_str).collect(),
crates,
extras: r
.extra_chosen
.iter()
.map(|(x, chosen)| ExtraEntry {
name: &x.name,
expression: &x.expression,
licenses: chosen.iter().map(String::as_str).collect(),
url: x.url.as_deref(),
copyright: x.copyright.as_deref(),
})
.collect(),
};
serde_json::to_string_pretty(&report).map_err(|e| e.to_string())
}
fn matches_output(disk: Option<String>, want: &str) -> bool {
disk.is_some_and(|d| d.replace("\r\n", "\n") == want)
}
pub fn stale_outputs(
licenses_dir: &Path,
texts: &BTreeMap<&str, String>,
notices_dir: &Path,
notices: &BTreeMap<String, String>,
manifest_path: &Path,
manifest: &str,
) -> Vec<String> {
let mut stale = Vec::new();
for (id, want) in texts {
let path = licenses_dir.join(format!("{id}.txt"));
if !matches_output(fs::read_to_string(&path).ok(), want) {
stale.push(path.display().to_string());
}
}
if let Ok(entries) = fs::read_dir(licenses_dir) {
for e in entries.flatten() {
let p = e.path();
if is_stale_license(&p, texts) {
stale.push(p.display().to_string());
}
}
}
for (stem, want) in notices {
let path = notices_dir.join(format!("{stem}.txt"));
if !matches_output(fs::read_to_string(&path).ok(), want) {
stale.push(path.display().to_string());
}
}
if let Ok(entries) = fs::read_dir(notices_dir) {
for e in entries.flatten() {
let p = e.path();
if is_stale_notice(&p, notices) {
stale.push(p.display().to_string());
}
}
}
let disk = fs::read_to_string(manifest_path).ok();
if !matches_output(disk.clone(), manifest) {
let entry = match &disk {
Some(d) => {
let d = d.replace("\r\n", "\n");
let line = d
.lines()
.zip(manifest.lines())
.position(|(a, b)| a != b)
.map_or_else(|| d.lines().count().min(manifest.lines().count()) + 1, |i| i + 1);
format!("{} (first difference at line {line})", manifest_path.display())
}
None => manifest_path.display().to_string(),
};
stale.push(entry);
}
stale
}
pub fn render_manifest(r: &Resolution, licenses_dir: &str, notices_dir: &str) -> String {
let mut out = String::from(
"# Third-party licenses\n\nDependencies linked into this crate, grouped by license; full texts are in \
[`",
);
out.push_str(licenses_dir);
out.push_str("/`](");
out.push_str(licenses_dir);
out.push(')');
if r.extras.values().any(|x| x.notice.is_some()) {
out.push_str(&format!(", NOTICE files shipped by dependencies in [`{notices_dir}/`]({notices_dir})"));
}
out.push_str(". Generated by `cargo tribute`; do not edit.\n\n");
let ids: BTreeSet<&String> = r.by_license.keys().chain(r.extra_by_license.keys()).collect();
for id in ids {
let mut ps: Vec<&Package> = r.by_license.get(id).cloned().unwrap_or_default();
ps.sort_by(|a, b| (&*a.name, &a.version).cmp(&(&*b.name, &b.version)));
ps.dedup_by(|a, b| a.id == b.id);
out.push_str(&format!("## {id}\n\nText: [`{licenses_dir}/{id}.txt`]({licenses_dir}/{id}.txt)\n\n"));
for p in ps {
let url = p.repository.clone().unwrap_or_else(|| format!("https://crates.io/crates/{}", p.name));
out.push_str(&format!("- [{} {}]({url})", p.name, p.version));
if let Some(expr) = r.effective.get(&p.id).copied().filter(|e| *e != id.as_str()) {
out.push_str(&format!(" -- `{expr}`"));
}
if let Some(x) = r.extras.get(&p.id) {
if !x.copyrights.is_empty() {
out.push_str(&format!(" -- {}", x.copyrights.join("; ")));
} else {
let names: Vec<&str> =
x.authors.iter().map(|a| display_author(a)).filter(|s| !s.is_empty()).collect();
if !names.is_empty() {
out.push_str(&format!(" -- by {}", names.join(", ")));
}
}
if x.notice.is_some() {
out.push_str(&format!(
" -- [NOTICE]({notices_dir}/{name}-{ver}.txt)",
name = p.name,
ver = p.version
));
}
}
out.push('\n');
}
for x in r.extra_by_license.get(id).into_iter().flatten() {
match &x.url {
Some(u) => out.push_str(&format!("- [{}]({u})", x.name)),
None => out.push_str(&format!("- {}", x.name)),
}
if x.expression != *id {
out.push_str(&format!(" -- `{}`", x.expression));
}
if let Some(c) = &x.copyright {
out.push_str(&format!(" -- {c}"));
}
out.push('\n');
}
out.push('\n');
}
out
}
pub fn render_text(r: &Resolution, texts: &BTreeMap<&str, String>) -> String {
let sep = "=".repeat(72);
let mut out = String::from(
"Third-party licenses\n\nDependencies grouped by license; the full text follows each group. Generated by cargo tribute.\n",
);
let ids: BTreeSet<&String> = r.by_license.keys().chain(r.extra_by_license.keys()).collect();
for id in ids {
out.push_str(&format!("\n{sep}\n{id}\n{sep}\n\n"));
let mut ps: Vec<&Package> = r.by_license.get(id).cloned().unwrap_or_default();
ps.sort_by(|a, b| (&*a.name, &a.version).cmp(&(&*b.name, &b.version)));
ps.dedup_by(|a, b| a.id == b.id);
for p in ps {
let url = p.repository.clone().unwrap_or_else(|| format!("https://crates.io/crates/{}", p.name));
out.push_str(&format!("- {} {} ({url})", p.name, p.version));
if let Some(expr) = r.effective.get(&p.id).copied().filter(|e| *e != id.as_str()) {
out.push_str(&format!(" -- {expr}"));
}
if let Some(x) = r.extras.get(&p.id) {
if !x.copyrights.is_empty() {
out.push_str(&format!(" -- {}", x.copyrights.join("; ")));
} else {
let names: Vec<&str> =
x.authors.iter().map(|a| display_author(a)).filter(|s| !s.is_empty()).collect();
if !names.is_empty() {
out.push_str(&format!(" -- by {}", names.join(", ")));
}
}
}
out.push('\n');
}
for x in r.extra_by_license.get(id).into_iter().flatten() {
match &x.url {
Some(u) => out.push_str(&format!("- {} ({u})", x.name)),
None => out.push_str(&format!("- {}", x.name)),
}
if x.expression != *id {
out.push_str(&format!(" -- {}", x.expression));
}
if let Some(c) = &x.copyright {
out.push_str(&format!(" -- {c}"));
}
out.push('\n');
}
if let Some(text) = texts.get(id.as_str()) {
out.push('\n');
out.push_str(text);
if !text.ends_with('\n') {
out.push('\n');
}
}
}
for ex in r.used_exceptions {
out.push_str(&format!("\n{sep}\n{ex} (license exception)\n{sep}\n\n"));
if let Some(text) = texts.get(ex.as_str()) {
out.push_str(text);
if !text.ends_with('\n') {
out.push('\n');
}
}
}
for id in r.deps {
let (Some(&pkg), Some(x)) = (r.pkg_of.get(id), r.extras.get(id)) else { continue };
if let Some(n) = &x.notice {
out.push_str(&format!("\n{sep}\nNOTICE for {} {}\n{sep}\n\n", pkg.name, pkg.version));
out.push_str(n);
if !n.ends_with('\n') {
out.push('\n');
}
}
}
out
}
fn cdx_licenses(chosen: &BTreeSet<String>, texts: &BTreeMap<&str, String>) -> serde_json::Value {
let list: Vec<serde_json::Value> = chosen
.iter()
.map(|id| {
let key = if canonical_text(id).is_some() { "id" } else { "name" };
let mut lic = serde_json::json!({ key: id });
if let Some(t) = texts.get(id.as_str()) {
lic["text"] = serde_json::json!({ "contentType": "text/plain", "content": t });
}
serde_json::json!({ "license": lic })
})
.collect();
serde_json::Value::Array(list)
}
fn cdx_copyright(x: &Extras) -> Option<String> {
if !x.copyrights.is_empty() {
return Some(x.copyrights.join("; "));
}
let names: Vec<&str> = x.authors.iter().map(|a| display_author(a)).filter(|s| !s.is_empty()).collect();
(!names.is_empty()).then(|| names.join(", "))
}
pub fn render_cyclonedx(r: &Resolution, texts: &BTreeMap<&str, String>) -> Result<String, String> {
let mut components: Vec<serde_json::Value> = Vec::new();
for id in r.deps {
let (Some(&pkg), Some(chosen)) = (r.pkg_of.get(id), r.chosen_of.get(id)) else { continue };
let mut comp = serde_json::json!({
"type": "library",
"name": pkg.name.as_ref() as &str,
"version": pkg.version.to_string(),
"licenses": cdx_licenses(chosen, texts),
});
if pkg.source.as_ref().is_some_and(|s| s.is_crates_io()) {
comp["purl"] = serde_json::json!(format!("pkg:cargo/{}@{}", pkg.name, pkg.version));
}
if let Some(c) = r.extras.get(id).and_then(cdx_copyright) {
comp["copyright"] = serde_json::json!(c);
}
if let Some(repo) = &pkg.repository {
comp["externalReferences"] = serde_json::json!([{ "type": "vcs", "url": repo }]);
}
if let Some(expr) = r.effective.get(id) {
comp["properties"] = serde_json::json!([{ "name": "cargo-tribute:expression", "value": expr }]);
}
components.push(comp);
}
for (x, chosen) in r.extra_chosen {
let mut comp = serde_json::json!({
"type": "library",
"name": x.name,
"licenses": cdx_licenses(chosen, texts),
});
if let Some(c) = &x.copyright {
comp["copyright"] = serde_json::json!(c);
}
if let Some(u) = &x.url {
comp["externalReferences"] = serde_json::json!([{ "type": "website", "url": u }]);
}
comp["properties"] = serde_json::json!([{ "name": "cargo-tribute:expression", "value": x.expression }]);
components.push(comp);
}
let bom = serde_json::json!({
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"metadata": {
"tools": {
"components": [{ "type": "application", "name": "cargo-tribute", "version": env!("CARGO_PKG_VERSION") }]
}
},
"components": components,
});
serde_json::to_string_pretty(&bom).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stale_detects_missing_and_orphan() {
let dir = std::env::temp_dir().join(format!("tribute-test-{}", std::process::id()));
let lic = dir.join("LICENSES");
let not = dir.join("NOTICES");
fs::create_dir_all(&lic).unwrap();
fs::create_dir_all(¬).unwrap();
let manifest_path = dir.join("THIRD-PARTY.md");
let mut texts: BTreeMap<&str, String> = BTreeMap::new();
texts.insert("MIT", "MIT TEXT".into());
let mut notices: BTreeMap<String, String> = BTreeMap::new();
notices.insert("dep-1.0.0".into(), "DEP NOTICE".into());
let stale = stale_outputs(&lic, &texts, ¬, ¬ices, &manifest_path, "MANIFEST");
assert!(stale.iter().any(|s| s.contains("MIT.txt")));
assert!(stale.iter().any(|s| s.contains("dep-1.0.0.txt")));
assert!(stale.iter().any(|s| s.contains("THIRD-PARTY.md")));
fs::write(lic.join("MIT.txt"), "MIT TEXT").unwrap();
fs::write(not.join("dep-1.0.0.txt"), "DEP NOTICE").unwrap();
fs::write(&manifest_path, "MANIFEST").unwrap();
assert!(stale_outputs(&lic, &texts, ¬, ¬ices, &manifest_path, "MANIFEST").is_empty());
fs::write(lic.join("Apache-2.0.txt"), "x").unwrap();
let stale = stale_outputs(&lic, &texts, ¬, ¬ices, &manifest_path, "MANIFEST");
assert!(stale.iter().any(|s| s.contains("Apache-2.0.txt")));
fs::remove_file(lic.join("Apache-2.0.txt")).unwrap();
fs::write(lic.join("NOTICE.txt"), "x").unwrap();
let stale = stale_outputs(&lic, &texts, ¬, ¬ices, &manifest_path, "MANIFEST");
assert!(!stale.iter().any(|s| s.contains("NOTICE.txt")));
fs::write(lic.join("LicenseRef-old.txt"), "x").unwrap();
let stale = stale_outputs(&lic, &texts, ¬, ¬ices, &manifest_path, "MANIFEST");
assert!(stale.iter().any(|s| s.contains("LicenseRef-old.txt")));
fs::remove_file(lic.join("LicenseRef-old.txt")).unwrap();
fs::write(not.join("gone-2.0.0.txt"), "x").unwrap();
fs::write(not.join("README.txt"), "x").unwrap();
let stale = stale_outputs(&lic, &texts, ¬, ¬ices, &manifest_path, "MANIFEST");
assert!(stale.iter().any(|s| s.contains("gone-2.0.0.txt")));
assert!(!stale.iter().any(|s| s.contains("README.txt")));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn stale_notice_requires_a_semver_stem_suffix() {
let notices: BTreeMap<String, String> = [("foo-bar-1.0.0".to_string(), String::new())].into();
assert!(is_stale_notice(Path::new("N/dep-2.0.0.txt"), ¬ices));
assert!(!is_stale_notice(Path::new("N/foo-bar-1.0.0.txt"), ¬ices));
assert!(!is_stale_notice(Path::new("N/NOTICE.txt"), ¬ices));
assert!(!is_stale_notice(Path::new("N/readme-notes.txt"), ¬ices));
assert!(!is_stale_notice(Path::new("N/dep-2.0.0.md"), ¬ices));
assert!(is_stale_notice(Path::new("N/dep-1.0.0-rc.1.txt"), ¬ices));
}
#[test]
fn matches_output_ignores_crlf() {
assert!(matches_output(Some("a\r\nb\r\n".into()), "a\nb\n"));
assert!(matches_output(Some("a\nb\n".into()), "a\nb\n"));
assert!(!matches_output(Some("a\nb\n".into()), "a\nDIFFERENT\n"));
assert!(!matches_output(None, "x")); }
}