Files
OneCAD-Simulation/xtask/src/licences.rs
T
jyvikoandClaude Opus 5.5 d5bd74570f Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag
fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server
name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family,
docs and licences. No compatibility shim: no files of the earlier name exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 23:51:40 -04:00

180 lines
7.1 KiB
Rust

//! `cargo xtask licences [--check]`: THIRD-PARTY-LICENSES.txt, the licences of every crate built
//! into the shipped programs (fieldcraft, fieldcraft-cli, fieldcraft-web), read from
//! `cargo metadata` and the crates' own licence files, so a release download can carry them.
//! `--check` (part of `cargo xtask ci`) fails when the committed file is out of date, e.g. after a
//! dependency changed.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::Path;
use serde_json::Value;
use crate::cargo;
pub const FILE: &str = "THIRD-PARTY-LICENSES.txt";
const ROOTS: &[&str] = &["fieldcraft", "fieldcraft-cli", "fieldcraft-web"];
pub fn run(root: &Path, check: bool) -> Result<(), String> {
let text = generate()?;
let path = root.join(FILE);
if check {
let old = std::fs::read_to_string(&path).unwrap_or_default();
if old.replace("\r\n", "\n") != text {
return Err(format!("{FILE} is out of date (dependencies changed): run `cargo xtask licences` and commit it"));
}
println!("licences: {FILE} is up to date");
return Ok(());
}
std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
println!("licences: wrote {FILE} ({} KB)", text.len() / 1024);
Ok(())
}
struct Crate {
name: String,
version: String,
license: String,
repository: String,
/// (file name, text) of the licence files shipped in the crate.
files: Vec<(String, String)>,
}
fn generate() -> Result<String, String> {
let out = cargo().args(["metadata", "--format-version", "1", "--locked"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
if !out.status.success() {
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
}
let meta: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata JSON: {e}"))?;
let packages: BTreeMap<&str, &Value> = meta["packages"].as_array().into_iter().flatten().filter_map(|p| Some((p["id"].as_str()?, p))).collect();
let nodes: BTreeMap<&str, &Value> =
meta["resolve"]["nodes"].as_array().into_iter().flatten().filter_map(|n| Some((n["id"].as_str()?, n))).collect();
let members: BTreeSet<&str> = meta["workspace_members"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
// Everything reachable from the shipped programs through normal (not dev) dependencies, on any
// platform.
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut queue: VecDeque<&str> = packages
.iter()
.filter(|(id, p)| members.contains(*id) && ROOTS.contains(&p["name"].as_str().unwrap_or_default()))
.map(|(id, _)| *id)
.collect();
while let Some(id) = queue.pop_front() {
if !seen.insert(id) {
continue;
}
for d in nodes.get(id).and_then(|n| n["deps"].as_array()).into_iter().flatten() {
let normal = d["dep_kinds"].as_array().into_iter().flatten().any(|k| k["kind"].is_null());
if normal && let Some(pkg) = d["pkg"].as_str() {
queue.push_back(pkg);
}
}
}
let mut crates = Vec::new();
for id in seen {
let Some(p) = packages.get(id) else { continue };
if members.contains(id) {
continue;
}
let s = |k: &str| p[k].as_str().unwrap_or_default().to_string();
let dir = Path::new(p["manifest_path"].as_str().unwrap_or_default()).parent().map(Path::to_path_buf).unwrap_or_default();
crates.push(Crate {
name: s("name"),
version: s("version"),
license: s("license"),
repository: s("repository"),
files: licence_files(&dir, p),
});
}
crates.sort_by(|a, b| (&a.name, &a.version).cmp(&(&b.name, &b.version)));
Ok(render(&crates))
}
/// The licence and notice files at the top of a crate's package (and its `license-file`).
fn licence_files(dir: &Path, p: &Value) -> Vec<(String, String)> {
let mut names: BTreeSet<String> = BTreeSet::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().to_string();
let u = n.to_ascii_uppercase();
if e.path().is_file() && ["LICENSE", "LICENCE", "COPYING", "NOTICE", "UNLICENSE", "COPYRIGHT"].iter().any(|k| u.starts_with(k)) {
names.insert(n);
}
}
}
if let Some(f) = p["license_file"].as_str() {
names.insert(f.to_string());
}
names
.into_iter()
.filter_map(|n| {
let text = std::fs::read_to_string(dir.join(&n)).ok()?;
let text = text.replace("\r\n", "\n").trim().to_string();
(!text.is_empty()).then(|| (Path::new(&n).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or(n), text))
})
.collect()
}
fn render(crates: &[Crate]) -> String {
let rule = "=".repeat(80);
let mut out = format!(
"Third-party software in FieldCraft\n\nFieldCraft (the desktop app, fieldcraft-cli and the web app) is built from the {} crates below.\n\
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.\n\
FieldCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.\n\n",
crates.len()
);
for c in crates {
out += &format!("{} {} ({})\n", c.name, c.version, if c.license.is_empty() { "see its licence file" } else { &c.license });
}
// One copy of each distinct licence text, with the crates it covers.
let mut by_text: BTreeMap<&str, Vec<String>> = BTreeMap::new();
let mut without: Vec<&Crate> = Vec::new();
for c in crates {
if c.files.is_empty() {
without.push(c);
}
for (_, text) in &c.files {
by_text.entry(text.as_str()).or_default().push(format!("{} {}", c.name, c.version));
}
}
let mut sections: Vec<(Vec<String>, &str)> = by_text.into_iter().map(|(t, v)| (v, t)).collect();
sections.sort();
for (names, text) in sections {
out += &format!("\n{rule}\n{}\n{rule}\n\n{text}\n", wrap(&names.join(", ")));
}
if !without.is_empty() {
out += &format!(
"\n{rule}\nCrates whose package carries no licence file (their licence is named in Cargo.toml; the MIT and Apache-2.0\ntexts are as in FieldCraft's own LICENSE-MIT and LICENSE-APACHE)\n{rule}\n\n"
);
for c in without {
out += &format!(
"{} {}: {}{}\n",
c.name,
c.version,
c.license,
if c.repository.is_empty() { String::new() } else { format!(" ({})", c.repository) }
);
}
}
out
}
/// Wrap a long list of crate names at 100 columns.
fn wrap(s: &str) -> String {
let mut out = String::new();
let mut col = 0;
for w in s.split(' ') {
if col > 0 && col + w.len() + 1 > 100 {
out.push('\n');
col = 0;
} else if col > 0 {
out.push(' ');
col += 1;
}
out += w;
col += w.len();
}
out
}