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}
235
236/// `[app].icon`: one square PNG for every platform, or `[app.icon]` with
237/// `default` for every platform and an entry per platform name.
238#[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    /// The package directory, relative to the manifest; by default the
279    /// directory named like the package.
280    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/// A Rust source file listed in the configuration.
310#[derive(Clone, Debug)]
311pub struct SourceFile {
312    /// Path as written in `Bern.toml`, used in diagnostics.
313    pub display: String,
314    pub path: PathBuf,
315    /// Module path inside its crate (`[]` for `lib.rs`).
316    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    /// Rust crate name of the package.
327    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    /// `[app].icon`, or `default` in `[app.icon]`: a checked square PNG
340    /// relative to `dir`.
341    pub icon: Option<String>,
342    /// The other entries of `[app.icon]`: platform name to a checked square
343    /// PNG, or a file or folder in the platform's own form, relative to `dir`.
344    pub platform_icons: BTreeMap<String, String>,
345    /// `[app.alternate_icons.<name>]`: name to its checked entries, as
346    /// `[app.icon]` has them, with `default` for every platform.
347    pub alternate_icons: BTreeMap<String, BTreeMap<String, String>>,
348    /// `[app].publisher`, trimmed; `None` when absent or blank.
349    pub publisher: Option<String>,
350    /// `[app].files_folder`.
351    pub files_folder: bool,
352    /// `[app].background_audio`.
353    pub background_audio: bool,
354    /// `[app].overlays`.
355    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    /// Manifests of component libraries the UI uses.
370    pub libraries: Vec<PathBuf>,
371    /// The application's modules, in the order `Bern.toml` lists them.
372    pub modules: Vec<ModuleConfig>,
373    /// Declared modules and whether this build includes them.
374    pub build_modules: bern_syntax::build::Modules,
375    /// Slot id to the application's own components placed there, before
376    /// the modules'.
377    pub contributes: BTreeMap<String, Vec<String>>,
378    /// Files shipped with the application, directories expanded, sorted by
379    /// name.
380    pub resources: Vec<ResourceDef>,
381    /// Breakpoints from `[layouts]`, if declared.
382    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
397/// The source files `sources` names, relative to `base` inside the project
398/// `dir`: listed files as written, or every `.rs` file below a directory,
399/// sorted by path.
400fn 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
448/// Resolve `pkg/src/a/b.rs` into the package directory and module path.
449fn 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            // `field.workspace = true` inherits from the workspace manifest.
496            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
565/// Line and column (both from 1) of a byte offset in the manifest.
566fn 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
576/// Check `[app].icon`: one square PNG for every platform, or `[app.icon]`
577/// with `default` for every platform and an icon per platform name. Returns
578/// the one for every platform and the platforms' own.
579fn 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
627/// Check one icon: a file or folder inside the project, reached without
628/// symbolic links. For every platform (`platform` is `None`) it is a square
629/// PNG of a size every platform scales down from; for one platform it can
630/// also be in that platform's own form.
631fn 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
687/// Check the `[resources]` entries and expand directories into the files
688/// they ship. Every problem is an error at the entry that declared it.
689fn 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        // No part of the path may be a symbolic link: what ships is what
706        // the entry names, inside the project directory.
707        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    // One name, one file: no duplicates, and no names a case-insensitive
753    // file system would merge.
754    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
781/// Every non-hidden file below `path`, named `prefix/relative/path`.
782fn 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
824/// How the file, when it is an SVG document, shows as a picture of it.
825fn 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
840/// Check `[app].id` and `[app].bundle_id`.
841fn 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
860/// Check `[languages]` and that `[app].name` translates exactly them;
861/// with `strict`, a missing translation is B102.
862fn 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
910/// `[theme].mode`, the system's by default.
911fn 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
925/// The breakpoints of `[layouts]`, each wider than the ones before it.
926fn 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            // Each breakpoint lies beyond the ones before it.
934            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
966/// `[theme].brand_seed`, a `#RRGGBB` color.
967fn 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
978/// The application's protocol and UI sources and its protocol, UI and
979/// engine packages; a part is missing once its problem is reported.
980struct 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
988/// Resolve the sources `[protocol]` and `[ui]` list, and check that each
989/// kind belongs to the package the manifest names, like the engine.
990fn 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
1060/// Check one `[targets.<name>]` table and turn it into its configuration.
1061fn 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
1111/// A browser target says whether the engine runs in the page, and the
1112/// transport and codec it declares follow from that; other targets have
1113/// no `web_mode`.
1114fn 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        // `app` runs the engine in the page; `ui` reaches a separate
1126        // engine over a browser transport. Mismatches fail here
1127        // instead of building a page that cannot reach its engine.
1128        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
1158/// The front ends a target builds. Windows builds both unless the target
1159/// names fewer; other platforms take none.
1160fn 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
1197/// The manifests `[[libraries]]` lists, which must exist.
1198fn 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
1218/// Load the modules `[[modules]]` lists. Each is listed once and
1219/// translates every language the application supports.
1220fn 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
1266/// A module depends only on modules listed before it, and one that
1267/// always runs never on an optional one.
1268fn 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
1292/// `[contributes]`: every slot named there is a lower snake case id.
1293fn 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
1307/// Read identity and dependency metadata before touching any module sources.
1308fn 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
1399/// Load and validate a project configuration.
1400pub fn load(path: &Path) -> Result<Config, Vec<Diagnostic>> {
1401    load_excluding(path, &[])
1402}
1403
1404/// Load only the selected modules; excluded source files are never opened.
1405pub 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        // `WindowSet(icon:)` names it by an enum variant: `DarkSkin` is
1457        // `dark_skin`, and `Default` is `[app.icon]`.
1458        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
1530/// Load the module at `written` (relative to the project directory `dir`).
1531/// Its source paths are kept relative to `dir`, like the application's.
1532fn 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
1693/// Load a component library manifest (`[library]` + `[languages]`).
1694pub 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
1754/// The absolute form of `dir`, without the verbatim prefix Windows gives
1755/// canonical paths (`\\?\C:\…`), which Cargo manifests and tools reject.
1756fn 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    /// Check `[resources]` written as `entries` in a project holding
1812    /// `files` (paths relative to the project, created empty unless they
1813    /// end in `/`).
1814    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        // Each file points at the entry that declared it: line 4 is the
1869        // first entry, line 5 the directory.
1870        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        // A case-insensitive file system has one file; a case-sensitive one
1914        // has two that would merge on another machine. Both are errors.
1915        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    /// Check `icon = <value>` (TOML) in a project holding `files`.
1971    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    /// Check `[app].icon = entry` in a project holding `files`.
2008    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    /// The first bytes of a PNG of `width` × `height` pixels.
2014    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        // A platform can also take another PNG, and needs no `default`.
2122        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        // An Icon Composer document serves macOS and iOS alike.
2127        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}