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