1use crate::prelude::*;
13
14use alloc::collections::BTreeSet;
15
16use serde::{Deserialize, Serialize};
17
18use crate::audio::{self, AudioManifest};
19use crate::error::{ContentError, line_of};
20use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
21use crate::ron::parse_ron;
22
23impl AssetKind for CreditsFile {
24 const NAME: &'static str = "credits";
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CreditsRon {
31 audio_manifest: String,
32 private_root: String,
33}
34
35impl CreditsRon {
36 pub fn new(audio_manifest: impl Into<String>, private_root: impl Into<String>) -> Self {
39 Self {
40 audio_manifest: audio_manifest.into(),
41 private_root: private_root.into(),
42 }
43 }
44}
45
46impl Default for CreditsRon {
47 fn default() -> Self {
49 Self::new("the audio manifest", "private")
50 }
51}
52
53impl Format for CreditsRon {
54 type Asset = CreditsFile;
55
56 fn id(&self) -> &'static str {
57 "credits"
58 }
59
60 fn extensions(&self) -> &'static [&'static str] {
61 &["ron"]
62 }
63
64 fn max_bytes(&self) -> usize {
65 TEXT_MAX_BYTES
66 }
67
68 fn decode(
69 &self,
70 file: &AssetPath,
71 bytes: &[u8],
72 _cx: &DecodeCx<'_>,
73 ) -> Result<CreditsFile, Vec<ContentError>> {
74 let places = Places {
75 audio_manifest: &self.audio_manifest,
76 private_root: &self.private_root,
77 };
78 from_source(file.shown(), text(file, bytes)?, &places)
79 }
80}
81
82pub const LICENSES: [&str; 15] = [
85 "MIT",
86 "MIT-0",
87 "Apache-2.0",
88 "Apache-2.0 WITH LLVM-exception",
89 "BSD-2-Clause",
90 "BSD-3-Clause",
91 "0BSD",
92 "ISC",
93 "Zlib",
94 "BSL-1.0",
95 "Unicode-3.0",
96 "Unicode-DFS-2016",
97 "CC0-1.0",
98 "Unlicense",
99 "OFL-1.1",
100];
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
105pub enum CreditGroup {
106 Music,
108 SoundEffects,
110 Art,
112 Fonts,
114 Software,
116}
117
118impl CreditGroup {
119 pub const ALL: [Self; 5] = [
121 Self::Music,
122 Self::SoundEffects,
123 Self::Art,
124 Self::Fonts,
125 Self::Software,
126 ];
127
128 pub fn is_audio(self) -> bool {
131 matches!(self, Self::Music | Self::SoundEffects)
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
137#[serde(deny_unknown_fields)]
138pub struct FileCredit {
139 pub id: String,
141 pub group: CreditGroup,
143 pub title: String,
145 pub author: String,
147 pub source: String,
149 pub license: String,
151 #[serde(default)]
153 pub hidden: bool,
154 #[serde(default)]
158 pub private: Vec<String>,
159}
160
161impl FileCredit {
162 pub fn covers(&self, path: &str) -> bool {
165 self.private.iter().any(|p| path.starts_with(p.as_str()))
166 }
167}
168
169const PRIVATE_README: &str = "README.md";
172
173fn is_custom(license: &str) -> bool {
175 let seller = license
176 .strip_prefix("Custom (")
177 .and_then(|rest| rest.strip_suffix(')'));
178 seller.is_some_and(|s| !s.trim().is_empty())
179}
180
181pub fn uncredited<'a>(file: &CreditsFile, paths: &[&'a str]) -> Vec<&'a str> {
185 let covered = |path: &str| file.credits.iter().any(|c| c.covers(path));
186 let needs_one = paths.iter().filter(|p| **p != PRIVATE_README);
187 needs_one.copied().filter(|p| !covered(p)).collect()
188}
189
190#[derive(Deserialize)]
191#[serde(deny_unknown_fields)]
192struct RawFile {
193 credits: Vec<FileCredit>,
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
198pub struct CreditsFile {
199 pub credits: Vec<FileCredit>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct CreditEntry {
206 pub group: CreditGroup,
208 pub title: String,
210 pub author: String,
212 pub license: String,
214 pub source: String,
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq)]
221pub struct Credits {
222 pub entries: Vec<CreditEntry>,
224}
225
226impl Credits {
227 pub fn in_group(&self, group: CreditGroup) -> impl Iterator<Item = &CreditEntry> {
229 self.entries.iter().filter(move |e| e.group == group)
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct Places<'a> {
236 pub audio_manifest: &'a str,
239 pub private_root: &'a str,
242}
243
244pub fn from_source(
248 file: &str,
249 source: &str,
250 places: &Places<'_>,
251) -> Result<CreditsFile, Vec<ContentError>> {
252 let raw: RawFile = parse_ron(file, source).map_err(|e| vec![e])?;
253 let mut errors = Vec::new();
254 let mut ids = BTreeSet::new();
255 for c in &raw.credits {
256 let mut err = |message: String| {
257 let e = ContentError::new(file, format!("credit \"{}\": {message}", c.id));
258 errors.push(match line_of(source, &format!("id: \"{}\"", c.id)) {
259 Some(l) => e.at(l, None),
260 None => e,
261 });
262 };
263 if c.id.is_empty() {
264 err("the id is empty".into());
265 }
266 if !ids.insert(c.id.as_str()) {
267 err("the id is used twice".into());
268 }
269 if c.group.is_audio() {
270 err(format!(
271 "{:?} credits go in {}",
272 c.group, places.audio_manifest
273 ));
274 }
275 for (field, value) in [("title", &c.title), ("author", &c.author)] {
276 if value.trim().is_empty() {
277 err(format!("{field} is missing"));
278 }
279 }
280 if !c.source.starts_with("https://") && !c.source.starts_with("http://") {
281 err("source must be a web link (http:// or https://)".into());
282 }
283 if !c.private.is_empty() {
284 if !is_custom(&c.license) {
285 err(format!(
286 "license \"{}\": a bought work's is written \"Custom (<seller>)\"",
287 c.license
288 ));
289 }
290 } else if !LICENSES.contains(&c.license.as_str()) {
291 err(format!(
292 "license \"{}\" is not allowed (ADR-0013: {})",
293 c.license,
294 LICENSES.join(", ")
295 ));
296 }
297 for path in &c.private {
298 if path.is_empty() || path.starts_with('/') {
299 err(format!(
300 "private path \"{path}\" must be a path inside {}/",
301 places.private_root
302 ));
303 }
304 }
305 }
306 if errors.is_empty() {
307 Ok(CreditsFile {
308 credits: raw.credits,
309 })
310 } else {
311 Err(errors)
312 }
313}
314
315pub fn merge(file: &CreditsFile, audio: &AudioManifest, bought: &[&str]) -> Credits {
322 let music = audio.music.values().map(|m| &m.credit);
323 let sounds = audio.sounds.values().map(|s| &s.credit);
324 let mut seen = BTreeSet::new();
325 let mut entries = Vec::new();
326 for (group, refs) in [
327 (CreditGroup::Music, music.collect::<Vec<_>>()),
328 (CreditGroup::SoundEffects, sounds.collect()),
329 ] {
330 let from = entries.len();
331 for id in refs.into_iter().flat_map(audio::CreditRef::ids) {
332 let Some(c) = audio.credits.get(id) else {
333 continue;
334 };
335 if c.license != audio::OWN && seen.insert(id) {
336 entries.push(CreditEntry {
337 group,
338 title: c.title.clone(),
339 author: c.author.clone(),
340 license: c.license.clone(),
341 source: c.source.clone(),
342 });
343 }
344 }
345 entries[from..].sort_by_key(|e| e.title.to_lowercase());
346 }
347 let in_build = |c: &FileCredit| c.private.is_empty() || bought.iter().any(|p| c.covers(p));
348 let shown = file.credits.iter().filter(|c| !c.hidden && in_build(c));
349 entries.extend(shown.map(|c| CreditEntry {
350 group: c.group,
351 title: c.title.clone(),
352 author: c.author.clone(),
353 license: c.license.clone(),
354 source: c.source.clone(),
355 }));
356 entries.sort_by_key(|e| e.group);
358 Credits { entries }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::audio::{Credit, CreditRef, MusicCue, SoundCue};
365
366 const FILE: &str = r#"(credits: [
367 (id: "font", group: Fonts, title: "A Font", author: "Ann",
368 source: "https://example.org/font", license: "OFL-1.1"),
369 (id: "loader", group: Software, title: "Loader", author: "Bob",
370 source: "http://example.org/loader", license: "MIT", hidden: true),
371 (id: "table", group: Software, title: "Table", author: "Cy",
372 source: "https://example.org/table", license: "Zlib"),
373])"#;
374
375 const PLACES: Places<'static> = Places {
376 audio_manifest: "base/audio/audio.ron",
377 private_root: "private",
378 };
379
380 fn from_source(file: &str, source: &str) -> Result<CreditsFile, Vec<ContentError>> {
381 super::from_source(file, source, &PLACES)
382 }
383
384 fn errors(source: &str) -> Vec<String> {
385 from_source("c.ron", source)
386 .err()
387 .unwrap_or_default()
388 .iter()
389 .map(ToString::to_string)
390 .collect()
391 }
392
393 fn credit(title: &str, license: &str) -> Credit {
394 Credit {
395 title: title.into(),
396 author: format!("{title}'s author"),
397 source: format!("https://example.org/{title}"),
398 license: license.into(),
399 ..Credit::default()
400 }
401 }
402
403 fn music(credit: CreditRef) -> MusicCue {
404 MusicCue {
405 file: "a.ogg".into(),
406 volume: 100,
407 looped: true,
408 length_ms: 1000,
409 credit,
410 }
411 }
412
413 fn sound(credit: CreditRef) -> SoundCue {
414 SoundCue {
415 files: vec!["a.ogg".into()],
416 volume: 100,
417 credit,
418 looped: false,
419 }
420 }
421
422 fn manifest() -> AudioManifest {
424 let one = |id: &str| CreditRef::Credit(id.into());
425 let mut m = AudioManifest::default();
426 for (id, c) in [
427 ("zeta", credit("Zeta", "CC0-1.0")),
428 ("alpha", credit("alpha", "CC-BY-4.0")),
429 ("step", credit("Step", "CC-BY-3.0")),
430 ("clang", credit("Clang", "CC0-1.0")),
431 ("ours", credit("", audio::OWN)),
432 ("unused", credit("Unused", "CC0-1.0")),
433 ] {
434 m.credits.insert(id.into(), c);
435 }
436 m.music.insert("a".into(), music(one("zeta")));
437 m.music.insert("b".into(), music(one("alpha")));
438 m.music.insert("c".into(), music(one("zeta")));
439 m.music.insert("d".into(), music(CreditRef::Own));
440 m.music.insert("e".into(), music(one("ours")));
441 m.sounds.insert(
442 "s".into(),
443 sound(CreditRef::Credits(vec!["step".into(), "clang".into()])),
444 );
445 m.sounds.insert("t".into(), sound(one("zeta")));
446 m.sounds.insert("u".into(), sound(CreditRef::Own));
447 m.sounds.insert("v".into(), sound(one("missing")));
448 m
449 }
450
451 fn shown(credits: &Credits) -> Vec<(CreditGroup, &str)> {
452 credits
453 .entries
454 .iter()
455 .map(|e| (e.group, e.title.as_str()))
456 .collect()
457 }
458
459 #[test]
460 fn a_good_file_loads_in_file_order() {
461 let file = from_source("c.ron", FILE).unwrap_or_default();
462 let ids: Vec<&str> = file.credits.iter().map(|c| c.id.as_str()).collect();
463 assert_eq!(ids, ["font", "loader", "table"]);
464 let hidden: Vec<bool> = file.credits.iter().map(|c| c.hidden).collect();
465 assert_eq!(hidden, [false, true, false]);
466 assert_eq!(file.credits[0].group, CreditGroup::Fonts);
467 assert_eq!(file.credits[0].license, "OFL-1.1");
468 assert_eq!(
469 from_source("c.ron", "(credits: [])"),
470 Ok(CreditsFile::default())
471 );
472 }
473
474 #[test]
475 fn bad_entries_are_reported_with_their_line() {
476 let dup = FILE.replace(r#"id: "table""#, r#"id: "font""#);
477 assert_eq!(
478 errors(&dup),
479 ["c.ron:2: credit \"font\": the id is used twice"]
480 );
481 let empty = FILE.replace(r#"id: "table""#, r#"id: """#);
482 assert_eq!(errors(&empty), ["c.ron:6: credit \"\": the id is empty"]);
483 let bare = FILE
484 .replace(
485 r#"title: "Table", author: "Cy""#,
486 r#"title: " ", author: """#,
487 )
488 .replace("https://example.org/table", "example.org/table");
489 assert_eq!(
490 errors(&bare),
491 [
492 "c.ron:6: credit \"table\": title is missing",
493 "c.ron:6: credit \"table\": author is missing",
494 "c.ron:6: credit \"table\": source must be a web link (http:// or https://)",
495 ]
496 );
497 let gpl = FILE.replace("\"Zlib\"", "\"GPL-3.0\"");
498 assert_eq!(
499 errors(&gpl),
500 [format!(
501 "c.ron:6: credit \"table\": license \"GPL-3.0\" is not allowed (ADR-0013: {})",
502 LICENSES.join(", ")
503 )]
504 );
505 }
506
507 #[test]
508 fn audio_groups_belong_to_the_audio_manifest() {
509 assert_eq!(
510 errors(&FILE.replace("group: Fonts", "group: Music")),
511 ["c.ron:2: credit \"font\": Music credits go in base/audio/audio.ron"]
512 );
513 assert_eq!(
514 errors(&FILE.replace("group: Fonts", "group: SoundEffects")),
515 ["c.ron:2: credit \"font\": SoundEffects credits go in base/audio/audio.ron"]
516 );
517 let audio: Vec<bool> = CreditGroup::ALL.iter().map(|g| g.is_audio()).collect();
518 assert_eq!(audio, [true, true, false, false, false]);
519 }
520
521 #[test]
522 fn syntax_and_unknown_fields_are_refused() {
523 assert_eq!(errors("(credits: [").len(), 1);
524 assert_eq!(errors(&FILE.replace("hidden: true", "hide: true")).len(), 1);
525 assert_eq!(
526 errors(&FILE.replace("group: Fonts", "group: Pics")).len(),
527 1
528 );
529 }
530
531 #[test]
532 fn merging_groups_sorts_and_drops_what_is_not_shown() {
533 use CreditGroup::{Fonts, Music, Software, SoundEffects};
534 let file = from_source("c.ron", FILE).unwrap_or_default();
535 let credits = merge(&file, &manifest(), &[]);
536 assert_eq!(
540 shown(&credits),
541 [
542 (Music, "alpha"),
543 (Music, "Zeta"),
544 (SoundEffects, "Clang"),
545 (SoundEffects, "Step"),
546 (Fonts, "A Font"),
547 (Software, "Table"),
548 ]
549 );
550 let first = &credits.entries[0];
551 assert_eq!(first.author, "alpha's author");
552 assert_eq!(first.license, "CC-BY-4.0");
553 assert_eq!(first.source, "https://example.org/alpha");
554 let font = credits.in_group(Fonts).collect::<Vec<_>>();
555 assert_eq!(font.len(), 1);
556 assert_eq!(
557 (font[0].author.as_str(), font[0].license.as_str()),
558 ("Ann", "OFL-1.1")
559 );
560 assert_eq!(font[0].source, "https://example.org/font");
561 assert_eq!(credits.in_group(Music).count(), 2);
562 }
563
564 #[test]
565 fn file_entries_keep_their_order_and_sort_under_their_group() {
566 use CreditGroup::{Fonts, Software};
567 let source = r#"(credits: [
570 (id: "z", group: Software, title: "Zed", author: "a", source: "https://z", license: "MIT"),
571 (id: "b", group: Software, title: "Bee", author: "a", source: "https://b", license: "MIT"),
572 (id: "f", group: Fonts, title: "Font", author: "a", source: "https://f", license: "MIT"),
573 ])"#;
574 let file = from_source("c.ron", source).unwrap_or_default();
575 let credits = merge(&file, &AudioManifest::default(), &[]);
576 assert_eq!(
577 shown(&credits),
578 [(Fonts, "Font"), (Software, "Zed"), (Software, "Bee")]
579 );
580 }
581
582 fn with_bought() -> String {
584 let pack = r#" (id: "pack", group: Art, title: "Pack", author: "Dee",
585 source: "https://example.org/pack", license: "Custom (Dee)",
586 private: ["units/", "tilesets/pack.ron"]),
587 (id: "loader""#;
588 FILE.replace(r#" (id: "loader""#, pack)
589 }
590
591 #[test]
592 fn a_bought_work_names_its_private_files_and_its_sellers_license() {
593 let file = from_source("c.ron", &with_bought()).unwrap_or_default();
594 assert_eq!(file.credits[1].private, ["units/", "tilesets/pack.ron"]);
595 assert_eq!(file.credits[1].group, CreditGroup::Art);
596 let got = &file.credits[0].private;
597 assert!(got.is_empty(), "{got:?}");
598 for bad in [
601 "MIT",
602 "Custom",
603 "Custom ()",
604 "Custom ( )",
605 "Custom (Dee",
606 "(Dee)",
607 ] {
608 assert_eq!(
609 errors(&with_bought().replace("Custom (Dee)", bad)),
610 [format!(
611 "c.ron:4: credit \"pack\": license \"{bad}\": a bought work's is written \
612 \"Custom (<seller>)\""
613 )]
614 );
615 }
616 let custom = FILE.replace("\"Zlib\"", "\"Custom (Cy)\"");
617 assert_eq!(errors(&custom).len(), 1);
618 assert!(errors(&custom)[0].contains("is not allowed (ADR-0013: "));
619 for bad in ["", "/units/"] {
620 assert_eq!(
621 errors(&with_bought().replace("\"units/\"", &format!("\"{bad}\""))),
622 [format!(
623 "c.ron:4: credit \"pack\": private path \"{bad}\" must be a path inside \
624 private/"
625 )]
626 );
627 }
628 }
629
630 #[test]
631 fn a_credit_covers_the_files_under_its_private_paths() {
632 let file = from_source("c.ron", &with_bought()).unwrap_or_default();
633 let pack = &file.credits[1];
634 assert!(pack.covers("units/a.png"));
635 assert!(pack.covers("tilesets/pack.ron"));
636 assert!(!pack.covers("tilesets/other.ron"));
637 assert!(!pack.covers("portraits/units/a.png"));
638 assert!(!file.credits[0].covers("units/a.png"));
639 let paths = [
640 "README.md",
641 "portraits/README.md",
642 "portraits/a.png",
643 "tilesets/pack.ron",
644 "units/a.png",
645 ];
646 assert_eq!(
647 uncredited(&file, &paths),
648 ["portraits/README.md", "portraits/a.png"]
649 );
650 assert_eq!(
651 uncredited(&CreditsFile::default(), &paths),
652 paths[1..].to_vec()
653 );
654 let got = uncredited(&file, &[]);
655 assert!(got.is_empty(), "{got:?}");
656 }
657
658 #[test]
659 fn a_bought_work_is_shown_only_in_a_build_that_has_its_files() {
660 use CreditGroup::{Art, Fonts, Software};
661 let file = from_source("c.ron", &with_bought()).unwrap_or_default();
662 let none = AudioManifest::default();
663 let without = [(Fonts, "A Font"), (Software, "Table")];
664 assert_eq!(shown(&merge(&file, &none, &[])), without);
665 assert_eq!(shown(&merge(&file, &none, &["portraits/a.png"])), without);
666 let with = merge(&file, &none, &["portraits/a.png", "units/a.png"]);
667 assert_eq!(
668 shown(&with),
669 [(Art, "Pack"), (Fonts, "A Font"), (Software, "Table")]
670 );
671 assert_eq!(with.entries[0].license, "Custom (Dee)");
672 let hidden = with_bought().replace("private: [", "hidden: true, private: [");
674 let file = from_source("c.ron", &hidden).unwrap_or_default();
675 assert_eq!(shown(&merge(&file, &none, &["units/a.png"])), without);
676 }
677}