Releases offer plain programs; the licences are built in

Release assets are now the programs themselves: SolveCraft and solvecraft-cli .exe for Windows,
plain Linux binaries plus the AppImage, a macOS .dmg (SolveCraft.app with an Applications link) and
the universal CLI binary; only the web app stays a zip. The notes template takes {tag}, says to
chmod +x on Linux and macOS, and GitHub adds the change list.

Since no LICENSE or NOTICE file rides along any more, the programs carry them:
THIRD-PARTY-LICENSES.txt (written by `cargo xtask licences` from cargo metadata and each shipped
crate's own licence files, 419 crates; `cargo xtask ci` checks it is current) is embedded with
LICENSE-MIT, LICENSE-APACHE and NOTICE in solvecraft_engine::licences, shown in Help ▸ About ▸
Licences (third-party text laid out by visible rows) and printed by `solvecraft-cli licences`.
Scenario s44_about_licences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 17:53:19 +00:00
1 parent 3a1f05bacf
commit bf2b64f597
14 files changed
+12283 -53

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
//! `cargo xtask licences [--check]`: THIRD-PARTY-LICENSES.txt, the licences of every crate built
//! into the shipped programs (solvecraft, solvecraft-cli, solvecraft-web), read from
//! `cargo metadata` and the crates' own licence files. The programs embed it (Help ▸ About ▸
//! Licences, `solvecraft-cli licences`), so a release download needs no licence files beside it.
//! `--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] = &["solvecraft", "solvecraft-cli", "solvecraft-web"];
pub fn run(root: &Path, check: bool) -> Result<(), String> {
let text = generate(root)?;
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(root: &Path) -> 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)));
let _ = root;
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 SolveCraft\n\nSolveCraft (the desktop app, solvecraft-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\
SolveCraft'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 SolveCraft'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
}
+7 -1
View File
@@ -6,6 +6,7 @@
mod assets;
mod book;
mod layers;
mod licences;
mod parity;
use std::path::{Path, PathBuf};
@@ -28,8 +29,11 @@ commands:
book [--no-build]
generate docs/book's command reference and screenshots, then mdbook build
into target/book
licences [--check]
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate, embedded
in the programs); --check fails when it is out of date
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
ci fmt --check, clippy -D warnings, test, assets, licences --check, layers, wasm (stops at first failure)
";
fn main() -> ExitCode {
@@ -42,6 +46,7 @@ fn main() -> ExitCode {
Some("oracle") => cmd_oracle(),
Some("step-corpus") => cmd_step_corpus(rest.first().copied()),
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
Some("ci") => cmd_ci(),
Some("wasm") => cmd_wasm(),
Some("-h" | "--help" | "help") | None => {
@@ -305,6 +310,7 @@ fn cmd_ci() -> Result<(), String> {
c.args(["test", "--workspace", "--release"]);
run(c, "cargo test")?;
assets::run(&root())?;
licences::run(&root(), true)?;
cmd_layers()?;
cmd_wasm()?;
eprintln!("ci: all gates passed");