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