Skip to main content

bern_compiler/
config.rs

1//! `Bern.toml`: project, language, theme, source, resource and target
2//! configuration.
3
4use 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    /// Slot id to the application's own components placed there, before
29    /// the modules': a module may leave room in its pages for the shell.
30    #[serde(default)]
31    contributes: BTreeMap<String, Vec<String>>,
32}
33
34/// `[[modules]]`: a module directory holding its own `Bern.toml`.
35#[derive(Debug, Deserialize)]
36#[serde(deny_unknown_fields)]
37struct RawModuleRef {
38    path: String,
39}
40
41/// A module manifest: `[module]` with its protocol, engine and UI, and the
42/// languages its translations cover.
43#[derive(Debug, Deserialize)]
44#[serde(deny_unknown_fields)]
45struct RawModuleConfig {
46    module: RawModule,
47    languages: RawLanguages,
48    #[serde(default)]
49    pages: Vec<RawPage>,
50    /// Slot id to the module's components placed there, in order.
51    #[serde(default)]
52    contributes: BTreeMap<String, Vec<String>>,
53}
54
55/// `[[pages]]`: a page the module adds to one of the shell's page slots.
56#[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    /// Boolean state field of the module: the page's navigation item shows
65    /// only while it is true.
66    when: Option<String>,
67    /// The slot's selection starts on this page.
68    #[serde(default)]
69    start: bool,
70    /// Whether a tab bar shows the page; `false` leaves it to sidebars.
71    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    /// Whether an optional module runs before the application saved a
85    /// choice; by default it does.
86    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/// Source files: a list of `.rs` files, or one directory whose `.rs` files
100/// are all collected.
101#[derive(Debug, Deserialize)]
102#[serde(untagged)]
103enum RawSources {
104    Directory(String),
105    Files(Vec<String>),
106}
107
108/// One module of the application.
109#[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    /// Whether an optional module runs before the application saved a choice.
118    pub enabled: bool,
119    pub protocol_package: Package,
120    pub protocol_source: SourceFile,
121    pub engine_package: Package,
122    /// Rust path of the module engine type, which implements `Default`.
123    pub engine_type: String,
124    pub ui_package: Option<Package>,
125    pub ui_sources: Vec<SourceFile>,
126    pub languages: Vec<String>,
127    /// Pages the module adds to the shell's page slots.
128    pub pages: Vec<PageConfig>,
129    /// Slot id to the module's components placed there, in order.
130    pub contributes: BTreeMap<String, Vec<String>>,
131}
132
133/// A page a module contributes to a page slot.
134#[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    /// Numeric state field of this module shown as the page's live badge.
141    pub badge: Option<String>,
142    /// Boolean state field of this module while which the page's
143    /// navigation item shows.
144    pub when: Option<String>,
145    /// The page's variant comes first in the slot's enum, so a selection
146    /// starts on it.
147    pub start: bool,
148    /// A `Tabs` lists the page; `false` leaves it to sidebars, for a page a
149    /// phone reaches some other way.
150    pub tab: bool,
151    /// The module component showing the page.
152    pub component: String,
153}
154
155/// `[layouts]`: breakpoints between layout sizes.
156#[derive(Debug, Deserialize)]
157#[serde(deny_unknown_fields)]
158struct RawLayouts {
159    /// Available width, in logical units, below which `compact` applies.
160    compact_below: f64,
161    /// Below which `medium` applies; without it there is no `medium`.
162    medium_below: Option<f64>,
163    /// Below which `expanded` applies, `large` from it; without it there
164    /// is no `large`.
165    expanded_below: Option<f64>,
166}
167
168/// `[resources]`: files and directories, relative to `Bern.toml`, that ship
169/// with the built application.
170#[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/// A component library crate, described by its own `Bern.toml`.
197#[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    /// A square PNG, relative to `Bern.toml`, or a table giving platforms
217    /// their own icons; the Bern logo without it.
218    icon: Option<toml::Spanned<RawIcon>>,
219    /// Icons the application can switch to at run time, by name, each as
220    /// `icon` is written.
221    alternate_icons: Option<BTreeMap<String, toml::Spanned<RawIcon>>>,
222    /// Who publishes the application, for installers and packages.
223    publisher: Option<String>,
224    /// iOS shows the application's Documents folder in Files.
225    #[serde(default)]
226    files_folder: bool,
227    /// The application plays audio on behind the lock screen and in the
228    /// background.
229    #[serde(default)]
230    background_audio: bool,
231    /// The application draws text over other apps (`bern::overlay`).
232    #[serde(default)]
233    overlays: bool,
234    /// The application saves pictures to the user's photos.
235    #[serde(default)]
236    photos: bool,
237}
238
239/// `[app].icon`: one square PNG for every platform, or `[app.icon]` with
240/// `default` for every platform and an entry per platform name.
241#[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    /// The package directory, relative to the manifest; by default the
282    /// directory named like the package.
283    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/// A Rust source file listed in the configuration.
313#[derive(Clone, Debug)]
314pub struct SourceFile {
315    /// Path as written in `Bern.toml`, used in diagnostics.
316    pub display: String,
317    pub path: PathBuf,
318    /// Module path inside its crate (`[]` for `lib.rs`).
319    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    /// Rust crate name of the package.
330    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    /// `[app].icon`, or `default` in `[app.icon]`: a checked square PNG
343    /// relative to `dir`.
344    pub icon: Option<String>,
345    /// The other entries of `[app.icon]`: platform name to a checked square
346    /// PNG, or a file or folder in the platform's own form, relative to `dir`.
347    pub platform_icons: BTreeMap<String, String>,
348    /// `[app.alternate_icons.<name>]`: name to its checked entries, as
349    /// `[app.icon]` has them, with `default` for every platform.
350    pub alternate_icons: BTreeMap<String, BTreeMap<String, String>>,
351    /// `[app].publisher`, trimmed; `None` when absent or blank.
352    pub publisher: Option<String>,
353    /// `[app].files_folder`.
354    pub files_folder: bool,
355    /// `[app].background_audio`.
356    pub background_audio: bool,
357    /// `[app].overlays`.
358    pub overlays: bool,
359    /// `[app].photos`.
360    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    /// Manifests of component libraries the UI uses.
375    pub libraries: Vec<PathBuf>,
376    /// The application's modules, in the order `Bern.toml` lists them.
377    pub modules: Vec<ModuleConfig>,
378    /// Declared modules and whether this build includes them.
379    pub build_modules: bern_syntax::build::Modules,
380    /// Slot id to the application's own components placed there, before
381    /// the modules'.
382    pub contributes: BTreeMap<String, Vec<String>>,
383    /// Files shipped with the application, directories expanded, sorted by
384    /// name.
385    pub resources: Vec<ResourceDef>,
386    /// Breakpoints from `[layouts]`, if declared.
387    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
402/// The source files `sources` names, relative to `base` inside the project
403/// `dir`: listed files as written, or every `.rs` file below a directory,
404/// sorted by path.
405fn 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
453/// Resolve `pkg/src/a/b.rs` into the package directory and module path.
454fn 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            // `field.workspace = true` inherits from the workspace manifest.
501            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
570/// Line and column (both from 1) of a byte offset in the manifest.
571fn 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
581/// Check `[app].icon`: one square PNG for every platform, or `[app.icon]`
582/// with `default` for every platform and an icon per platform name. Returns
583/// the one for every platform and the platforms' own.
584fn 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
632/// Check one icon: a file or folder inside the project, reached without
633/// symbolic links. For every platform (`platform` is `None`) it is a square
634/// PNG of a size every platform scales down from; for one platform it can
635/// also be in that platform's own form.
636fn 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
692/// Check the `[resources]` entries and expand directories into the files
693/// they ship. Every problem is an error at the entry that declared it.
694fn 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        // No part of the path may be a symbolic link: what ships is what
711        // the entry names, inside the project directory.
712        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    // One name, one file: no duplicates, and no names a case-insensitive
758    // file system would merge.
759    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
786/// Every non-hidden file below `path`, named `prefix/relative/path`.
787fn 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
829/// How the file, when it is an SVG document, shows as a picture of it.
830fn 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
845/// Check `[app].id` and `[app].bundle_id`.
846fn 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
865/// Check `[languages]` and that `[app].name` translates exactly them;
866/// with `strict`, a missing translation is B102.
867fn 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
915/// `[theme].mode`, the system's by default.
916fn 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
930/// The breakpoints of `[layouts]`, each wider than the ones before it.
931fn 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            // Each breakpoint lies beyond the ones before it.
939            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
971/// `[theme].brand_seed`, a `#RRGGBB` color.
972fn 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
983/// The application's protocol and UI sources and its protocol, UI and
984/// engine packages; a part is missing once its problem is reported.
985struct 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
993/// Resolve the sources `[protocol]` and `[ui]` list, and check that each
994/// kind belongs to the package the manifest names, like the engine.
995fn 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
1065/// Check one `[targets.<name>]` table and turn it into its configuration.
1066fn 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
1116/// A browser target says whether the engine runs in the page, and the
1117/// transport and codec it declares follow from that; other targets have
1118/// no `web_mode`.
1119fn 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        // `app` runs the engine in the page; `ui` reaches a separate
1131        // engine over a browser transport. Mismatches fail here
1132        // instead of building a page that cannot reach its engine.
1133        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
1163/// The front ends a target builds. Windows builds both unless the target
1164/// names fewer; other platforms take none.
1165fn 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
1202/// The manifests `[[libraries]]` lists, which must exist.
1203fn 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
1223/// Load the modules `[[modules]]` lists. Each is listed once and
1224/// translates every language the application supports.
1225fn 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
1271/// A module depends only on modules listed before it, and one that
1272/// always runs never on an optional one.
1273fn 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
1297/// `[contributes]`: every slot named there is a lower snake case id.
1298fn 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
1312/// Read identity and dependency metadata before touching any module sources.
1313fn 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
1404/// Load and validate a project configuration.
1405pub fn load(path: &Path) -> Result<Config, Vec<Diagnostic>> {
1406    load_excluding(path, &[])
1407}
1408
1409/// Load only the selected modules; excluded source files are never opened.
1410pub 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        // `WindowSet(icon:)` names it by an enum variant: `DarkSkin` is
1462        // `dark_skin`, and `Default` is `[app.icon]`.
1463        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
1536/// Load the module at `written` (relative to the project directory `dir`).
1537/// Its source paths are kept relative to `dir`, like the application's.
1538fn 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
1699/// Load a component library manifest (`[library]` + `[languages]`).
1700pub 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
1760/// The absolute form of `dir`, without the verbatim prefix Windows gives
1761/// canonical paths (`\\?\C:\…`), which Cargo manifests and tools reject.
1762fn 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    /// Check `[resources]` written as `entries` in a project holding
1818    /// `files` (paths relative to the project, created empty unless they
1819    /// end in `/`).
1820    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        // Each file points at the entry that declared it: line 4 is the
1875        // first entry, line 5 the directory.
1876        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        // A case-insensitive file system has one file; a case-sensitive one
1920        // has two that would merge on another machine. Both are errors.
1921        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    /// Check `icon = <value>` (TOML) in a project holding `files`.
1977    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    /// Check `[app].icon = entry` in a project holding `files`.
2014    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    /// The first bytes of a PNG of `width` × `height` pixels.
2020    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        // A platform can also take another PNG, and needs no `default`.
2128        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        // An Icon Composer document serves macOS and iOS alike.
2133        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}