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 <noreply@anthropic.com>
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

+19 -20
View File
@@ -2,17 +2,6 @@ SolveCraft is an open-source parametric 3D CAD application in pure Rust: sketch
constrain and dimension the sketch, turn profiles into solids on a feature timeline, and change a
parameter to watch the timeline rebuild. It is early, and we'd like your bug reports.
## Fixed since v0.1.0
- **Quitting with unsaved changes now asks first** ([#33](https://github.com/bherbruck/solvecraft/issues/33)):
"Save changes to <name>?" with Save, Don't Save and Cancel, once per unsaved design, for the
title bar's ×, File ▸ Quit, Alt+F4 / Cmd+Q and the window manager. The web app asks through
the browser's "leave site?" prompt.
- **Web: a new design no longer shows a black screen** in browsers with WebGPU
([#34](https://github.com/bherbruck/solvecraft/issues/34)); the viewport shader compiles there now.
- **README demo:** a short GIF at the top shows an agent building an enclosure over MCP and then
editing its parameters. The full video is attached below.
## Links
- **Documentation:** https://bherbruck.github.io/solvecraft/
@@ -42,24 +31,34 @@ Everything the UI does is a command with JSON parameters, so an agent can do it
`solvecraft-cli mcp` is a Model Context Protocol server: headless, or bridged to the running app
(`solvecraft --control 7878`, then `solvecraft-cli mcp --connect 127.0.0.1:7878`) so you can
watch the agent model. In Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. The
attached video shows an agent building an enclosure through the UI and MCP.
demo at the top of the [README](https://github.com/bherbruck/solvecraft#readme) shows an agent
building an enclosure through the UI and MCP.
## Downloads
| File | What |
|---|---|
| `solvecraft-v0.1.1-windows-x86_64.zip` | Windows 10/11: `solvecraft.exe` (the app) and `solvecraft-cli.exe` |
| `solvecraft-v0.1.1-linux-x86_64.tar.gz` | Linux x86_64 (glibc 2.35 or newer): `solvecraft` and `solvecraft-cli` |
| `SolveCraft-v0.1.1-x86_64.AppImage` | Linux, the app as one file: `chmod +x` and run it |
| `solvecraft-v0.1.1-macos-universal.zip` | macOS 11 or newer, Apple silicon and Intel: `SolveCraft.app` and `solvecraft-cli` |
| `solvecraft-v0.1.1-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
| `SolveCraft-{tag}-windows-x86_64.exe` | Windows 10/11: the app, ready to run |
| `solvecraft-cli-{tag}-windows-x86_64.exe` | Windows: the command-line tool and MCP server |
| `SolveCraft-{tag}-macos-universal.dmg` | macOS 11 or newer, Apple silicon and Intel: open it and drag SolveCraft onto Applications |
| `solvecraft-cli-{tag}-macos-universal` | macOS: the command-line tool and MCP server |
| `SolveCraft-{tag}-x86_64.AppImage` | Linux x86_64 (glibc 2.35 or newer): the app as one file |
| `solvecraft-{tag}-linux-x86_64` | Linux: the app as a plain binary |
| `solvecraft-cli-{tag}-linux-x86_64` | Linux: the command-line tool and MCP server |
| `solvecraft-{tag}-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
| `SHA256SUMS` | checksums: `sha256sum -c SHA256SUMS` |
On Linux and macOS a downloaded program isn't executable yet: `chmod +x` it first
(`chmod +x SolveCraft-{tag}-x86_64.AppImage && ./SolveCraft-{tag}-x86_64.AppImage`). The licences
of SolveCraft and everything it is built from are inside each program: Help ▸ About ▸ Licences,
or `solvecraft-cli licences`.
### The binaries are not signed
- **macOS:** the first time, right-click (or Control-click) `SolveCraft.app` and choose **Open**,
then **Open** again in the dialog. If macOS says the app is damaged, run
`xattr -dr com.apple.quarantine SolveCraft.app`. Do the same for `solvecraft-cli`.
- **macOS:** the first time, right-click (or Control-click) `SolveCraft.app` in Applications and
choose **Open**, then **Open** again in the dialog. If macOS says the app is damaged, run
`xattr -dr com.apple.quarantine /Applications/SolveCraft.app`. For the CLI:
`xattr -d com.apple.quarantine solvecraft-cli-{tag}-macos-universal`.
- **Windows:** SmartScreen may say "Windows protected your PC". Click **More info**, then
**Run anyway**.
+5 -6
View File
@@ -1,14 +1,13 @@
#!/usr/bin/env bash
# Package the Linux release build: a tar.gz with both binaries, and an AppImage of the desktop app.
# Usage: .github/release/package-linux.sh <tag> (from the repository root, after
# Package the Linux release build: the app and the CLI as plain binaries, and an AppImage of the
# app. Usage: .github/release/package-linux.sh <tag> (from the repository root, after
# `cargo build --release -p solvecraft -p solvecraft-cli`). Writes out/.
set -euo pipefail
tag=${1:?usage: package-linux.sh <tag>}
bin=${CARGO_TARGET_DIR:-target}/release
name="solvecraft-$tag-linux-x86_64"
rm -rf stage out && mkdir -p "stage/$name" out
cp "$bin/solvecraft" "$bin/solvecraft-cli" README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
tar -C stage -czf "out/$name.tar.gz" "$name"
rm -rf stage out && mkdir -p stage out
cp "$bin/solvecraft" "out/solvecraft-$tag-linux-x86_64"
cp "$bin/solvecraft-cli" "out/solvecraft-cli-$tag-linux-x86_64"
# AppImage. The icon is rendered by SolveCraft itself from the bracket example, so no image file
# is committed.
+8 -8
View File
@@ -1,17 +1,17 @@
#!/usr/bin/env bash
# Package the macOS release build: a universal (arm64 + x86_64) SolveCraft.app and CLI in one zip.
# Package the macOS release build: a universal (arm64 + x86_64) SolveCraft.app in a .dmg (with an
# Applications link to drag it onto), and the universal CLI as a plain binary.
# Usage: .github/release/package-macos.sh <tag> (from the repository root, after building both
# aarch64-apple-darwin and x86_64-apple-darwin). Writes out/. The app is not signed.
# aarch64-apple-darwin and x86_64-apple-darwin). Writes out/. Neither is signed.
set -euo pipefail
tag=${1:?usage: package-macos.sh <tag>}
version=${tag#v}
t=${CARGO_TARGET_DIR:-target}
name="solvecraft-$tag-macos-universal"
rm -rf stage out && mkdir -p "stage/$name" out
app="stage/$name/SolveCraft.app"
rm -rf stage out && mkdir -p stage/dmg out
app="stage/dmg/SolveCraft.app"
mkdir -p "$app/Contents/MacOS"
lipo -create -output "$app/Contents/MacOS/solvecraft" "$t/aarch64-apple-darwin/release/solvecraft" "$t/x86_64-apple-darwin/release/solvecraft"
lipo -create -output "stage/$name/solvecraft-cli" "$t/aarch64-apple-darwin/release/solvecraft-cli" "$t/x86_64-apple-darwin/release/solvecraft-cli"
lipo -create -output "out/solvecraft-cli-$tag-macos-universal" "$t/aarch64-apple-darwin/release/solvecraft-cli" "$t/x86_64-apple-darwin/release/solvecraft-cli"
cat > "$app/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -29,7 +29,7 @@ cat > "$app/Contents/Info.plist" <<PLIST
</dict>
</plist>
PLIST
cp README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
lipo -info "$app/Contents/MacOS/solvecraft"
(cd stage && ditto -c -k --keepParent "$name" "../out/$name.zip")
ln -s /Applications stage/dmg/Applications
hdiutil create -volname "SolveCraft $tag" -srcfolder stage/dmg -ov -format UDZO "out/SolveCraft-$tag-macos-universal.dmg"
ls -l out
+12 -8
View File
@@ -1,7 +1,9 @@
# Release builds: push a tag `v*` (or run it by hand with a tag name) and this builds the desktop
# app and the CLI for Linux, Windows and macOS plus the web app, and attaches them to a GitHub
# release with SHA256SUMS (a pre-release when the tag has a suffix, such as v0.2.0-rc1). Notes:
# .github/release-notes.md.
# release with SHA256SUMS (a pre-release when the tag has a suffix, such as v0.2.0-rc1). Every
# download is a plain program (Windows .exe, Linux binary and AppImage, a macOS .dmg); the licences
# are built into the programs (Help ▸ About ▸ Licences, `solvecraft-cli licences`). Only the web
# app, being many files, is a zip. Notes: .github/release-notes.md, with {tag} replaced.
name: Release
on:
@@ -52,10 +54,9 @@ jobs:
- name: Package
shell: pwsh
run: |
$name = "solvecraft-$env:TAG-windows-x86_64"
New-Item -ItemType Directory -Force -Path "stage/$name", out | Out-Null
Copy-Item target/release/solvecraft.exe, target/release/solvecraft-cli.exe, README.md, LICENSE-MIT, LICENSE-APACHE, NOTICE, ATTRIBUTION.md "stage/$name/"
Compress-Archive -Path "stage/$name" -DestinationPath "out/$name.zip"
New-Item -ItemType Directory -Force -Path out | Out-Null
Copy-Item target/release/solvecraft.exe "out/SolveCraft-$env:TAG-windows-x86_64.exe"
Copy-Item target/release/solvecraft-cli.exe "out/solvecraft-cli-$env:TAG-windows-x86_64.exe"
- uses: actions/upload-artifact@v4
with:
name: windows
@@ -106,7 +107,7 @@ jobs:
run: |
name="solvecraft-$TAG-web"
mkdir -p out && cp -r dist/web "$name"
cp LICENSE-MIT LICENSE-APACHE NOTICE "$name/"
cp LICENSE-MIT LICENSE-APACHE NOTICE THIRD-PARTY-LICENSES.txt "$name/"
zip -qr "out/$name.zip" "$name"
- uses: actions/upload-artifact@v4
with:
@@ -127,11 +128,14 @@ jobs:
- name: Checksums
working-directory: assets
run: sha256sum * > SHA256SUMS && cat SHA256SUMS
- name: Notes
run: sed "s/{tag}/$TAG/g" .github/release-notes.md > notes.md
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG }}
name: SolveCraft ${{ env.TAG }}
body_path: .github/release-notes.md
body_path: notes.md
generate_release_notes: true
# A tag with a suffix (v0.2.0-rc1) is a pre-release; v1.2.3 is a normal release.
prerelease: ${{ contains(env.TAG, '-') }}
files: assets/*
+2 -1
View File
@@ -59,7 +59,8 @@ The [README](README.md#architecture) has the layer table. The short version:
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolveCraft is
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
dependencies are fine. Note which one you're adding and why in the PR.
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
3. **Every asset is original or openly licensed and has a row in
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
`cargo xtask assets` enforces this.
+3 -1
View File
@@ -5,7 +5,9 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
Third-party material
Software dependencies are listed in Cargo.lock. The geometry kernel builds on the truck crates
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
File diff suppressed because it is too large. Load diff
+8
View File
@@ -10,6 +10,7 @@
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -35,6 +36,7 @@ const USAGE: &str = "usage:
solvecraft-cli oracle <case-dir>... [--json]
solvecraft-cli step-corpus <case-dir>... [--json]
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
solvecraft-cli licences
";
fn main() -> ExitCode {
@@ -55,6 +57,12 @@ fn main() -> ExitCode {
Some("oracle") => oracle::run(&args[1..]),
Some("step-corpus") => oracle::step_corpus(&args[1..]),
Some("mcp") => cmd_mcp(&args[1..]),
Some("licences" | "licenses") => {
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
Ok(())
}
// Hidden, for the kill-mid-save test: save a large design over and over.
Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md).
+1
View File
@@ -10,6 +10,7 @@
pub mod cmd;
pub mod frames;
pub mod legacy_ids;
pub mod licences;
pub mod params;
pub mod recovery;
pub mod sample;
+26
View File
@@ -0,0 +1,26 @@
//! SolveCraft's licence texts and the third-party licences, built into every program so a single
//! downloaded executable carries them (Help ▸ About ▸ Licences, `solvecraft-cli licences`).
//! THIRD-PARTY-LICENSES.txt is written by `cargo xtask licences` and checked by `cargo xtask ci`.
pub const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
pub const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
pub const NOTICE: &str = include_str!("../../../NOTICE");
pub const THIRD_PARTY: &str = include_str!("../../../THIRD-PARTY-LICENSES.txt");
/// Everything, as one text.
pub fn all() -> String {
let rule = "=".repeat(80);
format!(
"SolveCraft is licensed under the MIT License or the Apache License, Version 2.0, at your option.\n\n{rule}\nNOTICE\n{rule}\n\n{NOTICE}\n{rule}\nLICENSE-MIT\n{rule}\n\n{LICENSE_MIT}\n{rule}\nLICENSE-APACHE\n{rule}\n\n{LICENSE_APACHE}\n{rule}\n{THIRD_PARTY}"
)
}
#[cfg(test)]
mod tests {
#[test]
fn the_licences_are_built_in() {
assert!(super::LICENSE_MIT.contains("MIT License") && super::LICENSE_APACHE.contains("Apache License"));
assert!(super::THIRD_PARTY.contains("truck") && super::THIRD_PARTY.contains("egui"));
assert!(super::all().contains("NOTICE"));
}
}
+31 -8
View File
@@ -13,8 +13,7 @@ pub const BUILD_DATE: &str = env!("SOLVECRAFT_BUILD_DATE");
/// The user and developer guide (docs/book, published by .github/workflows/pages.yml).
pub const DOCS_URL: &str = "https://bherbruck.github.io/solvecraft/";
const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md");
const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
use solvecraft_engine::licences::{LICENSE_APACHE, LICENSE_MIT, NOTICE, THIRD_PARTY};
/// Fonts: what this build carries.
pub const FONTS: &str = "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and \
the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans \
@@ -112,17 +111,28 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").default_open(true).show(ui, |ui| {
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").show(ui, |ui| {
ui.label(RichText::new(ATTRIBUTION).monospace().size(11.0));
});
egui::CollapsingHeader::new("Fonts").show(ui, |ui| {
ui.label(FONTS);
});
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
});
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
egui::CollapsingHeader::new("Licences").show(ui, |ui| {
egui::CollapsingHeader::new("NOTICE").show(ui, |ui| {
ui.label(RichText::new(NOTICE).monospace().size(11.0));
});
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
});
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
});
egui::CollapsingHeader::new("Third-party licences").show(ui, |ui| {
if ui.button("Copy all licences").clicked() {
ctx.copy_text(solvecraft_engine::licences::all());
}
third_party(ui);
});
});
});
});
@@ -131,6 +141,19 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
}
}
/// THIRD-PARTY-LICENSES.txt (over 10 000 lines): only the lines in view are laid out.
fn third_party(ui: &mut egui::Ui) {
let lines: Vec<&str> = THIRD_PARTY.lines().collect();
let font = egui::FontId::monospace(11.0);
let row_h = ui.fonts_mut(|f| f.row_height(&font));
egui::ScrollArea::both().id_salt("sc_third_party").max_height(360.0).show_rows(ui, row_h, lines.len(), |ui, rows| {
for line in lines.get(rows).unwrap_or_default() {
// One row per line (show_rows needs equal rows): long lines scroll sideways.
ui.add(egui::Label::new(RichText::new(*line).font(font.clone())).extend());
}
});
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,9 @@
[
{"note": "Help ▸ About ▸ Licences shows SolveCraft's licences and every third-party crate's, built into the program."},
{"call": "ui.help", "params": {"item": "about"}},
{"widget": "Licences"},
{"expect": {"shown": "Third-party licences"}},
{"widget": "Third-party licences"},
{"expect": {"shown": "Copy all licences"}},
{"expect": {"shown": "Third-party software in SolveCraft"}}
]
+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");