1use bern_ir::{LayoutConfig, ResourceDef, SourceLocation, Srgb, SvgPicture, ThemeMode};
5use bern_target_api::{Diagnostic, TargetConfig, WINDOWS_FRONTENDS};
6use serde::Deserialize;
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Deserialize)]
11#[serde(deny_unknown_fields)]
12struct RawConfig {
13 app: RawApp,
14 languages: RawLanguages,
15 #[serde(default)]
16 theme: RawTheme,
17 protocol: RawProtocol,
18 engine: RawEngine,
19 ui: RawUi,
20 #[serde(default)]
21 targets: BTreeMap<String, RawTarget>,
22 #[serde(default)]
23 libraries: Vec<RawLibraryRef>,
24 #[serde(default)]
25 modules: Vec<RawModuleRef>,
26 resources: Option<RawResources>,
27 layouts: Option<RawLayouts>,
28 #[serde(default)]
31 contributes: BTreeMap<String, Vec<String>>,
32}
33
34#[derive(Debug, Deserialize)]
36#[serde(deny_unknown_fields)]
37struct RawModuleRef {
38 path: String,
39}
40
41#[derive(Debug, Deserialize)]
44#[serde(deny_unknown_fields)]
45struct RawModuleConfig {
46 module: RawModule,
47 languages: RawLanguages,
48 #[serde(default)]
49 pages: Vec<RawPage>,
50 #[serde(default)]
52 contributes: BTreeMap<String, Vec<String>>,
53}
54
55#[derive(Debug, Deserialize)]
57#[serde(deny_unknown_fields)]
58struct RawPage {
59 slot: String,
60 id: String,
61 label: BTreeMap<String, String>,
62 icon: Option<String>,
63 badge: Option<String>,
64 when: Option<String>,
67 #[serde(default)]
69 start: bool,
70 tab: Option<bool>,
72 component: String,
73}
74
75#[derive(Debug, Deserialize)]
76#[serde(deny_unknown_fields)]
77struct RawModule {
78 id: String,
79 name: BTreeMap<String, String>,
80 #[serde(default)]
81 depends: Vec<String>,
82 #[serde(default)]
83 optional: bool,
84 enabled: Option<bool>,
87 protocol: RawProtocol,
88 engine: RawEngine,
89 ui: Option<RawModuleUi>,
90}
91
92#[derive(Debug, Deserialize)]
93#[serde(deny_unknown_fields)]
94struct RawModuleUi {
95 package: String,
96 sources: RawSources,
97}
98
99#[derive(Debug, Deserialize)]
102#[serde(untagged)]
103enum RawSources {
104 Directory(String),
105 Files(Vec<String>),
106}
107
108#[derive(Clone, Debug)]
110pub struct ModuleConfig {
111 pub id: String,
112 pub dir: PathBuf,
113 pub manifest: PathBuf,
114 pub name: BTreeMap<String, String>,
115 pub depends: Vec<String>,
116 pub optional: bool,
117 pub enabled: bool,
119 pub protocol_package: Package,
120 pub protocol_source: SourceFile,
121 pub engine_package: Package,
122 pub engine_type: String,
124 pub ui_package: Option<Package>,
125 pub ui_sources: Vec<SourceFile>,
126 pub languages: Vec<String>,
127 pub pages: Vec<PageConfig>,
129 pub contributes: BTreeMap<String, Vec<String>>,
131}
132
133#[derive(Clone, Debug)]
135pub struct PageConfig {
136 pub slot: String,
137 pub id: String,
138 pub label: BTreeMap<String, String>,
139 pub icon: Option<String>,
140 pub badge: Option<String>,
142 pub when: Option<String>,
145 pub start: bool,
148 pub tab: bool,
151 pub component: String,
153}
154
155#[derive(Debug, Deserialize)]
157#[serde(deny_unknown_fields)]
158struct RawLayouts {
159 compact_below: f64,
161 medium_below: Option<f64>,
163 expanded_below: Option<f64>,
166}
167
168#[derive(Debug, Deserialize)]
171#[serde(deny_unknown_fields)]
172struct RawResources {
173 paths: Vec<toml::Spanned<String>>,
174}
175
176#[derive(Debug, Deserialize)]
177#[serde(deny_unknown_fields)]
178struct RawLibraryRef {
179 manifest: String,
180}
181
182#[derive(Debug, Deserialize)]
183#[serde(deny_unknown_fields)]
184struct RawLibraryConfig {
185 library: RawLibrary,
186 languages: RawLanguages,
187}
188
189#[derive(Debug, Deserialize)]
190#[serde(deny_unknown_fields)]
191struct RawLibrary {
192 package: String,
193 sources: Vec<String>,
194}
195
196#[derive(Clone, Debug)]
198pub struct LibraryConfig {
199 pub dir: PathBuf,
200 pub manifest: PathBuf,
201 pub package: Package,
202 pub version: String,
203 pub sources: Vec<SourceFile>,
204 pub languages: Vec<String>,
205 pub default_language: String,
206 pub strict: bool,
207}
208
209#[derive(Debug, Deserialize)]
210#[serde(deny_unknown_fields)]
211struct RawApp {
212 id: String,
213 bundle_id: String,
214 version: String,
215 name: BTreeMap<String, String>,
216 icon: Option<toml::Spanned<RawIcon>>,
219 alternate_icons: Option<BTreeMap<String, toml::Spanned<RawIcon>>>,
222 publisher: Option<String>,
224 #[serde(default)]
226 files_folder: bool,
227 #[serde(default)]
230 background_audio: bool,
231 #[serde(default)]
233 overlays: bool,
234 #[serde(default)]
236 photos: bool,
237}
238
239#[derive(Debug, Deserialize)]
242#[serde(untagged)]
243enum RawIcon {
244 One(String),
245 Platforms(BTreeMap<String, String>),
246}
247
248#[derive(Debug, Deserialize)]
249#[serde(deny_unknown_fields)]
250struct RawLanguages {
251 supported: Vec<String>,
252 default: String,
253 #[serde(default = "yes")]
254 strict: bool,
255}
256
257fn yes() -> bool {
258 true
259}
260
261#[derive(Debug, Default, Deserialize)]
262#[serde(deny_unknown_fields)]
263struct RawTheme {
264 mode: Option<String>,
265 brand_seed: Option<String>,
266}
267
268#[derive(Debug, Deserialize)]
269#[serde(deny_unknown_fields)]
270struct RawProtocol {
271 package: String,
272 source: String,
273}
274
275#[derive(Debug, Deserialize)]
276#[serde(deny_unknown_fields)]
277struct RawEngine {
278 package: String,
279 #[serde(rename = "type")]
280 type_path: String,
281 path: Option<String>,
284}
285
286#[derive(Debug, Deserialize)]
287#[serde(deny_unknown_fields)]
288struct RawUi {
289 package: String,
290 sources: RawSources,
291 root: String,
292}
293
294#[derive(Debug, Deserialize)]
295#[serde(deny_unknown_fields)]
296struct RawTarget {
297 platform: String,
298 ui: Option<String>,
299 web_mode: Option<String>,
300 web_extension: Option<bern_target_api::WebExtension>,
301 application_transport: Option<String>,
302 application_codec: Option<String>,
303 #[serde(default)]
304 channels: Vec<String>,
305 frontends: Option<Vec<String>>,
306 webview: Option<bool>,
307 #[serde(default)]
308 javascript: bool,
309 android: Option<bern_target_api::AndroidOptions>,
310}
311
312#[derive(Clone, Debug)]
314pub struct SourceFile {
315 pub display: String,
317 pub path: PathBuf,
318 pub module: Vec<String>,
320}
321
322#[derive(Clone, Debug)]
323pub struct Package {
324 pub name: String,
325 pub dir: PathBuf,
326}
327
328impl Package {
329 pub fn crate_name(&self) -> String {
331 self.name.replace('-', "_")
332 }
333}
334
335#[derive(Clone, Debug)]
336pub struct Config {
337 pub dir: PathBuf,
338 pub app_id: String,
339 pub bundle_id: String,
340 pub version: String,
341 pub name: BTreeMap<String, String>,
342 pub icon: Option<String>,
345 pub platform_icons: BTreeMap<String, String>,
348 pub alternate_icons: BTreeMap<String, BTreeMap<String, String>>,
351 pub publisher: Option<String>,
353 pub files_folder: bool,
355 pub background_audio: bool,
357 pub overlays: bool,
359 pub photos: bool,
361 pub languages: Vec<String>,
362 pub default_language: String,
363 pub strict: bool,
364 pub theme_mode: ThemeMode,
365 pub brand_seed: Option<Srgb>,
366 pub protocol_package: Package,
367 pub protocol_source: SourceFile,
368 pub engine_package: Package,
369 pub engine_type: String,
370 pub ui_package: Package,
371 pub ui_sources: Vec<SourceFile>,
372 pub root: String,
373 pub targets: Vec<TargetConfig>,
374 pub libraries: Vec<PathBuf>,
376 pub modules: Vec<ModuleConfig>,
378 pub build_modules: bern_syntax::build::Modules,
380 pub contributes: BTreeMap<String, Vec<String>>,
383 pub resources: Vec<ResourceDef>,
386 pub layouts: Option<LayoutConfig>,
388}
389
390fn valid_language(tag: &str) -> bool {
391 let mut parts = tag.split('-');
392 let Some(language) = parts.next() else {
393 return false;
394 };
395 (2..=3).contains(&language.len())
396 && language.chars().all(|c| c.is_ascii_lowercase())
397 && parts.all(|part| {
398 (2..=8).contains(&part.len()) && part.chars().all(|c| c.is_ascii_alphanumeric())
399 })
400}
401
402fn expand_sources(dir: &Path, base: &str, sources: &RawSources) -> Result<Vec<String>, Diagnostic> {
406 let join = |written: &str| {
407 if base.is_empty() {
408 written.to_string()
409 } else {
410 format!("{base}/{written}")
411 }
412 };
413 match sources {
414 RawSources::Files(files) => Ok(files.iter().map(|file| join(file)).collect()),
415 RawSources::Directory(directory) if directory.ends_with(".rs") => Ok(vec![join(directory)]),
416 RawSources::Directory(directory) => {
417 let relative = join(directory.trim_end_matches('/'));
418 let mut found = Vec::new();
419 rust_files(&dir.join(&relative), &relative, &mut found)?;
420 found.sort();
421 if found.is_empty() {
422 return Err(Diagnostic::error(format!(
423 "source directory `{relative}` holds no .rs files"
424 )));
425 }
426 Ok(found)
427 }
428 }
429}
430
431fn rust_files(path: &Path, written: &str, found: &mut Vec<String>) -> Result<(), Diagnostic> {
432 let entries = std::fs::read_dir(path).map_err(|error| {
433 Diagnostic::error(format!(
434 "cannot read source directory {}: {error}",
435 path.display()
436 ))
437 })?;
438 for entry in entries.flatten() {
439 let name = entry.file_name().to_string_lossy().into_owned();
440 if name.starts_with('.') {
441 continue;
442 }
443 let child = format!("{written}/{name}");
444 match entry.file_type() {
445 Ok(kind) if kind.is_dir() => rust_files(&entry.path(), &child, found)?,
446 Ok(kind) if kind.is_file() && name.ends_with(".rs") => found.push(child),
447 _ => {}
448 }
449 }
450 Ok(())
451}
452
453fn source_file(dir: &Path, written: &str) -> Result<(PathBuf, Vec<String>), Diagnostic> {
455 let relative = Path::new(written);
456 let components: Vec<_> = relative
457 .components()
458 .map(|component| component.as_os_str().to_string_lossy().to_string())
459 .collect();
460 let src = components
461 .iter()
462 .position(|part| part == "src")
463 .ok_or_else(|| {
464 Diagnostic::error(format!(
465 "source `{written}` must be inside a package's `src` directory"
466 ))
467 })?;
468 let package_dir = dir.join(components[..src].iter().collect::<PathBuf>());
469 let mut module: Vec<String> = components[src + 1..].to_vec();
470 let file = module
471 .pop()
472 .ok_or_else(|| Diagnostic::error(format!("source `{written}` does not name a file")))?;
473 let stem = file
474 .strip_suffix(".rs")
475 .ok_or_else(|| Diagnostic::error(format!("source `{written}` must be a `.rs` file")))?;
476 if !matches!(stem, "lib" | "main" | "mod") {
477 module.push(stem.to_string());
478 }
479 if matches!(stem, "lib" | "main") && !module.is_empty() {
480 return Err(Diagnostic::error(format!(
481 "`{written}`: only the crate root may be named lib.rs or main.rs"
482 )));
483 }
484 Ok((package_dir, module))
485}
486
487fn read_package_field(dir: &Path, field: &str) -> Result<String, Diagnostic> {
488 let manifest = dir.join("Cargo.toml");
489 let text = std::fs::read_to_string(&manifest).map_err(|error| {
490 Diagnostic::error(format!("cannot read {}: {error}", manifest.display()))
491 })?;
492 let value: toml::Value = text
493 .parse()
494 .map_err(|error| Diagnostic::error(format!("{}: {error}", manifest.display())))?;
495 value
496 .get("package")
497 .and_then(|package| package.get(field))
498 .and_then(|value| match value {
499 toml::Value::String(text) => Some(text.clone()),
500 toml::Value::Table(table) if table.get("workspace").is_some() => {
502 workspace_package_field(dir, field)
503 }
504 _ => None,
505 })
506 .ok_or_else(|| Diagnostic::error(format!("{} has no package {field}", manifest.display())))
507}
508
509fn workspace_package_field(dir: &Path, field: &str) -> Option<String> {
510 let mut directory = dir.parent();
511 while let Some(current) = directory {
512 if let Ok(text) = std::fs::read_to_string(current.join("Cargo.toml"))
513 && let Ok(value) = text.parse::<toml::Value>()
514 && let Some(found) = value
515 .get("workspace")
516 .and_then(|workspace| workspace.get("package"))
517 .and_then(|package| package.get(field))
518 .and_then(toml::Value::as_str)
519 {
520 return Some(found.to_string());
521 }
522 directory = current.parent();
523 }
524 None
525}
526
527fn read_package_name(dir: &Path) -> Result<String, Diagnostic> {
528 let manifest = dir.join("Cargo.toml");
529 let text = std::fs::read_to_string(&manifest).map_err(|error| {
530 Diagnostic::error(format!("cannot read {}: {error}", manifest.display()))
531 })?;
532 let value: toml::Value = text
533 .parse()
534 .map_err(|error| Diagnostic::error(format!("{}: {error}", manifest.display())))?;
535 value
536 .get("package")
537 .and_then(|package| package.get("name"))
538 .and_then(toml::Value::as_str)
539 .map(str::to_string)
540 .ok_or_else(|| Diagnostic::error(format!("{} has no package name", manifest.display())))
541}
542
543fn package(
544 dir: &Path,
545 expected: &str,
546 sources: &[(String, PathBuf)],
547 what: &str,
548) -> Result<Package, Diagnostic> {
549 let package_dir = sources
550 .first()
551 .map(|(_, path)| path.clone())
552 .unwrap_or_else(|| dir.join(expected));
553 if sources.iter().any(|(_, path)| *path != package_dir) {
554 return Err(Diagnostic::error(format!(
555 "all {what} sources must belong to the package `{expected}`"
556 )));
557 }
558 let name = read_package_name(&package_dir)?;
559 if name != expected {
560 return Err(Diagnostic::error(format!(
561 "{what} sources are in package `{name}`, but Bern.toml names `{expected}`"
562 )));
563 }
564 Ok(Package {
565 name,
566 dir: package_dir,
567 })
568}
569
570fn manifest_location(file: &str, text: &str, offset: usize) -> SourceLocation {
572 let before = &text[..offset.min(text.len())];
573 let line_start = before.rfind('\n').map_or(0, |index| index + 1);
574 SourceLocation {
575 file: file.to_string(),
576 line: before.matches('\n').count() as u32 + 1,
577 column: before[line_start..].chars().count() as u32 + 1,
578 }
579}
580
581fn app_icons(
585 dir: &Path,
586 manifest: &str,
587 text: &str,
588 (parent, leaf): (&str, &str),
589 entry: &toml::Spanned<RawIcon>,
590 errors: &mut Vec<Diagnostic>,
591) -> (Option<String>, BTreeMap<String, String>) {
592 let declared = manifest_location(manifest, text, entry.span().start);
593 let mut platforms = BTreeMap::new();
594 match entry.get_ref() {
595 RawIcon::One(written) => (
596 app_icon(
597 dir,
598 &format!("[{parent}].{leaf}"),
599 None,
600 written,
601 &declared,
602 errors,
603 ),
604 platforms,
605 ),
606 RawIcon::Platforms(table) => {
607 let mut every = None;
608 for (key, written) in table {
609 let name = format!("[{parent}.{leaf}].{key}");
610 if key == "default" {
611 every = app_icon(dir, &name, None, written, &declared, errors);
612 } else if bern_target_api::icon::PLATFORMS.contains(&key.as_str()) {
613 if let Some(icon) = app_icon(dir, &name, Some(key), written, &declared, errors)
614 {
615 platforms.insert(key.clone(), icon);
616 }
617 } else {
618 errors.push(
619 Diagnostic::error(format!(
620 "{name}: `{key}` is not a platform; use `default` or one of {}",
621 bern_target_api::icon::PLATFORMS.join(", ")
622 ))
623 .at(declared.clone()),
624 );
625 }
626 }
627 (every, platforms)
628 }
629 }
630}
631
632fn app_icon(
637 dir: &Path,
638 name: &str,
639 platform: Option<&str>,
640 written: &str,
641 declared: &SourceLocation,
642 errors: &mut Vec<Diagnostic>,
643) -> Option<String> {
644 let error = |message: String| Diagnostic::error(message).at(declared.clone());
645 if let Err(message) = bern_protocol::check_resource_name(written) {
646 errors.push(error(format!("{name}: {message}")));
647 return None;
648 }
649 let mut path = dir.to_path_buf();
650 for segment in written.split('/') {
651 path.push(segment);
652 match std::fs::symlink_metadata(&path) {
653 Ok(metadata) if metadata.file_type().is_symlink() => {
654 errors.push(error(format!(
655 "{name} `{written}`: {} is a symbolic link; name the file it points to",
656 path.display()
657 )));
658 return None;
659 }
660 Ok(_) => {}
661 Err(_) => {
662 errors.push(error(format!(
663 "{name} `{written}` does not exist: {}",
664 dir.join(written).display()
665 )));
666 return None;
667 }
668 }
669 }
670 let checked = match platform {
671 Some(platform) if !bern_target_api::icon::is_png_name(written) => {
672 bern_target_api::icon::check_native(platform, &path)
673 }
674 _ if path.is_dir() => Err(format!(
675 "is a folder; {}",
676 match platform {
677 Some(platform) => format!("give {}", bern_target_api::icon::accepted(platform)),
678 None => "give a square PNG".to_string(),
679 }
680 )),
681 _ => std::fs::read(&path)
682 .map_err(|failure| format!("cannot be read: {failure}"))
683 .and_then(|bytes| bern_target_api::icon::check(&bytes).map(drop)),
684 };
685 if let Err(message) = checked {
686 errors.push(error(format!("{name} `{written}` {message}")));
687 return None;
688 }
689 Some(written.to_string())
690}
691
692fn resources(
695 dir: &Path,
696 manifest: &str,
697 text: &str,
698 raw: &RawResources,
699 errors: &mut Vec<Diagnostic>,
700) -> Vec<ResourceDef> {
701 let mut found: Vec<ResourceDef> = Vec::new();
702 for entry in &raw.paths {
703 let written = entry.get_ref().as_str();
704 let declared = manifest_location(manifest, text, entry.span().start);
705 let error = |message: String| Diagnostic::error(message).at(declared.clone());
706 if let Err(message) = bern_protocol::check_resource_name(written) {
707 errors.push(error(message));
708 continue;
709 }
710 let mut path = dir.to_path_buf();
713 let mut kind = None;
714 for segment in written.split('/') {
715 path.push(segment);
716 match std::fs::symlink_metadata(&path) {
717 Ok(metadata) if metadata.file_type().is_symlink() => {
718 kind = Some(Err(format!(
719 "resource `{written}`: {} is a symbolic link; list the file it points to",
720 path.display()
721 )));
722 break;
723 }
724 Ok(metadata) => kind = Some(Ok(metadata.file_type())),
725 Err(_) => {
726 kind = Some(Err(format!(
727 "resource `{written}` does not exist: {}",
728 dir.join(written).display()
729 )));
730 break;
731 }
732 }
733 }
734 match kind {
735 Some(Ok(file_type)) if file_type.is_file() => found.push(ResourceDef {
736 name: written.to_string(),
737 declared,
738 picture: svg_picture(&path),
739 }),
740 Some(Ok(file_type)) if file_type.is_dir() => {
741 let before = found.len();
742 if let Err(message) = directory_files(&path, written, &declared, &mut found) {
743 errors.push(error(message));
744 } else if found.len() == before {
745 errors.push(error(format!(
746 "resource directory `{written}` has no files to ship"
747 )));
748 }
749 }
750 Some(Ok(_)) => errors.push(error(format!(
751 "resource `{written}` is neither a file nor a directory"
752 ))),
753 Some(Err(message)) => errors.push(error(message)),
754 None => {}
755 }
756 }
757 let mut by_name: std::collections::BTreeMap<String, &ResourceDef> = Default::default();
760 for resource in &found {
761 match by_name.get(&resource.name.to_lowercase()) {
762 Some(first) if first.name == resource.name => errors.push(
763 Diagnostic::error(format!(
764 "resource `{}` is listed twice; it is already declared at line {}",
765 resource.name, first.declared.line
766 ))
767 .at(resource.declared.clone()),
768 ),
769 Some(first) => errors.push(
770 Diagnostic::error(format!(
771 "resources `{}` and `{}` differ only in case",
772 first.name, resource.name
773 ))
774 .at(resource.declared.clone()),
775 ),
776 None => {
777 by_name.insert(resource.name.to_lowercase(), resource);
778 }
779 }
780 }
781 found.sort_by(|a, b| a.name.cmp(&b.name));
782 found.dedup_by(|a, b| a.name == b.name);
783 found
784}
785
786fn directory_files(
788 path: &Path,
789 prefix: &str,
790 declared: &SourceLocation,
791 found: &mut Vec<ResourceDef>,
792) -> Result<(), String> {
793 let mut entries: Vec<_> = std::fs::read_dir(path)
794 .and_then(|entries| entries.collect::<Result<Vec<_>, _>>())
795 .map_err(|error| format!("cannot read resource directory {}: {error}", path.display()))?;
796 entries.sort_by_key(|entry| entry.file_name());
797 for entry in entries {
798 let file_name = entry.file_name().to_string_lossy().into_owned();
799 if file_name.starts_with('.') {
800 continue;
801 }
802 let name = format!("{prefix}/{file_name}");
803 bern_protocol::check_resource_name(&name)?;
804 let file_type = entry
805 .file_type()
806 .map_err(|error| format!("cannot read {}: {error}", entry.path().display()))?;
807 if file_type.is_symlink() {
808 return Err(format!(
809 "resource `{name}`: {} is a symbolic link; list the file it points to",
810 entry.path().display()
811 ));
812 } else if file_type.is_dir() {
813 directory_files(&entry.path(), &name, declared, found)?;
814 } else if file_type.is_file() {
815 found.push(ResourceDef {
816 picture: svg_picture(&entry.path()),
817 name,
818 declared: declared.clone(),
819 });
820 } else {
821 return Err(format!(
822 "resource `{name}` is neither a file nor a directory"
823 ));
824 }
825 }
826 Ok(())
827}
828
829fn svg_picture(path: &Path) -> Option<SvgPicture> {
831 if !path
832 .extension()
833 .is_some_and(|extension| extension.eq_ignore_ascii_case("svg"))
834 {
835 return None;
836 }
837 let source = std::fs::read_to_string(path).ok()?;
838 Some(match bern_svg::picture(&source)? {
839 bern_svg::Picture::Still => SvgPicture::Still,
840 bern_svg::Picture::Tinted => SvgPicture::Tinted,
841 bern_svg::Picture::Themed => SvgPicture::Themed,
842 })
843}
844
845fn check_app(app: &RawApp, errors: &mut Vec<Diagnostic>) {
847 if !bern_protocol::valid_name(&app.id) {
848 errors.push(Diagnostic::error(format!(
849 "[app].id `{}` must be lower snake case",
850 app.id
851 )));
852 }
853 let bundle_ok = app.bundle_id.split('.').count() >= 2
854 && app.bundle_id.split('.').all(|part| {
855 !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
856 });
857 if !bundle_ok {
858 errors.push(Diagnostic::error(format!(
859 "[app].bundle_id `{}` must be reverse-DNS, e.g. `com.example.app`",
860 app.bundle_id
861 )));
862 }
863}
864
865fn check_languages(
868 languages: &RawLanguages,
869 name: &BTreeMap<String, String>,
870 errors: &mut Vec<Diagnostic>,
871) {
872 let mut seen = std::collections::BTreeSet::new();
873 for tag in &languages.supported {
874 if !valid_language(tag) {
875 errors.push(Diagnostic::error(format!(
876 "language tag `{tag}` is not a standard tag such as `zh-Hans` or `en`"
877 )));
878 }
879 if !seen.insert(tag) {
880 errors.push(Diagnostic::error(format!(
881 "language `{tag}` is listed twice"
882 )));
883 }
884 }
885 if languages.supported.is_empty() {
886 errors.push(Diagnostic::error("[languages].supported is empty"));
887 }
888 if !languages.supported.contains(&languages.default) {
889 errors.push(Diagnostic::error(format!(
890 "default language `{}` is not in [languages].supported",
891 languages.default
892 )));
893 }
894 for language in name.keys() {
895 if !languages.supported.contains(language) {
896 errors.push(Diagnostic::error(format!(
897 "[app].name has language `{language}`, which is not supported"
898 )));
899 }
900 }
901 if languages.strict {
902 for language in &languages.supported {
903 if !name.contains_key(language) {
904 errors.push(
905 Diagnostic::error(format!(
906 "Missing translation\nProperty: [app].name\nMissing language: {language}"
907 ))
908 .code("B102"),
909 );
910 }
911 }
912 }
913}
914
915fn theme_mode(theme: &RawTheme, errors: &mut Vec<Diagnostic>) -> ThemeMode {
917 match theme.mode.as_deref().unwrap_or("system") {
918 "system" => ThemeMode::System,
919 "light" => ThemeMode::Light,
920 "dark" => ThemeMode::Dark,
921 other => {
922 errors.push(Diagnostic::error(format!(
923 "[theme].mode `{other}` must be system, light or dark"
924 )));
925 ThemeMode::System
926 }
927 }
928}
929
930fn layout_config(
932 layouts: Option<&RawLayouts>,
933 errors: &mut Vec<Diagnostic>,
934) -> Option<LayoutConfig> {
935 match layouts {
936 None => None,
937 Some(layouts) if layouts.compact_below.is_finite() && layouts.compact_below > 0.0 => {
938 let mut previous = ("compact_below", layouts.compact_below);
940 let mut ordered = true;
941 for (name, value) in [
942 ("medium_below", layouts.medium_below),
943 ("expanded_below", layouts.expanded_below),
944 ] {
945 let Some(value) = value else { continue };
946 if !value.is_finite() || value <= previous.1 {
947 errors.push(Diagnostic::error(format!(
948 "[layouts].{name} must be wider than {} ({}), not {value}",
949 previous.0, previous.1
950 )));
951 ordered = false;
952 }
953 previous = (name, value);
954 }
955 ordered.then_some(LayoutConfig {
956 compact_below: layouts.compact_below,
957 medium_below: layouts.medium_below,
958 expanded_below: layouts.expanded_below,
959 })
960 }
961 Some(layouts) => {
962 errors.push(Diagnostic::error(format!(
963 "[layouts].compact_below must be a positive width, not {}",
964 layouts.compact_below
965 )));
966 None
967 }
968 }
969}
970
971fn brand_seed(theme: &RawTheme, errors: &mut Vec<Diagnostic>) -> Option<Srgb> {
973 let seed = theme.brand_seed.as_ref()?;
974 let parsed = Srgb::parse(seed);
975 if parsed.is_none() {
976 errors.push(Diagnostic::error(format!(
977 "[theme].brand_seed `{seed}` must be a `#RRGGBB` color"
978 )));
979 }
980 parsed
981}
982
983struct AppSources {
986 protocol_source: Option<SourceFile>,
987 protocol_package: Option<Package>,
988 ui_sources: Vec<SourceFile>,
989 ui_package: Option<Package>,
990 engine_package: Option<Package>,
991}
992
993fn app_sources(
996 dir: &Path,
997 protocol: &RawProtocol,
998 ui: &RawUi,
999 engine: &RawEngine,
1000 errors: &mut Vec<Diagnostic>,
1001) -> AppSources {
1002 let ui_written = expand_sources(dir, "", &ui.sources).unwrap_or_else(|error| {
1003 errors.push(error);
1004 Vec::new()
1005 });
1006 let mut resolve = |written: &str| -> Option<SourceFile> {
1007 match source_file(dir, written) {
1008 Ok((_, module)) => Some(SourceFile {
1009 display: written.to_string(),
1010 path: dir.join(written),
1011 module,
1012 }),
1013 Err(error) => {
1014 errors.push(error);
1015 None
1016 }
1017 }
1018 };
1019 let protocol_source = resolve(&protocol.source);
1020 let ui_sources: Vec<SourceFile> = ui_written.iter().filter_map(|s| resolve(s)).collect();
1021
1022 let protocol_package = protocol_source.as_ref().and_then(|source| {
1023 let package_dir = source_file(dir, &source.display).ok()?.0;
1024 package(
1025 dir,
1026 &protocol.package,
1027 &[(source.display.clone(), package_dir)],
1028 "protocol",
1029 )
1030 .map_err(|error| errors.push(error))
1031 .ok()
1032 });
1033 let ui_dirs: Vec<(String, PathBuf)> = ui_sources
1034 .iter()
1035 .filter_map(|source| {
1036 source_file(dir, &source.display)
1037 .ok()
1038 .map(|(package_dir, _)| (source.display.clone(), package_dir))
1039 })
1040 .collect();
1041 let ui_package = package(dir, &ui.package, &ui_dirs, "UI")
1042 .map_err(|error| errors.push(error))
1043 .ok();
1044 let engine_dir = dir.join(engine.path.as_deref().unwrap_or(&engine.package));
1045 let engine_package = package(
1046 dir,
1047 &engine.package,
1048 &[(engine.package.clone(), engine_dir)],
1049 "engine",
1050 )
1051 .map_err(|error| errors.push(error))
1052 .ok();
1053 if ui_written.is_empty() {
1054 errors.push(Diagnostic::error("[ui].sources lists no files"));
1055 }
1056 AppSources {
1057 protocol_source,
1058 protocol_package,
1059 ui_sources,
1060 ui_package,
1061 engine_package,
1062 }
1063}
1064
1065fn target_config(name: String, target: RawTarget, errors: &mut Vec<Diagnostic>) -> TargetConfig {
1067 check_web_mode(&name, &target, errors);
1068 if target.platform == "browser" && target.webview == Some(false) {
1069 errors.push(Diagnostic::error(format!(
1070 "target `{name}`: the browser provides JavaScript; webview cannot be disabled"
1071 )));
1072 }
1073 if target.platform == "tvos" && target.webview == Some(true) {
1074 errors.push(Diagnostic::error(format!(
1075 "target `{name}`: tvOS has no WebView; omit webview or set it to false"
1076 )));
1077 }
1078 if target.javascript && target.web_mode.as_deref() == Some("ui") {
1079 errors.push(Diagnostic::error(format!(
1080 "target `{name}`: JavaScript hosting currently requires web_mode = \"app\""
1081 )));
1082 }
1083 if target.web_extension.is_some() && target.platform != "browser" {
1084 errors.push(Diagnostic::error(format!(
1085 "target `{name}`: web_extension requires a browser target"
1086 )));
1087 }
1088 if target.android.is_some() && target.platform != "android" {
1089 errors.push(Diagnostic::error(format!(
1090 "target `{name}`: android host options require an Android target"
1091 )));
1092 }
1093
1094 let frontends = windows_frontends(&name, &target.platform, target.frontends, errors);
1095 for channel in &target.channels {
1096 if channel != "ipc" {
1097 errors.push(Diagnostic::error(format!(
1098 "target `{name}`: unknown channel `{channel}`; supported: ipc"
1099 )));
1100 }
1101 }
1102 TargetConfig {
1103 name,
1104 platform: target.platform,
1105 ui: target.ui,
1106 web_mode: target.web_mode,
1107 web_extension: target.web_extension,
1108 channels: target.channels,
1109 frontends,
1110 webview: target.webview,
1111 javascript: target.javascript,
1112 android: target.android,
1113 }
1114}
1115
1116fn check_web_mode(name: &str, target: &RawTarget, errors: &mut Vec<Diagnostic>) {
1120 if target.platform == "browser" {
1121 match target.web_mode.as_deref() {
1122 None => errors.push(Diagnostic::error(format!(
1123 "target `{name}`: browser targets must set `web_mode = \"app\"` or `\"ui\"`"
1124 ))),
1125 Some("app") | Some("ui") => {}
1126 Some(other) => errors.push(Diagnostic::error(format!(
1127 "target `{name}`: `web_mode = \"{other}\"` is unknown; use \"app\" or \"ui\""
1128 ))),
1129 }
1130 let (transport, codec) = match target.web_mode.as_deref() {
1134 Some("ui") => ("websocket", Some("json")),
1135 _ => ("in-process", None),
1136 };
1137 if let Some(declared) = &target.application_transport
1138 && declared != transport
1139 {
1140 errors.push(Diagnostic::error(format!(
1141 "target `{name}`: `web_mode = \"{}\"` uses `application_transport = \"{transport}\"`, not `\"{declared}\"`",
1142 target.web_mode.as_deref().unwrap_or("app")
1143 )));
1144 }
1145 if let Some(declared) = &target.application_codec
1146 && Some(declared.as_str()) != codec
1147 {
1148 errors.push(Diagnostic::error(format!(
1149 "target `{name}`: `application_codec = \"{declared}\"` does not apply; {}",
1150 match codec {
1151 Some(codec) => format!("the WebUI channel uses `{codec}`"),
1152 None => "the in-page engine is called directly".into(),
1153 }
1154 )));
1155 }
1156 } else if target.web_mode.is_some() {
1157 errors.push(Diagnostic::error(format!(
1158 "target `{name}`: `web_mode` applies to browser targets only"
1159 )));
1160 }
1161}
1162
1163fn windows_frontends(
1166 name: &str,
1167 platform: &str,
1168 frontends: Option<Vec<String>>,
1169 errors: &mut Vec<Diagnostic>,
1170) -> Vec<String> {
1171 match (platform, frontends) {
1172 ("windows", None) => WINDOWS_FRONTENDS.map(str::to_string).to_vec(),
1173 ("windows", Some(frontends)) => {
1174 if frontends.is_empty() {
1175 errors.push(Diagnostic::error(format!(
1176 "target `{name}`: `frontends` lists none; name \"winui3\", \"xaml-islands\" or both"
1177 )));
1178 }
1179 for (index, frontend) in frontends.iter().enumerate() {
1180 if !WINDOWS_FRONTENDS.contains(&frontend.as_str()) {
1181 errors.push(Diagnostic::error(format!(
1182 "target `{name}`: unknown front end `{frontend}`; use \"winui3\" or \"xaml-islands\""
1183 )));
1184 } else if frontends[..index].contains(frontend) {
1185 errors.push(Diagnostic::error(format!(
1186 "target `{name}`: `frontends` lists `{frontend}` twice"
1187 )));
1188 }
1189 }
1190 frontends
1191 }
1192 (_, Some(_)) => {
1193 errors.push(Diagnostic::error(format!(
1194 "target `{name}`: `frontends` applies to Windows targets only"
1195 )));
1196 Vec::new()
1197 }
1198 (_, None) => Vec::new(),
1199 }
1200}
1201
1202fn library_manifests(
1204 dir: &Path,
1205 libraries: &[RawLibraryRef],
1206 errors: &mut Vec<Diagnostic>,
1207) -> Vec<PathBuf> {
1208 let libraries: Vec<PathBuf> = libraries
1209 .iter()
1210 .map(|library| dir.join(&library.manifest))
1211 .collect();
1212 for library in &libraries {
1213 if !library.is_file() {
1214 errors.push(Diagnostic::error(format!(
1215 "library manifest {} does not exist",
1216 library.display()
1217 )));
1218 }
1219 }
1220 libraries
1221}
1222
1223fn load_modules(
1226 dir: &Path,
1227 entries: &[RawModuleRef],
1228 languages: &[String],
1229 selection: &bern_syntax::build::Modules,
1230 errors: &mut Vec<Diagnostic>,
1231) -> Vec<ModuleConfig> {
1232 let mut modules: Vec<ModuleConfig> = Vec::new();
1233 for entry in entries {
1234 let manifest = dir.join(&entry.path).join("Bern.toml");
1235 let id = std::fs::read_to_string(&manifest)
1236 .ok()
1237 .and_then(|text| text.parse::<toml::Value>().ok())
1238 .and_then(|value| value.get("module")?.get("id")?.as_str().map(str::to_string));
1239 if id
1240 .as_ref()
1241 .is_some_and(|id| selection.0.get(id) == Some(&false))
1242 {
1243 continue;
1244 }
1245 match load_module(dir, &entry.path) {
1246 Ok(module) => {
1247 if modules.iter().any(|other| other.id == module.id) {
1248 errors.push(Diagnostic::error(format!(
1249 "module `{}` is listed twice",
1250 module.id
1251 )));
1252 }
1253 for language in languages {
1254 if !module.languages.contains(language) {
1255 errors.push(Diagnostic::error(format!(
1256 "module `{}` does not translate `{language}`, which the application supports; add it to {}",
1257 module.id,
1258 module.manifest.display()
1259 )));
1260 }
1261 }
1262 modules.push(module);
1263 }
1264 Err(module_errors) => errors.extend(module_errors),
1265 }
1266 }
1267 check_module_order(&modules, errors);
1268 modules
1269}
1270
1271fn check_module_order(modules: &[ModuleConfig], errors: &mut Vec<Diagnostic>) {
1274 for (index, module) in modules.iter().enumerate() {
1275 for dependency in &module.depends {
1276 match modules.iter().position(|other| &other.id == dependency) {
1277 Some(position) if position < index && modules[position].optional && !module.optional => {
1278 errors.push(Diagnostic::error(format!(
1279 "module `{}` always runs but depends on the optional module `{dependency}`; make it optional too",
1280 module.id
1281 )))
1282 }
1283 Some(position) if position < index => {}
1284 Some(_) => errors.push(Diagnostic::error(format!(
1285 "module `{}` depends on `{dependency}`, which Bern.toml lists after it; list modules after the ones they depend on",
1286 module.id
1287 ))),
1288 None => errors.push(Diagnostic::error(format!(
1289 "module `{}` depends on `{dependency}`, which is not a module of this application",
1290 module.id
1291 ))),
1292 }
1293 }
1294 }
1295}
1296
1297fn check_contributes(
1299 contributes: &BTreeMap<String, Vec<String>>,
1300 manifest: &str,
1301 errors: &mut Vec<Diagnostic>,
1302) {
1303 for slot in contributes.keys() {
1304 if !bern_protocol::valid_name(slot) {
1305 errors.push(Diagnostic::error(format!(
1306 "{manifest}: slot `{slot}` must be lower snake case"
1307 )));
1308 }
1309 }
1310}
1311
1312fn module_selection(
1314 dir: &Path,
1315 entries: &[RawModuleRef],
1316 excluded: &[String],
1317) -> Result<bern_syntax::build::Modules, Vec<Diagnostic>> {
1318 let mut catalog = BTreeMap::<String, Vec<String>>::new();
1319 let mut errors = Vec::new();
1320 for entry in entries {
1321 let path = dir.join(&entry.path).join("Bern.toml");
1322 let result = std::fs::read_to_string(&path)
1323 .map_err(|e| e.to_string())
1324 .and_then(|text| text.parse::<toml::Value>().map_err(|e| e.to_string()));
1325 match result {
1326 Ok(value) => {
1327 let module = value.get("module");
1328 if let Some(id) = module
1329 .and_then(|m| m.get("id"))
1330 .and_then(toml::Value::as_str)
1331 {
1332 let depends = module
1333 .and_then(|m| m.get("depends"))
1334 .and_then(toml::Value::as_array)
1335 .into_iter()
1336 .flatten()
1337 .filter_map(toml::Value::as_str)
1338 .map(str::to_string)
1339 .collect();
1340 if !bern_protocol::valid_name(id) {
1341 errors.push(Diagnostic::error(format!(
1342 "invalid module id `{id}` in {}",
1343 path.display()
1344 )));
1345 }
1346 if catalog.insert(id.to_string(), depends).is_some() {
1347 errors.push(Diagnostic::error(format!("module `{id}` is listed twice")));
1348 }
1349 } else {
1350 errors.push(Diagnostic::error(format!(
1351 "{} needs [module].id",
1352 path.display()
1353 )));
1354 }
1355 }
1356 Err(e) => errors.push(Diagnostic::error(format!("{}: {e}", path.display()))),
1357 }
1358 }
1359 for id in excluded {
1360 if !catalog.contains_key(id) {
1361 errors.push(Diagnostic::error(format!(
1362 "unknown module `{id}` in --exclude-module; declared modules: {}",
1363 catalog.keys().cloned().collect::<Vec<_>>().join(", ")
1364 )));
1365 }
1366 }
1367 fn chain(
1368 id: &str,
1369 catalog: &BTreeMap<String, Vec<String>>,
1370 excluded: &[String],
1371 path: &mut Vec<String>,
1372 ) -> Option<Vec<String>> {
1373 if path.iter().any(|p| p == id) {
1374 return None;
1375 }
1376 path.push(id.to_string());
1377 if excluded.iter().any(|e| e == id) {
1378 return Some(path.clone());
1379 }
1380 for dep in catalog.get(id).into_iter().flatten() {
1381 if let Some(found) = chain(dep, catalog, excluded, path) {
1382 return Some(found);
1383 }
1384 }
1385 path.pop();
1386 None
1387 }
1388 for id in catalog.keys().filter(|id| !excluded.contains(id)) {
1389 if let Some(path) = chain(id, &catalog, excluded, &mut Vec::new()) {
1390 errors.push(Diagnostic::error(format!("cannot exclude module `{}`: retained dependency chain {}; also exclude the dependent module or keep its dependency", path.last().unwrap(), path.join(" -> "))));
1391 }
1392 }
1393 if !errors.is_empty() {
1394 return Err(errors);
1395 }
1396 Ok(bern_syntax::build::Modules(
1397 catalog
1398 .keys()
1399 .map(|id| (id.clone(), !excluded.contains(id)))
1400 .collect(),
1401 ))
1402}
1403
1404pub fn load(path: &Path) -> Result<Config, Vec<Diagnostic>> {
1406 load_excluding(path, &[])
1407}
1408
1409pub fn load_excluding(path: &Path, excluded: &[String]) -> Result<Config, Vec<Diagnostic>> {
1411 let text = std::fs::read_to_string(path).map_err(|error| {
1412 vec![Diagnostic::error(format!(
1413 "cannot read {}: {error}",
1414 path.display()
1415 ))]
1416 })?;
1417 let raw: RawConfig = toml::from_str(&text)
1418 .map_err(|error| vec![Diagnostic::error(format!("{}: {error}", path.display()))])?;
1419 let dir = path
1420 .parent()
1421 .map(Path::to_path_buf)
1422 .unwrap_or_else(|| PathBuf::from("."));
1423 let dir = canonical(dir);
1424 let mut errors = Vec::new();
1425
1426 check_app(&raw.app, &mut errors);
1427 check_languages(&raw.languages, &raw.app.name, &mut errors);
1428 let theme_mode = theme_mode(&raw.theme, &mut errors);
1429 let layouts = layout_config(raw.layouts.as_ref(), &mut errors);
1430 let brand_seed = brand_seed(&raw.theme, &mut errors);
1431 let sources = app_sources(&dir, &raw.protocol, &raw.ui, &raw.engine, &mut errors);
1432 let targets: Vec<TargetConfig> = raw
1433 .targets
1434 .into_iter()
1435 .map(|(name, target)| target_config(name, target, &mut errors))
1436 .collect();
1437 let libraries = library_manifests(&dir, &raw.libraries, &mut errors);
1438 let build_modules = module_selection(&dir, &raw.modules, excluded)?;
1439 let modules = load_modules(
1440 &dir,
1441 &raw.modules,
1442 &raw.languages.supported,
1443 &build_modules,
1444 &mut errors,
1445 );
1446 let manifest = path.display().to_string();
1447 check_contributes(&raw.contributes, &manifest, &mut errors);
1448 let resources = raw
1449 .resources
1450 .as_ref()
1451 .map(|raw| resources(&dir, &manifest, &text, raw, &mut errors))
1452 .unwrap_or_default();
1453 let (icon, platform_icons) = raw
1454 .app
1455 .icon
1456 .as_ref()
1457 .map(|entry| app_icons(&dir, &manifest, &text, ("app", "icon"), entry, &mut errors))
1458 .unwrap_or_default();
1459 let mut alternate_icons = BTreeMap::new();
1460 for (name, entry) in raw.app.alternate_icons.iter().flatten() {
1461 let named = name.starts_with(|c: char| c.is_ascii_lowercase())
1464 && name
1465 .chars()
1466 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
1467 && name != "default";
1468 if !named {
1469 errors.push(
1470 Diagnostic::error(format!(
1471 "[app.alternate_icons].{name}: name an alternate icon with lowercase letters, digits and `_`, starting with a letter; `default` is `[app.icon]`"
1472 ))
1473 .at(manifest_location(&manifest, &text, entry.span().start)),
1474 );
1475 continue;
1476 }
1477 let (every, mut entries) = app_icons(
1478 &dir,
1479 &manifest,
1480 &text,
1481 ("app.alternate_icons", name),
1482 entry,
1483 &mut errors,
1484 );
1485 if let Some(every) = every {
1486 entries.insert("default".to_string(), every);
1487 }
1488 if !entries.is_empty() {
1489 alternate_icons.insert(name.clone(), entries);
1490 }
1491 }
1492
1493 if !errors.is_empty() {
1494 return Err(errors);
1495 }
1496 Ok(Config {
1497 dir,
1498 app_id: raw.app.id,
1499 bundle_id: raw.app.bundle_id,
1500 version: raw.app.version,
1501 name: raw.app.name,
1502 icon,
1503 platform_icons,
1504 alternate_icons,
1505 publisher: raw
1506 .app
1507 .publisher
1508 .map(|publisher| publisher.trim().to_string())
1509 .filter(|publisher| !publisher.is_empty()),
1510 files_folder: raw.app.files_folder,
1511 background_audio: raw.app.background_audio,
1512 overlays: raw.app.overlays,
1513 photos: raw.app.photos,
1514 languages: raw.languages.supported,
1515 default_language: raw.languages.default,
1516 strict: raw.languages.strict,
1517 theme_mode,
1518 brand_seed,
1519 protocol_package: sources.protocol_package.unwrap(),
1520 protocol_source: sources.protocol_source.unwrap(),
1521 engine_package: sources.engine_package.unwrap(),
1522 engine_type: raw.engine.type_path,
1523 ui_package: sources.ui_package.unwrap(),
1524 ui_sources: sources.ui_sources,
1525 root: raw.ui.root,
1526 targets,
1527 libraries,
1528 modules,
1529 build_modules,
1530 contributes: raw.contributes,
1531 resources,
1532 layouts,
1533 })
1534}
1535
1536fn load_module(dir: &Path, written: &str) -> Result<ModuleConfig, Vec<Diagnostic>> {
1539 let base = written.trim_end_matches('/');
1540 let manifest = dir.join(base).join("Bern.toml");
1541 let text = std::fs::read_to_string(&manifest).map_err(|error| {
1542 vec![Diagnostic::error(format!(
1543 "cannot read module manifest {}: {error}",
1544 manifest.display()
1545 ))]
1546 })?;
1547 let raw: RawModuleConfig = toml::from_str(&text).map_err(|error| {
1548 vec![Diagnostic::error(format!(
1549 "{}: {error}",
1550 manifest.display()
1551 ))]
1552 })?;
1553 let mut errors = Vec::new();
1554 let id = raw.module.id;
1555 if !bern_protocol::valid_name(&id) {
1556 errors.push(Diagnostic::error(format!(
1557 "{}: module id `{id}` must be lower snake case",
1558 manifest.display()
1559 )));
1560 }
1561 let mut resolve = |written: &str| -> Option<SourceFile> {
1562 match source_file(dir, written) {
1563 Ok((_, module)) => Some(SourceFile {
1564 display: written.to_string(),
1565 path: dir.join(written),
1566 module,
1567 }),
1568 Err(error) => {
1569 errors.push(error);
1570 None
1571 }
1572 }
1573 };
1574 let protocol_source = resolve(&format!("{base}/{}", raw.module.protocol.source));
1575 let mut ui_sources = Vec::new();
1576 let mut ui_dirs = Vec::new();
1577 if let Some(ui) = &raw.module.ui {
1578 match expand_sources(dir, base, &ui.sources) {
1579 Ok(files) => {
1580 for file in files {
1581 if let Ok((package_dir, _)) = source_file(dir, &file) {
1582 ui_dirs.push((file.clone(), package_dir));
1583 }
1584 ui_sources.extend(resolve(&file));
1585 }
1586 }
1587 Err(error) => errors.push(error),
1588 }
1589 }
1590 let protocol_package = protocol_source.as_ref().and_then(|source| {
1591 let package_dir = source_file(dir, &source.display).ok()?.0;
1592 package(
1593 dir,
1594 &raw.module.protocol.package,
1595 &[(source.display.clone(), package_dir)],
1596 "protocol",
1597 )
1598 .map_err(|error| errors.push(error))
1599 .ok()
1600 });
1601 let ui_package = raw.module.ui.as_ref().and_then(|ui| {
1602 package(dir, &ui.package, &ui_dirs, "UI")
1603 .map_err(|error| errors.push(error))
1604 .ok()
1605 });
1606 let engine_dir = dir.join(base).join(
1607 raw.module
1608 .engine
1609 .path
1610 .as_deref()
1611 .unwrap_or(&raw.module.engine.package),
1612 );
1613 let engine_package = package(
1614 dir,
1615 &raw.module.engine.package,
1616 &[(raw.module.engine.package.clone(), engine_dir)],
1617 "engine",
1618 )
1619 .map_err(|error| errors.push(error))
1620 .ok();
1621 for page in &raw.pages {
1622 for (what, field) in [("badge", &page.badge), ("when", &page.when)] {
1623 if let Some(field) = field
1624 && !bern_protocol::valid_name(field)
1625 {
1626 errors.push(Diagnostic::error(format!(
1627 "{}: page {what} `{field}` must name a lower snake case state field",
1628 manifest.display()
1629 )));
1630 }
1631 }
1632 for (what, name) in [("slot", &page.slot), ("page id", &page.id)] {
1633 if !bern_protocol::valid_name(name) {
1634 errors.push(Diagnostic::error(format!(
1635 "{}: {what} `{name}` must be lower snake case",
1636 manifest.display()
1637 )));
1638 }
1639 }
1640 if raw.module.ui.is_none() {
1641 errors.push(Diagnostic::error(format!(
1642 "{}: page `{}` needs the module's [module.ui] to show it",
1643 manifest.display(),
1644 page.id
1645 )));
1646 }
1647 }
1648 for slot in raw.contributes.keys() {
1649 if !bern_protocol::valid_name(slot) {
1650 errors.push(Diagnostic::error(format!(
1651 "{}: slot `{slot}` must be lower snake case",
1652 manifest.display()
1653 )));
1654 }
1655 }
1656 if raw.module.enabled.is_some() && !raw.module.optional {
1657 errors.push(Diagnostic::error(format!(
1658 "{}: `enabled` applies to optional modules; add `optional = true` or remove it",
1659 manifest.display()
1660 )));
1661 }
1662 if !errors.is_empty() {
1663 return Err(errors);
1664 }
1665 Ok(ModuleConfig {
1666 id,
1667 dir: canonical(dir.join(base)),
1668 manifest,
1669 name: raw.module.name,
1670 depends: raw.module.depends,
1671 optional: raw.module.optional,
1672 enabled: raw.module.enabled.unwrap_or(true),
1673 protocol_package: protocol_package.unwrap(),
1674 protocol_source: protocol_source.unwrap(),
1675 engine_package: engine_package.unwrap(),
1676 engine_type: raw.module.engine.type_path,
1677 ui_package,
1678 ui_sources,
1679 languages: raw.languages.supported,
1680 pages: raw
1681 .pages
1682 .into_iter()
1683 .map(|page| PageConfig {
1684 slot: page.slot,
1685 id: page.id,
1686 label: page.label,
1687 icon: page.icon,
1688 badge: page.badge,
1689 when: page.when,
1690 start: page.start,
1691 tab: page.tab.unwrap_or(true),
1692 component: page.component,
1693 })
1694 .collect(),
1695 contributes: raw.contributes,
1696 })
1697}
1698
1699pub fn load_library(path: &Path) -> Result<LibraryConfig, Vec<Diagnostic>> {
1701 let text = std::fs::read_to_string(path).map_err(|error| {
1702 vec![Diagnostic::error(format!(
1703 "cannot read {}: {error}",
1704 path.display()
1705 ))]
1706 })?;
1707 let raw: RawLibraryConfig = toml::from_str(&text)
1708 .map_err(|error| vec![Diagnostic::error(format!("{}: {error}", path.display()))])?;
1709 let dir = path
1710 .parent()
1711 .map(Path::to_path_buf)
1712 .unwrap_or_else(|| PathBuf::from("."));
1713 let dir = canonical(dir);
1714 let mut errors = Vec::new();
1715 let mut sources = Vec::new();
1716 let mut dirs = Vec::new();
1717 for written in &raw.library.sources {
1718 match source_file(&dir, written) {
1719 Ok((package_dir, module)) => {
1720 dirs.push((written.clone(), package_dir));
1721 sources.push(SourceFile {
1722 display: format!("{}/{written}", raw.library.package),
1723 path: dir.join(written),
1724 module,
1725 });
1726 }
1727 Err(error) => errors.push(error),
1728 }
1729 }
1730 for tag in &raw.languages.supported {
1731 if !valid_language(tag) {
1732 errors.push(Diagnostic::error(format!(
1733 "language tag `{tag}` is not a standard tag"
1734 )));
1735 }
1736 }
1737 let package = package(&dir, &raw.library.package, &dirs, "library")
1738 .map_err(|error| errors.push(error))
1739 .ok();
1740 let version = package.as_ref().and_then(|package| {
1741 read_package_field(&package.dir, "version")
1742 .map_err(|error| errors.push(error))
1743 .ok()
1744 });
1745 if !errors.is_empty() {
1746 return Err(errors);
1747 }
1748 Ok(LibraryConfig {
1749 dir,
1750 manifest: path.to_path_buf(),
1751 package: package.unwrap(),
1752 version: version.unwrap(),
1753 sources,
1754 languages: raw.languages.supported,
1755 default_language: raw.languages.default,
1756 strict: raw.languages.strict,
1757 })
1758}
1759
1760fn canonical(dir: PathBuf) -> PathBuf {
1763 let Ok(path) = dir.canonicalize() else {
1764 return dir;
1765 };
1766 if cfg!(windows)
1767 && let Some(rest) = path.to_string_lossy().strip_prefix(r"\\?\")
1768 && !rest.starts_with(r"UNC\")
1769 {
1770 return PathBuf::from(rest);
1771 }
1772 path
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777 use super::*;
1778
1779 #[test]
1780 fn android_host_options_are_explicit_and_platform_scoped() {
1781 let parse = |text: &str| toml::from_str::<RawTarget>(text).unwrap();
1782 let mut errors = Vec::new();
1783 let target = target_config(
1784 "phone".into(),
1785 parse("platform = 'android'\nandroid = { network = true, multicast = true }"),
1786 &mut errors,
1787 );
1788 assert!(errors.is_empty(), "{errors:?}");
1789 let options = target.android.unwrap();
1790 assert!(options.network && options.multicast);
1791
1792 let default = target_config("phone".into(), parse("platform = 'android'"), &mut errors);
1793 assert!(default.android.is_none());
1794 target_config(
1795 "desktop".into(),
1796 parse("platform = 'macos'\nandroid = { network = true }"),
1797 &mut errors,
1798 );
1799 assert_eq!(errors.len(), 1);
1800 assert!(errors[0].message.contains("require an Android target"));
1801 assert!(
1802 toml::from_str::<RawTarget>("platform = 'android'\nandroid = { netwrok = true }")
1803 .is_err()
1804 );
1805 }
1806
1807 #[test]
1808 fn language_tags_are_validated() {
1809 for tag in ["zh-Hans", "en", "ja", "pt-BR", "zh-Hant-TW"] {
1810 assert!(valid_language(tag), "{tag}");
1811 }
1812 for tag in ["", "EN", "english", "zh_Hans", "zh-"] {
1813 assert!(!valid_language(tag), "{tag}");
1814 }
1815 }
1816
1817 fn check_resources(files: &[&str], entries: &str) -> (Vec<ResourceDef>, Vec<Diagnostic>) {
1821 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1822 let dir = std::env::temp_dir().join(format!(
1823 "bern-resources-test-{}-{}",
1824 std::process::id(),
1825 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1826 ));
1827 let _ = std::fs::remove_dir_all(&dir);
1828 std::fs::create_dir_all(&dir).unwrap();
1829 for file in files {
1830 let path = dir.join(file);
1831 if file.ends_with('/') {
1832 std::fs::create_dir_all(&path).unwrap();
1833 } else {
1834 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1835 std::fs::write(&path, b"x").unwrap();
1836 }
1837 }
1838 #[derive(Deserialize)]
1839 struct Manifest {
1840 resources: RawResources,
1841 }
1842 let text = format!("# Bern.toml\n[resources]\npaths = [\n{entries}\n]\n");
1843 let raw: Manifest = toml::from_str(&text).unwrap();
1844 let mut errors = Vec::new();
1845 let found = resources(&dir, "Bern.toml", &text, &raw.resources, &mut errors);
1846 let _ = std::fs::remove_dir_all(&dir);
1847 (found, errors)
1848 }
1849
1850 fn names(found: &[ResourceDef]) -> Vec<&str> {
1851 found
1852 .iter()
1853 .map(|resource| resource.name.as_str())
1854 .collect()
1855 }
1856
1857 #[test]
1858 fn resources_expand_directories_and_point_at_their_entries() {
1859 let (found, errors) = check_resources(
1860 &[
1861 "media/b.mp4",
1862 "data/seed.json",
1863 "data/nested/more.json",
1864 "data/.DS_Store",
1865 "data/.git/config",
1866 ],
1867 " \"media/b.mp4\",\n \"data\",",
1868 );
1869 assert!(errors.is_empty(), "{errors:?}");
1870 assert_eq!(
1871 names(&found),
1872 ["data/nested/more.json", "data/seed.json", "media/b.mp4"]
1873 );
1874 let media = &found[2].declared;
1877 assert_eq!(
1878 (media.file.as_str(), media.line, media.column),
1879 ("Bern.toml", 4, 3)
1880 );
1881 assert_eq!(found[0].declared.line, 5);
1882 }
1883
1884 #[test]
1885 fn resource_errors_are_strict_and_located() {
1886 let cases: &[(&[&str], &str, &str)] = &[
1887 (&[], "\"media/missing.mp4\"", "does not exist"),
1888 (&["a.mp4"], "\"/a.mp4\"", "is absolute"),
1889 (&["a.mp4"], "\"../a.mp4\"", "starts with `.`"),
1890 (&["a b.mp4"], "\"a b.mp4\"", "contains ` `"),
1891 (&["empty/"], "\"empty\"", "has no files to ship"),
1892 (&["only/.hidden"], "\"only\"", "has no files to ship"),
1893 (&["d/a b.txt"], "\"d\"", "contains ` `"),
1894 (&["a.mp4"], "\"a.mp4\", \"a.mp4\"", "listed twice"),
1895 (&["d/a.mp4"], "\"d\", \"d/a.mp4\"", "listed twice"),
1896 (&["d/A.mp4", "e/a.mp4"], "\"d/A.mp4\", \"e/a.mp4\"", ""),
1897 ];
1898 for (files, entries, expected) in cases {
1899 let (_, errors) = check_resources(files, entries);
1900 if expected.is_empty() {
1901 assert!(errors.is_empty(), "{entries}: {errors:?}");
1902 continue;
1903 }
1904 assert_eq!(errors.len(), 1, "{entries}: {errors:?}");
1905 assert!(
1906 errors[0].message.contains(expected),
1907 "{entries}: {}",
1908 errors[0].message
1909 );
1910 let location = errors[0].location.as_ref().expect("located");
1911 assert_eq!((location.file.as_str(), location.line), ("Bern.toml", 4));
1912 }
1913 }
1914
1915 #[cfg(unix)]
1916 #[test]
1917 fn symbolic_links_and_case_collisions_are_rejected() {
1918 let (_, errors) = check_resources(&["a.mp4", "A.MP4"], "\"a.mp4\", \"A.MP4\"");
1919 assert_eq!(errors.len(), 1, "{errors:?}");
1922 assert!(
1923 errors[0].message.contains("differ only in case")
1924 || errors[0].message.contains("listed twice"),
1925 "{}",
1926 errors[0].message
1927 );
1928
1929 let dir = std::env::temp_dir().join(format!("bern-resources-link-{}", std::process::id()));
1930 let _ = std::fs::remove_dir_all(&dir);
1931 std::fs::create_dir_all(dir.join("real")).unwrap();
1932 std::fs::write(dir.join("real/a.mp4"), b"x").unwrap();
1933 std::os::unix::fs::symlink(dir.join("real"), dir.join("linked")).unwrap();
1934 std::os::unix::fs::symlink(dir.join("real/a.mp4"), dir.join("real/b.mp4")).unwrap();
1935 #[derive(Deserialize)]
1936 struct Manifest {
1937 resources: RawResources,
1938 }
1939 for entry in ["linked/a.mp4", "real"] {
1940 let text = format!("[resources]\npaths = [\"{entry}\"]\n");
1941 let raw: Manifest = toml::from_str(&text).unwrap();
1942 let mut errors = Vec::new();
1943 resources(&dir, "Bern.toml", &text, &raw.resources, &mut errors);
1944 assert_eq!(errors.len(), 1, "{entry}: {errors:?}");
1945 assert!(
1946 errors[0].message.contains("symbolic link"),
1947 "{}",
1948 errors[0].message
1949 );
1950 }
1951 let _ = std::fs::remove_dir_all(&dir);
1952 }
1953
1954 #[test]
1955 fn module_paths_follow_cargo_layout() {
1956 let dir = Path::new("/p");
1957 assert_eq!(
1958 source_file(dir, "ui/src/lib.rs").unwrap().1,
1959 Vec::<String>::new()
1960 );
1961 assert_eq!(
1962 source_file(dir, "ui/src/pages.rs").unwrap().1,
1963 vec!["pages"]
1964 );
1965 assert_eq!(
1966 source_file(dir, "ui/src/a/b/mod.rs").unwrap().1,
1967 vec!["a", "b"]
1968 );
1969 assert_eq!(
1970 source_file(dir, "ui/src/pages.rs").unwrap().0,
1971 PathBuf::from("/p/ui")
1972 );
1973 assert!(source_file(dir, "ui/pages.rs").is_err());
1974 }
1975
1976 fn check_icons(
1978 files: &[(&str, Vec<u8>)],
1979 value: &str,
1980 ) -> (Option<String>, BTreeMap<String, String>, Vec<Diagnostic>) {
1981 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1982 let dir = std::env::temp_dir().join(format!(
1983 "bern-icon-test-{}-{}",
1984 std::process::id(),
1985 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1986 ));
1987 let _ = std::fs::remove_dir_all(&dir);
1988 std::fs::create_dir_all(&dir).unwrap();
1989 for (file, bytes) in files {
1990 let path = dir.join(file);
1991 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1992 std::fs::write(&path, bytes).unwrap();
1993 }
1994 #[derive(Deserialize)]
1995 struct Manifest {
1996 icon: toml::Spanned<RawIcon>,
1997 }
1998 let text = format!("# Bern.toml\nicon = {value}\n");
1999 let raw: Manifest = toml::from_str(&text).unwrap();
2000 let mut errors = Vec::new();
2001 let (every, platforms) = app_icons(
2002 &dir,
2003 "Bern.toml",
2004 &text,
2005 ("app", "icon"),
2006 &raw.icon,
2007 &mut errors,
2008 );
2009 let _ = std::fs::remove_dir_all(&dir);
2010 (every, platforms, errors)
2011 }
2012
2013 fn check_icon(files: &[(&str, Vec<u8>)], entry: &str) -> (Option<String>, Vec<Diagnostic>) {
2015 let (every, _, errors) = check_icons(files, &format!("\"{entry}\""));
2016 (every, errors)
2017 }
2018
2019 fn png(width: u32, height: u32) -> Vec<u8> {
2021 let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
2022 bytes.extend_from_slice(&width.to_be_bytes());
2023 bytes.extend_from_slice(&height.to_be_bytes());
2024 bytes
2025 }
2026
2027 #[test]
2028 fn app_icons_are_square_pngs_in_the_project() {
2029 let (found, errors) = check_icon(&[("art/icon.png", png(1024, 1024))], "art/icon.png");
2030 assert!(errors.is_empty(), "{errors:?}");
2031 assert_eq!(found.as_deref(), Some("art/icon.png"));
2032 for (files, entry, expected) in [
2033 (vec![], "icon.png", "does not exist"),
2034 (vec![("icon.png", png(300, 300))], "icon.png", "1024"),
2035 (vec![("icon.png", png(512, 256))], "icon.png", "square"),
2036 (
2037 vec![("icon.png", b"GIF89a".to_vec())],
2038 "icon.png",
2039 "not a PNG",
2040 ),
2041 (vec![], "../icon.png", "[app].icon"),
2042 ] {
2043 let (found, errors) = check_icon(&files, entry);
2044 assert!(found.is_none(), "{entry}");
2045 assert_eq!(errors.len(), 1, "{entry}: {errors:?}");
2046 assert!(
2047 errors[0].message.contains(expected),
2048 "{entry}: {}",
2049 errors[0].message
2050 );
2051 assert_eq!(errors[0].location.as_ref().map(|at| at.line), Some(2));
2052 }
2053 }
2054
2055 #[test]
2056 fn platforms_take_their_own_icons() {
2057 let icns = {
2058 let mut bytes = b"icns\0\0\0\x18ic10\0\0\0\x10".to_vec();
2059 bytes.extend_from_slice(&png(1024, 1024)[..8]);
2060 bytes
2061 };
2062 let ico = {
2063 let mut bytes = vec![0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 32, 0];
2064 bytes.extend_from_slice(&24u32.to_le_bytes());
2065 bytes.extend_from_slice(&22u32.to_le_bytes());
2066 bytes.extend_from_slice(&png(256, 256));
2067 bytes
2068 };
2069 let files = vec![
2070 ("icon.png", png(1024, 1024)),
2071 ("mac/App.icns", icns),
2072 ("win/app.ico", ico),
2073 (
2074 "ios/AppIcon.appiconset/Contents.json",
2075 br#"{"images":[{"filename":"AppIcon.png","idiom":"universal","platform":"ios","size":"1024x1024"}]}"#.to_vec(),
2076 ),
2077 ("ios/AppIcon.appiconset/AppIcon.png", png(1024, 1024)),
2078 (
2079 "apple/App.icon/icon.json",
2080 br#"{"fill":"automatic","groups":[{"layers":[{"image-name":"Shape.svg","name":"Shape","glass":true}]}]}"#.to_vec(),
2081 ),
2082 ("apple/App.icon/Assets/Shape.svg", b"<svg/>".to_vec()),
2083 ("android/res/mipmap-anydpi-v26/ic_launcher.xml", b"<adaptive-icon/>".to_vec()),
2084 ("android/res/mipmap-hdpi/ic_launcher_foreground.png", png(162, 162)),
2085 ("android/res/mipmap-hdpi/ic_launcher_round.png", png(72, 72)),
2086 ("android/res/values/ic_launcher_background.xml", b"<resources/>".to_vec()),
2087 ("linux/hicolor/48x48/apps/app.png", png(48, 48)),
2088 ("linux/hicolor/256x256/apps/app.png", png(256, 256)),
2089 ("linux/hicolor/scalable/apps/app.svg", b"<svg/>".to_vec()),
2090 ("linux/hicolor/symbolic/apps/app-symbolic.svg", b"<svg/>".to_vec()),
2091 ("web/favicon.svg", b"<svg/>".to_vec()),
2092 ("web/apple-touch-icon.png", png(180, 180)),
2093 ("web/site.webmanifest", b"{}".to_vec()),
2094 (
2095 "tv/App.brandassets/Contents.json",
2096 br#"{"assets":[{"filename":"App Icon.imagestack","idiom":"tv","role":"primary-app-icon","size":"400x240"}]}"#.to_vec(),
2097 ),
2098 (
2099 "tv/App.brandassets/App Icon.imagestack/Contents.json",
2100 br#"{"layers":[{"filename":"Front.imagestacklayer"},{"filename":"Back.imagestacklayer"}]}"#.to_vec(),
2101 ),
2102 (
2103 "tv/App.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json",
2104 br#"{"images":[{"filename":"front.png","idiom":"tv"}]}"#.to_vec(),
2105 ),
2106 (
2107 "tv/App.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/front.png",
2108 png(400, 240),
2109 ),
2110 (
2111 "tv/App.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json",
2112 br#"{"images":[{"filename":"back.png","idiom":"tv"}]}"#.to_vec(),
2113 ),
2114 (
2115 "tv/App.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/back.png",
2116 png(400, 240),
2117 ),
2118 ];
2119 let (every, platforms, errors) = check_icons(
2120 &files,
2121 r#"{ default = "icon.png", macos = "mac/App.icns", windows = "win/app.ico", ios = "ios/AppIcon.appiconset", tvos = "tv/App.brandassets", android = "android/res", linux = "linux/hicolor", browser = "web" }"#,
2122 );
2123 assert!(errors.is_empty(), "{errors:?}");
2124 assert_eq!(every.as_deref(), Some("icon.png"));
2125 assert_eq!(platforms.len(), 7);
2126 assert_eq!(platforms["linux"], "linux/hicolor");
2127 let (every, platforms, errors) = check_icons(&files, r#"{ android = "icon.png" }"#);
2129 assert!(errors.is_empty(), "{errors:?}");
2130 assert_eq!(every, None);
2131 assert_eq!(platforms["android"], "icon.png");
2132 let (_, platforms, errors) = check_icons(
2134 &files,
2135 r#"{ macos = "apple/App.icon", ios = "apple/App.icon" }"#,
2136 );
2137 assert!(errors.is_empty(), "{errors:?}");
2138 assert_eq!(platforms["macos"], "apple/App.icon");
2139 assert_eq!(platforms["ios"], "apple/App.icon");
2140
2141 for (extra, value, expected) in [
2142 (vec![], r#"{ watchos = "icon.png" }"#, "not a platform"),
2143 (vec![], r#"{ tvos = "win/app.ico" }"#, "`.brandassets`"),
2144 (
2145 vec![(
2146 "tv/App.brandassets/App Icon.imagestack/Contents.json",
2147 br#"{"layers":[{"filename":"Front.imagestacklayer"}]}"#.to_vec(),
2148 )],
2149 r#"{ tvos = "tv/App.brandassets" }"#,
2150 "two to five",
2151 ),
2152 (vec![], r#"{ macos = "win/app.ico" }"#, "`.icns`"),
2153 (vec![], r#"{ default = "mac/App.icns" }"#, "not a PNG"),
2154 (vec![], r#"{ windows = "web" }"#, "`.ico`"),
2155 (
2156 vec![("bad.icns", b"icns\0\0\0\x40".to_vec())],
2157 r#"{ macos = "bad.icns" }"#,
2158 "bytes long",
2159 ),
2160 (
2161 vec![("android/res/values/strings.xml", b"<resources/>".to_vec())],
2162 r#"{ android = "android/res" }"#,
2163 "Bern writes itself",
2164 ),
2165 (
2166 vec![("other/res/mipmap-hdpi/ic_foo.png", png(72, 72))],
2167 r#"{ android = "other/res" }"#,
2168 "ic_launcher",
2169 ),
2170 (
2171 vec![("linux/hicolor/64x64/apps/app.png", png(48, 48))],
2172 r#"{ linux = "linux/hicolor" }"#,
2173 "48 × 48 pixels, not 64 × 64",
2174 ),
2175 (
2176 vec![("linux/hicolor/32x32/apps/other.png", png(32, 32))],
2177 r#"{ linux = "linux/hicolor" }"#,
2178 "one icon",
2179 ),
2180 (
2181 vec![(
2182 "ios/AppIcon.appiconset/Contents.json",
2183 br#"{"images":[{"filename":"Gone.png"}]}"#.to_vec(),
2184 )],
2185 r#"{ ios = "ios/AppIcon.appiconset" }"#,
2186 "Gone.png",
2187 ),
2188 (
2189 vec![("web/notes.txt", b"x".to_vec())],
2190 r#"{ browser = "web" }"#,
2191 "notes.txt",
2192 ),
2193 (
2194 vec![(
2195 "apple/App.icon/icon.json",
2196 br#"{"groups":[{"layers":[{"image-name":"Gone.svg"}]}]}"#.to_vec(),
2197 )],
2198 r#"{ macos = "apple/App.icon" }"#,
2199 "Gone.svg",
2200 ),
2201 (vec![], r#"{ windows = "apple/App.icon" }"#, "`.ico`"),
2202 ] {
2203 let mut all = files.clone();
2204 all.retain(|(path, _)| !extra.iter().any(|(other, _)| other == path));
2205 all.extend(extra);
2206 let (_, platforms, errors) = check_icons(&all, value);
2207 assert_eq!(errors.len(), 1, "{value}: {errors:?}");
2208 assert!(platforms.is_empty(), "{value}");
2209 assert!(
2210 errors[0].message.contains(expected),
2211 "{value}: {}",
2212 errors[0].message
2213 );
2214 }
2215 }
2216}