Skip to main content

shuyi_cli/
notices.rs

1//! `NOTICES.txt`: the notices a package of a game owes for the works in
2//! it, written beside the game. `shuyi web` writes one into the web build
3//! ([`crate::web`]). It lists the crates compiled in ([`crates`]), the web
4//! shell's files and the game's own license texts ([`game_texts`]), then
5//! each license text once ([`write()`]).
6//!
7//! It copies what the packages say and judges nothing: every license of an
8//! `OR` is listed, and a crate with no license file gets the standard text
9//! of each license it declares that this tool can write.
10
11use std::collections::{BTreeSet, HashMap};
12use std::fmt::Write as _;
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use serde::Deserialize;
18
19use crate::project::Notices;
20
21/// The file's name.
22pub const FILE: &str = "NOTICES.txt";
23
24/// The engine's repository: its crates' `repository`.
25const ENGINE_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
26
27/// The engine's license texts, from the repository's root.
28const ENGINE_MIT: &str = include_str!("../../../LICENSE-MIT");
29const ENGINE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
30
31/// What a license file's name starts with, in capitals.
32const LICENSE_FILES: [&str; 6] = [
33    "LICENSE",
34    "LICENCE",
35    "COPYING",
36    "NOTICE",
37    "COPYRIGHT",
38    "UNLICENSE",
39];
40
41/// Which part of `NOTICES.txt` a work is listed in.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43#[non_exhaustive]
44pub enum Section {
45    /// The crates compiled into the game.
46    Crates,
47    /// The web shell's files beside the page.
48    Shell,
49    /// The engine's files a crate compiles in that aren't crates.
50    Data,
51    /// The game's own files whose texts it names in `shuyi.ron`.
52    Game,
53}
54
55impl Section {
56    /// Its heading.
57    fn heading(self) -> &'static str {
58        match self {
59            Self::Crates => "The crates compiled in",
60            Self::Shell => "The web shell's files",
61            Self::Data => "Data compiled in",
62            Self::Game => "The game's files",
63        }
64    }
65}
66
67/// What a package is built for, as [`FILES`] says which carry a file.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Build {
70    /// The web build: the shell's files beside the page.
71    Web,
72    /// A native build (Windows, Linux, macOS).
73    Native,
74}
75
76/// A file of the engine's that reaches a game's package and isn't a
77/// crate. Every one has its row in `THIRD_PARTY_ASSETS.md` (a test).
78#[derive(Debug, Clone, Copy)]
79pub struct EngineFile {
80    /// The file in the engine's repository, or the folder of the crate it
81    /// is compiled in from.
82    pub file: &'static str,
83    /// The work it comes from.
84    pub work: &'static str,
85    /// The work's version, if it has one.
86    pub version: &'static str,
87    /// Where it comes from.
88    pub source: &'static str,
89    /// Its license.
90    pub license: &'static str,
91    /// Its license text's file in the repository, and the text.
92    pub text: (&'static str, &'static str),
93    /// The builds that carry it.
94    pub builds: &'static [Build],
95    /// The crate that compiles it in, if a crate does: it is carried only
96    /// when that crate is.
97    pub within: Option<&'static str>,
98}
99
100/// The engine's files that aren't crates, as data: the web shell's
101/// vendored scripts, and the controller database `gilrs` compiles into
102/// native builds (which the crates' walk misses, in a subfolder of its
103/// package).
104pub const FILES: [EngineFile; 4] = [
105    EngineFile {
106        file: "crates/cli/web/mq_js_bundle.js",
107        work: "macroquad",
108        version: "0.4.16",
109        source: "https://github.com/not-fl3/macroquad",
110        license: "MIT",
111        text: (
112            "crates/cli/web/mq_js_bundle-LICENSE-MIT.txt",
113            include_str!("../web/mq_js_bundle-LICENSE-MIT.txt"),
114        ),
115        builds: &[Build::Web],
116        within: None,
117    },
118    EngineFile {
119        file: "crates/cli/web/sapp_jsutils.js",
120        work: "sapp-jsutils",
121        version: "0.1.7",
122        source: "https://github.com/not-fl3/sapp-jsutils",
123        license: "MIT",
124        text: (
125            "crates/cli/web/sapp_jsutils-LICENSE-MIT.txt",
126            include_str!("../web/sapp_jsutils-LICENSE-MIT.txt"),
127        ),
128        builds: &[Build::Web],
129        within: None,
130    },
131    EngineFile {
132        file: "crates/cli/web/quad-storage.js",
133        work: "quad-storage",
134        version: "0.1.3",
135        source: "https://github.com/optozorax/quad-storage",
136        license: "MIT",
137        text: (
138            "crates/cli/web/quad-storage-LICENSE-MIT.txt",
139            include_str!("../web/quad-storage-LICENSE-MIT.txt"),
140        ),
141        builds: &[Build::Web],
142        within: None,
143    },
144    EngineFile {
145        file: "gamecontrollerdb.txt",
146        work: "SDL_GameControllerDB",
147        version: "",
148        source: "https://github.com/mdqinc/SDL_GameControllerDB",
149        license: "Zlib",
150        text: (
151            "crates/app/SDL_GameControllerDB-LICENSE.txt",
152            include_str!("../../app/SDL_GameControllerDB-LICENSE.txt"),
153        ),
154        builds: &[Build::Native],
155        within: Some("gilrs"),
156    },
157];
158
159impl EngineFile {
160    /// Its name as `NOTICES.txt` lists it: the file and its work.
161    pub fn name(&self) -> String {
162        let file = self.file.rsplit('/').next().unwrap_or(self.file);
163        match self.within {
164            Some(krate) => format!("{file}, from {}, compiled in by {krate}", self.work),
165            None => format!("{file}, from {}", self.work),
166        }
167    }
168
169    /// Its license text's file name, without its folders.
170    pub fn text_name(&self) -> &'static str {
171        self.text.0.rsplit('/').next().unwrap_or(self.text.0)
172    }
173
174    /// Its work in `section`.
175    pub fn work(&self, section: Section) -> Work {
176        Work {
177            section,
178            name: self.name(),
179            version: self.version.to_owned(),
180            license: self.license.to_owned(),
181            source: self.source.to_owned(),
182            texts: vec![Text::new(self.text_name(), self.text.1)],
183        }
184    }
185}
186
187/// The works of [`FILES`] that a `build` package carries whose crates are
188/// `crates` (the [`crates`] compiled in), apart from the web shell's,
189/// which `web` lists with the shell.
190pub fn engine_files(build: Build, crates: &[Work]) -> Vec<Work> {
191    FILES
192        .iter()
193        .filter(|file| file.builds.contains(&build))
194        .filter_map(|file| {
195            let krate = file.within?;
196            crates
197                .iter()
198                .any(|work| work.name == krate)
199                .then(|| file.work(Section::Data))
200        })
201        .collect()
202}
203
204/// A license or notice text.
205#[derive(Debug, Clone, PartialEq, Eq)]
206#[non_exhaustive]
207pub struct Text {
208    /// What it is: the file it was read from, or how it was written.
209    pub label: String,
210    /// The text.
211    pub body: String,
212}
213
214impl Text {
215    /// The text `body`, called `label`.
216    pub fn new(label: impl Into<String>, body: impl Into<String>) -> Self {
217        Self {
218            label: label.into(),
219            body: body.into(),
220        }
221    }
222}
223
224/// A work in the package, and its texts.
225#[derive(Debug, Clone, PartialEq, Eq)]
226#[non_exhaustive]
227pub struct Work {
228    /// Where it is listed.
229    pub section: Section,
230    /// Its name.
231    pub name: String,
232    /// Its version, if it has one.
233    pub version: String,
234    /// Its license, as it declares it, if it does.
235    pub license: String,
236    /// Where it comes from, if known.
237    pub source: String,
238    /// Its license and notice texts.
239    pub texts: Vec<Text>,
240}
241
242impl Work {
243    /// The work `name` in `section`, with no version, license, source or
244    /// text yet.
245    pub fn new(section: Section, name: impl Into<String>) -> Self {
246        Self {
247            section,
248            name: name.into(),
249            version: String::new(),
250            license: String::new(),
251            source: String::new(),
252            texts: Vec::new(),
253        }
254    }
255}
256
257/// The engine's own texts, for its crates and its files.
258pub fn engine_texts() -> Vec<Text> {
259    vec![
260        Text::new("the engine's LICENSE-MIT", ENGINE_MIT),
261        Text::new("the engine's LICENSE-APACHE", ENGINE_APACHE),
262    ]
263}
264
265/// The crates the package `package` of the workspace at `root` compiles
266/// in for `triple` with `features` (its own) turned on: its normal
267/// dependencies, all the way down, by `cargo metadata`. Dev- and
268/// build-dependencies don't ship; the game's own crates (workspace members
269/// outside the engine's repository) are the game's work, not a notice.
270pub fn crates(
271    root: &Path,
272    package: &str,
273    triple: &str,
274    features: &[String],
275) -> Result<Vec<Work>, String> {
276    let cargo = crate::cargo();
277    let mut args: Vec<String> = [
278        "metadata",
279        "--format-version",
280        "1",
281        "--filter-platform",
282        triple,
283    ]
284    .map(str::to_owned)
285    .to_vec();
286    for feature in features {
287        args.extend(["--features".to_owned(), format!("{package}/{feature}")]);
288    }
289    let output = Command::new(cargo)
290        .args(args)
291        .current_dir(root)
292        .output()
293        .map_err(|e| format!("spawn cargo metadata: {e}"))?;
294    if !output.status.success() {
295        let error = String::from_utf8_lossy(&output.stderr);
296        return Err(format!("cargo metadata failed: {}", error.trim()));
297    }
298    let json = String::from_utf8_lossy(&output.stdout);
299    crates_of(&json, package)
300}
301
302/// `cargo metadata`'s answer, the fields [`crates`] reads.
303#[derive(Deserialize)]
304struct Metadata {
305    packages: Vec<Package>,
306    workspace_members: Vec<String>,
307    resolve: Resolve,
308}
309
310#[derive(Deserialize)]
311struct Package {
312    id: String,
313    name: String,
314    version: String,
315    license: Option<String>,
316    license_file: Option<String>,
317    repository: Option<String>,
318    source: Option<String>,
319    #[serde(default)]
320    authors: Vec<String>,
321    manifest_path: PathBuf,
322}
323
324#[derive(Deserialize)]
325struct Resolve {
326    nodes: Vec<Node>,
327}
328
329#[derive(Deserialize)]
330struct Node {
331    id: String,
332    deps: Vec<Dep>,
333}
334
335#[derive(Deserialize)]
336struct Dep {
337    pkg: String,
338    dep_kinds: Vec<DepKind>,
339}
340
341#[derive(Deserialize)]
342struct DepKind {
343    kind: Option<String>,
344}
345
346/// [`crates`] from `cargo metadata`'s answer, `json`.
347fn crates_of(json: &str, package: &str) -> Result<Vec<Work>, String> {
348    let meta: Metadata =
349        serde_json::from_str(json).map_err(|e| format!("read cargo metadata: {e}"))?;
350    let member = |p: &Package| meta.workspace_members.contains(&p.id);
351    let start = meta
352        .packages
353        .iter()
354        .filter(|p| p.name == package)
355        .find(|p| member(p))
356        .ok_or_else(|| format!("cargo metadata: no package `{package}` in the workspace"))?;
357    let nodes: HashMap<&str, &Node> = meta
358        .resolve
359        .nodes
360        .iter()
361        .map(|node| (node.id.as_str(), node))
362        .collect();
363    let mut seen = BTreeSet::new();
364    let mut stack = vec![start.id.as_str()];
365    while let Some(id) = stack.pop() {
366        if !seen.insert(id) {
367            continue;
368        }
369        let Some(node) = nodes.get(id) else { continue };
370        let normal = node
371            .deps
372            .iter()
373            .filter(|dep| dep.dep_kinds.iter().any(|kind| kind.kind.is_none()));
374        stack.extend(normal.map(|dep| dep.pkg.as_str()));
375    }
376    let engine = |p: &Package| p.repository.as_deref() == Some(ENGINE_REPOSITORY);
377    let mut shipped: Vec<&Package> = meta
378        .packages
379        .iter()
380        .filter(|p| seen.contains(p.id.as_str()) && (engine(p) || !member(p)))
381        .collect();
382    shipped.sort_by(|a, b| (&a.name, &a.version).cmp(&(&b.name, &b.version)));
383    Ok(shipped.into_iter().map(crate_work).collect())
384}
385
386/// A crate's work: what it declares, and its texts.
387fn crate_work(package: &Package) -> Work {
388    let mut work = Work::new(Section::Crates, &package.name);
389    work.version.clone_from(&package.version);
390    work.license = package.license.clone().unwrap_or_default();
391    work.source = package
392        .repository
393        .clone()
394        .or_else(|| {
395            let registry = package.source.as_deref()?;
396            registry
397                .contains("crates.io-index")
398                .then(|| format!("https://crates.io/crates/{}", package.name))
399        })
400        .unwrap_or_default();
401    work.texts = license_files(package);
402    if work.texts.is_empty() {
403        work.texts = if package.repository.as_deref() == Some(ENGINE_REPOSITORY) {
404            engine_texts()
405        } else {
406            written_texts(&work.license, &package.authors)
407        };
408    }
409    work
410}
411
412/// The license files at the top of a crate's folder, and its
413/// `license_file`, by name.
414fn license_files(package: &Package) -> Vec<Text> {
415    let Some(dir) = package.manifest_path.parent() else {
416        return Vec::new();
417    };
418    let mut names = BTreeSet::new();
419    if let Ok(entries) = fs::read_dir(dir) {
420        for entry in entries.flatten() {
421            let name = entry.file_name().to_string_lossy().into_owned();
422            let upper = name.to_uppercase();
423            if entry.path().is_file() && LICENSE_FILES.iter().any(|p| upper.starts_with(p)) {
424                names.insert(name);
425            }
426        }
427    }
428    if let Some(file) = &package.license_file {
429        names.insert(file.replace('\\', "/"));
430    }
431    names
432        .into_iter()
433        .filter_map(|name| {
434            let bytes = fs::read(dir.join(&name)).ok()?;
435            Some(Text::new(name, String::from_utf8_lossy(&bytes)))
436        })
437        .collect()
438}
439
440/// The ids of a license expression, once each, in order: `MIT OR
441/// Apache-2.0`, `(MIT OR Apache-2.0) AND Unicode-3.0` and the old
442/// `MIT/Apache-2.0` alike.
443fn license_ids(expression: &str) -> Vec<&str> {
444    let mut ids: Vec<&str> = Vec::new();
445    let words = expression
446        .split(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | '/'))
447        .filter(|word| !word.is_empty() && !matches!(*word, "OR" | "AND" | "WITH"));
448    for word in words {
449        if !ids.contains(&word) {
450            ids.push(word);
451        }
452    }
453    ids
454}
455
456/// The standard texts of `license`'s ids this tool can write, for a crate
457/// with no license file: Apache-2.0 as it is, and MIT with its copyright
458/// line naming `authors` (none without them).
459fn written_texts(license: &str, authors: &[String]) -> Vec<Text> {
460    let label = |id: &str| {
461        format!("{id}, written from the declared license: the package has no license file")
462    };
463    license_ids(license)
464        .into_iter()
465        .filter_map(|id| match id {
466            "Apache-2.0" => Some(Text::new(label(id), ENGINE_APACHE)),
467            "MIT" if !authors.is_empty() => {
468                Some(Text::new(label(id), mit_text(&authors.join(", "))))
469            }
470            _ => None,
471        })
472        .collect()
473}
474
475/// The MIT text with its copyright line naming `holders`.
476fn mit_text(holders: &str) -> String {
477    ENGINE_MIT
478        .lines()
479        .map(|line| {
480            if line.starts_with("Copyright") {
481                format!("Copyright (c) {holders}\n")
482            } else {
483                format!("{line}\n")
484            }
485        })
486        .collect()
487}
488
489/// The game's own license texts that `notices` names, each a work of its
490/// own read whole from the project at `root`.
491pub fn game_texts(root: &Path, notices: &Notices) -> Result<Vec<Work>, String> {
492    notices
493        .texts
494        .iter()
495        .map(|path| {
496            let body =
497                fs::read(root.join(path)).map_err(|e| format!("notices: read {path}: {e}"))?;
498            let mut work = Work::new(Section::Game, path);
499            work.texts = vec![Text::new(path, String::from_utf8_lossy(&body))];
500            Ok(work)
501        })
502        .collect()
503}
504
505/// A text as it is compared and written: `\n` line ends, no trailing
506/// blank lines.
507fn normalized(body: &str) -> String {
508    let text = body.replace("\r\n", "\n");
509    let lines: Vec<&str> = text.lines().collect();
510    let end = lines
511        .iter()
512        .rposition(|line| !line.trim().is_empty())
513        .map_or(0, |last| last + 1);
514    let mut out = lines[..end].join("\n");
515    out.push('\n');
516    out
517}
518
519/// Writes `NOTICES.txt` into `dist` for the game called `title`.
520pub fn write(dist: &Path, title: &str, works: &[Work]) -> Result<(), String> {
521    let path = dist.join(FILE);
522    fs::write(&path, render(title, works)).map_err(|e| format!("write {}: {e}", path.display()))
523}
524
525/// `NOTICES.txt`'s text: the works by section, then each text once,
526/// headed by the works it covers. The same works give the same bytes.
527fn render(title: &str, works: &[Work]) -> String {
528    let mut sorted: Vec<&Work> = works.iter().collect();
529    sorted.sort_by_key(|work| work.section);
530    let heading = format!("Notices for {title}");
531    let mut out = format!("{heading}\n{}\n\n", "=".repeat(heading.chars().count()));
532    out.push_str(
533        "This build holds the works listed below. Each license or notice\n\
534         text they come with follows the list, once for all the works it\n\
535         covers.\n",
536    );
537    let mut section = None;
538    for work in &sorted {
539        if section != Some(work.section) {
540            section = Some(work.section);
541            let heading = work.section.heading();
542            let _ = write!(out, "\n{heading}\n{}\n\n", "-".repeat(heading.len()));
543        }
544        let mut line = format!("- {}", named(work));
545        for part in [&work.license, &work.source] {
546            if !part.is_empty() {
547                let _ = write!(line, ", {part}");
548            }
549        }
550        if work.texts.is_empty() {
551            line.push_str(" (no license text found)");
552        }
553        out.push_str(&line);
554        out.push('\n');
555    }
556    // Each text once, in the order the works first name it.
557    let mut texts: Vec<(String, Vec<String>)> = Vec::new();
558    for work in &sorted {
559        for text in &work.texts {
560            let body = normalized(&text.body);
561            let cover = if text.label == work.name {
562                named(work)
563            } else {
564                format!("{} ({})", named(work), text.label)
565            };
566            match texts.iter_mut().find(|(known, _)| *known == body) {
567                Some((_, covers)) => covers.push(cover),
568                None => texts.push((body, vec![cover])),
569            }
570        }
571    }
572    let rule = "=".repeat(72);
573    out.push_str("\n\nLicense texts\n=============\n");
574    for (n, (body, covers)) in texts.iter().enumerate() {
575        let _ = write!(out, "\n{rule}\nText {} covers:\n", n + 1);
576        for cover in covers {
577            let _ = writeln!(out, "  {cover}");
578        }
579        let _ = write!(out, "{rule}\n\n{body}");
580    }
581    out
582}
583
584/// A work's name and version.
585fn named(work: &Work) -> String {
586    if work.version.is_empty() {
587        work.name.clone()
588    } else {
589        format!("{} {}", work.name, work.version)
590    }
591}
592
593#[cfg(test)]
594mod tests;