1use 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
21pub const FILE: &str = "NOTICES.txt";
23
24const ENGINE_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
26
27const ENGINE_MIT: &str = include_str!("../../../LICENSE-MIT");
29const ENGINE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
30
31const LICENSE_FILES: [&str; 6] = [
33 "LICENSE",
34 "LICENCE",
35 "COPYING",
36 "NOTICE",
37 "COPYRIGHT",
38 "UNLICENSE",
39];
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43#[non_exhaustive]
44pub enum Section {
45 Crates,
47 Shell,
49 Data,
51 Game,
53}
54
55impl Section {
56 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Build {
70 Web,
72 Native,
74}
75
76#[derive(Debug, Clone, Copy)]
79pub struct EngineFile {
80 pub file: &'static str,
83 pub work: &'static str,
85 pub version: &'static str,
87 pub source: &'static str,
89 pub license: &'static str,
91 pub text: (&'static str, &'static str),
93 pub builds: &'static [Build],
95 pub within: Option<&'static str>,
98}
99
100pub 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 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 pub fn text_name(&self) -> &'static str {
171 self.text.0.rsplit('/').next().unwrap_or(self.text.0)
172 }
173
174 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
187pub 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#[derive(Debug, Clone, PartialEq, Eq)]
206#[non_exhaustive]
207pub struct Text {
208 pub label: String,
210 pub body: String,
212}
213
214impl Text {
215 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#[derive(Debug, Clone, PartialEq, Eq)]
226#[non_exhaustive]
227pub struct Work {
228 pub section: Section,
230 pub name: String,
232 pub version: String,
234 pub license: String,
236 pub source: String,
238 pub texts: Vec<Text>,
240}
241
242impl Work {
243 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
257pub 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
265pub 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#[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
346fn 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
386fn 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
412fn 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
440fn 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
456fn 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
475fn 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
489pub 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
505fn 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
519pub 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
525fn 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 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
584fn 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;