Skip to main content

sofie_model/
load.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use syn::ext::IdentExt;
6
7use crate::config::read_config;
8use crate::gateways::gateways;
9use crate::identity::IDENTITY_CRATE;
10use crate::manifest::read_manifest;
11use crate::menu::read_menu;
12use crate::messages::Found;
13use crate::metadata::{Package, Packages};
14use crate::naming::{
15    BILLING_PACKAGE, CONTENT_PACKAGE, GEN_MODULE, IDENTITY_AREA, IDENTITY_PACKAGE, TENANCY_PACKAGE,
16    contract_package, crate_name, file_stem, screen_key, snake_case,
17};
18use crate::package_routes::{read_module_mounts, read_mounts, read_route_tables};
19use crate::provisions::{RootExports, provisions};
20use crate::routes::read_routes;
21use crate::sources::{ConditionalWiring, module_tree};
22use crate::syntax::{parsed_or_read, public_uses};
23use crate::targets::target_sources;
24use crate::{
25    App, AreaContribution, AreaDefinition, ContractTrait, Crate, EventDeclaration, Identity,
26    MenuFile, ModelError, Module, RemoteModule, RoutedPackage, ScreenOverride, Source, Split,
27};
28
29pub fn load(root: &Path) -> Result<App, ModelError> {
30    let app_directory = root.join("app");
31    let app_manifest = manifest(&app_directory)?;
32    let packages = Packages::read(&app_manifest)?;
33    let app_package = packages.at(&app_manifest)?;
34    let split = read_split(app_package, &packages, &app_manifest)?;
35    let app = read_crate(app_package, &app_directory, ConditionalWiring::Refused)?;
36    let modules = module_dirs(&root.join("modules"))?
37        .iter()
38        .map(|directory| read_module(directory, &packages))
39        .collect::<Result<Vec<Module>, _>>()?;
40    refuse_second_plans(&modules)?;
41    let identity = read_identity(&app, &packages)?;
42    let package_events = read_package_events(&app.krate, &modules, &packages)?;
43    let routed = read_routed_packages(&app, &packages)?;
44    let app = App {
45        root: root.to_path_buf(),
46        app: app.krate,
47        modules,
48        identity,
49        package_events,
50        packages: routed,
51        split,
52        structure_ceilings: app_package.structure_ceilings,
53    };
54    if app.has_permitted_areas() {
55        app.modules
56            .iter()
57            .try_for_each(crate::permissions::refuse_colliding_constants)?;
58    }
59    Ok(app)
60}
61
62/// One catalog per app, so every owner's plan is looked up in the same place.
63fn refuse_second_plans(modules: &[Module]) -> Result<(), ModelError> {
64    let mut declaring = modules.iter().filter_map(|module| module.plans.as_ref());
65    match (declaring.next(), declaring.next()) {
66        (Some(first), Some(second)) => Err(ModelError::in_file(
67            second,
68            format!(
69                "plans are declared once per app, and {} declares them already",
70                first.display()
71            ),
72        )),
73        _ => Ok(()),
74    }
75}
76
77fn read_split(app: &Package, packages: &Packages, manifest: &Path) -> Result<Split, ModelError> {
78    let remote = app
79        .remote
80        .iter()
81        .map(|name| read_remote_module(name, packages, manifest))
82        .collect::<Result<_, _>>()?;
83    Ok(Split {
84        serves: app.serves.clone(),
85        remote,
86    })
87}
88
89fn read_remote_module(
90    name: &str,
91    packages: &Packages,
92    manifest: &Path,
93) -> Result<RemoteModule, ModelError> {
94    let package = contract_package(name);
95    let found = packages.dependency(&package)?.ok_or_else(|| {
96        ModelError::in_file(
97            manifest,
98            format!(
99                "`[package.metadata.sofie] remote` names module `{name}`, but the workspace has no \
100                 package `{package}`; add the split app to the workspace"
101            ),
102        )
103    })?;
104    let contract = read_contract(&found.directory, packages)?;
105    let krate = contract.krate.ok_or_else(|| {
106        ModelError::in_file(
107            &found.directory,
108            "the remote module's contract has no manifest",
109        )
110    })?;
111    Ok(RemoteModule {
112        name: crate_name(name),
113        contract: krate,
114        defined_areas: contract.areas,
115        exports: contract.exports,
116        events: contract
117            .events
118            .into_iter()
119            .map(|event| event.name)
120            .collect(),
121        traits: contract.traits,
122    })
123}
124
125pub fn workspace_crates(manifest: &Path) -> Result<Vec<Crate>, ModelError> {
126    let packages = Packages::read(manifest)?;
127    let mut crates = packages
128        .members()
129        .map(|package| {
130            Ok(read_crate(package, &package.directory, ConditionalWiring::Allowed)?.krate)
131        })
132        .collect::<Result<Vec<_>, ModelError>>()?;
133    crates.sort_by(|left, right| left.path.cmp(&right.path));
134    Ok(crates)
135}
136
137pub fn dependency_crate(root: &Path, package: &str) -> Result<Option<Crate>, ModelError> {
138    let packages = Packages::read(&manifest(&root.join("app"))?)?;
139    packages
140        .dependency(package)?
141        .map(|package| {
142            Ok(read_crate(&package, &package.directory, ConditionalWiring::Allowed)?.krate)
143        })
144        .transpose()
145}
146
147struct CrateTree {
148    krate: Crate,
149    found: Found,
150    syntax: BTreeMap<PathBuf, syn::File>,
151    module_paths: BTreeMap<PathBuf, Vec<String>>,
152}
153
154fn manifest(directory: &Path) -> Result<PathBuf, ModelError> {
155    let manifest = directory.join("Cargo.toml");
156    if manifest.is_file() {
157        return Ok(manifest);
158    }
159    Err(ModelError::in_file(
160        &manifest,
161        "missing manifest; an app root holds app/Cargo.toml and modules/<name>/Cargo.toml",
162    ))
163}
164
165fn read_member(directory: &Path, packages: &Packages) -> Result<CrateTree, ModelError> {
166    read_crate(
167        packages.at(&manifest(directory)?)?,
168        directory,
169        ConditionalWiring::Refused,
170    )
171}
172
173/// `directory` keeps the caller's spelling, so model paths stay relative when the root was.
174fn read_crate(
175    package: &Package,
176    directory: &Path,
177    conditional_wiring: ConditionalWiring,
178) -> Result<CrateTree, ModelError> {
179    let sources = module_tree(directory, conditional_wiring)?;
180    let mut tests_and_examples = Vec::new();
181    let build_script = directory.join("build.rs");
182    if build_script.is_file() {
183        tests_and_examples.push(Source::read(&build_script)?);
184    }
185    tests_and_examples.extend(target_sources(directory)?);
186    let krate = Crate {
187        package: package.name.clone(),
188        crate_name: package.crate_name.clone(),
189        path: directory.to_path_buf(),
190        dependencies: package.dependencies.clone(),
191        sources: sources.files,
192        tests_and_examples,
193    };
194    Ok(CrateTree {
195        krate,
196        found: sources.found,
197        syntax: sources.syntax,
198        module_paths: sources.module_paths,
199    })
200}
201
202fn read_module(directory: &Path, packages: &Packages) -> Result<Module, ModelError> {
203    let tree = read_member(directory, packages)?;
204    let manifest = read_manifest(directory, &tree.syntax)?;
205    let config = read_config(directory, &tree.krate.crate_name, &tree.syntax)?;
206    let contract = read_contract(&directory.join("contract"), packages)?;
207    let migrations = files_with_extension(&directory.join("migrations"), "sql")?;
208    let mails = crate::mails::read_mails(directory, &tree.krate.crate_name)?;
209    let deletable: Vec<String> = tree
210        .found
211        .code
212        .aggregates
213        .iter()
214        .filter(|aggregate| aggregate.soft_deletes())
215        .map(|aggregate| snake_case(&aggregate.name))
216        .collect();
217    let areas = area_contributions(&tree, &deletable)?;
218    let contract_name = contract
219        .krate
220        .as_ref()
221        .map(|krate| krate.crate_name.as_str());
222    let exports = RootExports::of(tree.syntax.get(&directory.join("src").join("lib.rs")));
223    let (implementations, extensions) = provisions(
224        &tree.found.impls,
225        &tree.found.units,
226        &tree.syntax,
227        &exports,
228        &tree.krate,
229        contract_name,
230    );
231    let mut code = tree.found.code;
232    code.telemetry
233        .sensitive_values
234        .extend(contract.sensitive_values);
235    let gateways = gateways(
236        tree.found.gateways,
237        &tree.found.impls,
238        &tree.syntax,
239        &exports,
240        &tree.krate.crate_name,
241    )?;
242    for aggregate in &mut code.aggregates {
243        aggregate.path = exports.public(&aggregate.path);
244    }
245    for enumeration in &mut code.enums {
246        enumeration.path = exports.public(&enumeration.path);
247    }
248    for path in &mut code.value_paths {
249        *path = exports.public(path);
250    }
251    let islands = crate::islands::read_islands(directory, &areas)?;
252    Ok(Module {
253        krate: tree.krate,
254        manifest,
255        config,
256        contract: contract.krate,
257        defined_areas: contract.areas,
258        contract_declares_gen: contract.declares_gen,
259        contract_exports: contract.exports,
260        contract_traits: contract.traits,
261        migrations,
262        sqlite_migrations: files_with_extension(&directory.join("migrations/sqlite"), "sql")?,
263        areas,
264        unrouted_areas: unrouted_areas(directory)?,
265        listeners: tree.found.listeners,
266        schedules: tree.found.schedules,
267        events: contract
268            .events
269            .iter()
270            .chain(&tree.found.events)
271            .map(|event| event.name.clone())
272            .collect(),
273        module_events: tree.found.events,
274        jobs: tree.found.jobs,
275        tasks: tree.found.tasks,
276        implementations,
277        extensions,
278        gateways,
279        islands,
280        components: files_with_extension(&directory.join("web"), "html")?,
281        languages: files_with_extension(&directory.join("lang"), "toml")?,
282        mails,
283        plans: Some(directory.join("src").join("plans.rs")).filter(|file| file.is_file()),
284        private_items: tree.found.private_items,
285        code,
286    })
287}
288
289#[derive(Default)]
290struct Contract {
291    krate: Option<Crate>,
292    areas: Vec<AreaDefinition>,
293    declares_gen: bool,
294    exports: Vec<String>,
295    events: Vec<EventDeclaration>,
296    traits: Vec<ContractTrait>,
297    sensitive_values: Vec<String>,
298}
299
300fn read_contract(directory: &Path, packages: &Packages) -> Result<Contract, ModelError> {
301    if !directory.join("Cargo.toml").is_file() {
302        return Ok(Contract::default());
303    }
304    let tree = read_member(directory, packages)?;
305    let contract = tree.krate;
306    let member_areas = tree.found.member_areas;
307    let sign_up_areas = tree.found.sign_up_areas;
308    let areas = tree
309        .found
310        .areas
311        .into_iter()
312        .map(|mut area| {
313            let name = area.type_path.last();
314            area.implements_member_area = name.is_some_and(|name| member_areas.contains(name));
315            area.opens_sign_up = name.is_some_and(|name| sign_up_areas.contains(name));
316            area.type_path.insert(0, contract.crate_name.clone());
317            area
318        })
319        .collect();
320    let root = tree.syntax.get(&directory.join("src").join("lib.rs"));
321    Ok(Contract {
322        krate: Some(contract),
323        areas,
324        declares_gen: root.is_some_and(declares_gen_module),
325        exports: root.map(gen_exports).unwrap_or_default(),
326        events: tree.found.events,
327        traits: tree.found.contract_traits,
328        sensitive_values: tree.found.code.telemetry.sensitive_values,
329    })
330}
331
332fn declares_gen_module(root: &syn::File) -> bool {
333    root.items
334        .iter()
335        .any(|item| matches!(item, syn::Item::Mod(module) if module.ident.unraw() == GEN_MODULE))
336}
337
338fn gen_exports(root: &syn::File) -> Vec<String> {
339    let generated = format!("r#{GEN_MODULE}");
340    public_uses(&root.items)
341        .filter_map(|used| match used.segments.as_slice() {
342            [module, name] if *module == generated => Some(name.clone()),
343            _ => None,
344        })
345        .collect()
346}
347
348/// Read from the crate itself so no list of identity's screens is kept beside it.
349fn read_identity(app: &CrateTree, packages: &Packages) -> Result<Option<Identity>, ModelError> {
350    if !app.krate.depends_on(IDENTITY_PACKAGE) {
351        return Ok(None);
352    }
353    let identity = listed_dependency(&app.krate, packages, IDENTITY_PACKAGE)?;
354    let screens = files_with_extension(&identity.directory.join("src").join("screens"), "html")?
355        .iter()
356        .map(|file| {
357            screen_key(
358                &crate_name(IDENTITY_PACKAGE),
359                IDENTITY_AREA,
360                &file_stem(file),
361            )
362        })
363        .collect();
364    let features = packages.enabled_features(IDENTITY_PACKAGE)?;
365    Ok(Some(Identity {
366        directory: identity.directory.clone(),
367        screens,
368        routes: read_route_tables(&identity.directory, &features)?,
369        mounts: read_mounts(&app.syntax, IDENTITY_CRATE),
370        shipped: crate::shipped::read_shipped(&identity.directory)?,
371        features,
372    }))
373}
374
375/// Mounted by the app's code, or by the package's `Module` itself.
376fn read_routed_packages(
377    app: &CrateTree,
378    packages: &Packages,
379) -> Result<Vec<RoutedPackage>, ModelError> {
380    let mut routed = Vec::new();
381    for package in [TENANCY_PACKAGE, CONTENT_PACKAGE] {
382        if !app.krate.depends_on(package) {
383            continue;
384        }
385        let found = listed_dependency(&app.krate, packages, package)?;
386        let mut mounts = read_mounts(&app.syntax, &found.crate_name);
387        for mount in read_module_mounts(&found.directory)? {
388            if !mounts.contains(&mount) {
389                mounts.push(mount);
390            }
391        }
392        routed.push(RoutedPackage {
393            routes: read_route_tables(&found.directory, &packages.enabled_features(package)?)?,
394            crate_name: found.crate_name,
395            mounts,
396        });
397    }
398    Ok(routed)
399}
400
401fn listed_dependency(
402    app: &Crate,
403    packages: &Packages,
404    package: &str,
405) -> Result<Package, ModelError> {
406    packages.dependency(package)?.ok_or_else(|| {
407        ModelError::in_file(
408            &app.path.join("Cargo.toml"),
409            format!("`cargo metadata` did not list `{package}` among the app's dependencies"),
410        )
411    })
412}
413
414/// A listener may hear what a framework package raises; billing is reached through the facade.
415fn read_package_events(
416    app: &Crate,
417    modules: &[Module],
418    packages: &Packages,
419) -> Result<Vec<EventDeclaration>, ModelError> {
420    let depended_on = |package: &str| {
421        app.depends_on(package)
422            || modules
423                .iter()
424                .any(|module| module.krate.depends_on(package))
425    };
426    let billed = modules.iter().any(|module| module.plans.is_some());
427    let mut events = Vec::new();
428    for package in [IDENTITY_PACKAGE, TENANCY_PACKAGE, BILLING_PACKAGE] {
429        let used = depended_on(package) || (package == BILLING_PACKAGE && billed);
430        if !used {
431            continue;
432        }
433        let found = if depended_on(package) {
434            listed_dependency(app, packages, package)?
435        } else {
436            // Reached through the facade's `billing` feature, which a stand-in facade may lack.
437            let Some(found) = packages.dependency(package)? else {
438                continue;
439            };
440            found
441        };
442        events.extend(crate::package_events::read_events(&found.directory)?);
443    }
444    Ok(events)
445}
446
447/// A module may add only islands to an area whose routes another module serves.
448fn unrouted_areas(directory: &Path) -> Result<Vec<PathBuf>, ModelError> {
449    let mut unrouted = Vec::new();
450    for area_dir in subdirectories(&directory.join("src").join("areas"))? {
451        if area_dir.join("routes.rs").is_file() {
452            continue;
453        }
454        let holds_more = entries(&area_dir, |path| {
455            path.file_name()
456                .and_then(|name| name.to_str())
457                .is_some_and(|name| name != "islands" && !name.starts_with('.'))
458        })?;
459        if !holds_more.is_empty() {
460            unrouted.push(area_dir);
461        }
462    }
463    Ok(unrouted)
464}
465
466fn area_contributions(
467    tree: &CrateTree,
468    deletable: &[String],
469) -> Result<Vec<AreaContribution>, ModelError> {
470    let areas_dir = tree.krate.path.join("src").join("areas");
471    let mut contributions = Vec::new();
472    for area_dir in subdirectories(&areas_dir)? {
473        let routes_file = area_dir.join("routes.rs");
474        if !routes_file.is_file() {
475            continue;
476        }
477        let (area, module_path) = area_module(tree, &routes_file, &area_dir)?;
478        if syn::parse_str::<syn::Ident>(&area).is_err() {
479            return Err(ModelError::in_file(
480                &routes_file,
481                format!(
482                    "the area folder `{area}` is not a Rust identifier, yet the wiring names its \
483                     module after it; rename the folder, such as to `{}`",
484                    area.replace(|character: char| !character.is_alphanumeric(), "_")
485                ),
486            ));
487        }
488        let routes = read_routes(
489            &routes_file,
490            parsed_or_read(&tree.syntax, &routes_file)?.as_ref(),
491            module_path,
492            deletable,
493        )?;
494        contributions.push(AreaContribution {
495            declared: tree.syntax.contains_key(&routes_file),
496            routes,
497            area,
498            routes_file,
499            screens: files_with_extension(&area_dir.join("screens"), "html")?,
500            overrides: screen_overrides(&area_dir.join("screens"))?,
501            menu: menu_file(tree, &area_dir.join("menu.rs"))?,
502        });
503    }
504    Ok(contributions)
505}
506
507/// A `#[path]` may give the area's module another name than its folder; the wiring names the module.
508fn area_module(
509    tree: &CrateTree,
510    routes_file: &Path,
511    area_dir: &Path,
512) -> Result<(String, Vec<String>), ModelError> {
513    if let Some(module_path) = tree.module_paths.get(routes_file)
514        && let [areas, area, routes] = module_path.as_slice()
515        && areas == "areas"
516        && routes == "routes"
517    {
518        return Ok((area.clone(), module_path.clone()));
519    }
520    let area = directory_name(area_dir)?;
521    let module_path = ["areas", &area, "routes"].map(str::to_owned).to_vec();
522    Ok((area, module_path))
523}
524
525fn menu_file(tree: &CrateTree, file: &Path) -> Result<Option<MenuFile>, ModelError> {
526    if !file.is_file() {
527        return Ok(None);
528    }
529    let entries = read_menu(file, parsed_or_read(&tree.syntax, file)?.as_ref())?;
530    Ok(Some(MenuFile {
531        file: file.to_path_buf(),
532        declared: tree.syntax.contains_key(file),
533        entries,
534    }))
535}
536
537fn screen_overrides(screens_dir: &Path) -> Result<Vec<ScreenOverride>, ModelError> {
538    let mut overrides = Vec::new();
539    for module_dir in subdirectories(screens_dir)? {
540        let module = directory_name(&module_dir)?;
541        for file in files_with_extension(&module_dir, "html")? {
542            overrides.push(ScreenOverride {
543                module: module.clone(),
544                file,
545            });
546        }
547    }
548    Ok(overrides)
549}
550
551fn module_dirs(modules_dir: &Path) -> Result<Vec<PathBuf>, ModelError> {
552    Ok(subdirectories(modules_dir)?
553        .into_iter()
554        .filter(|directory| directory.join("Cargo.toml").is_file())
555        .collect())
556}
557
558pub fn rust_files(directory: &Path) -> Result<Vec<PathBuf>, ModelError> {
559    let mut files = Vec::new();
560    collect_rust_files(directory, &mut BTreeSet::new(), &mut files)?;
561    Ok(files)
562}
563
564/// `walked` holds canonical folders, so a symlink back to one is not walked again.
565fn collect_rust_files(
566    directory: &Path,
567    walked: &mut BTreeSet<PathBuf>,
568    files: &mut Vec<PathBuf>,
569) -> Result<(), ModelError> {
570    let canonical = fs::canonicalize(directory).unwrap_or_else(|_| directory.to_path_buf());
571    if !walked.insert(canonical) {
572        return Ok(());
573    }
574    for entry in entries(directory, |_| true)? {
575        if entry.is_dir() {
576            collect_rust_files(&entry, walked, files)?;
577        } else if entry.extension().is_some_and(|extension| extension == "rs") {
578            files.push(entry);
579        }
580    }
581    Ok(())
582}
583
584pub fn subdirectories(directory: &Path) -> Result<Vec<PathBuf>, ModelError> {
585    entries(directory, Path::is_dir)
586}
587
588pub fn files_with_extension(directory: &Path, extension: &str) -> Result<Vec<PathBuf>, ModelError> {
589    entries(directory, |path| {
590        path.is_file() && path.extension().is_some_and(|found| found == extension)
591    })
592}
593
594/// Sorted, so the model does not depend on the order the OS lists files in.
595fn entries(directory: &Path, keep: impl Fn(&Path) -> bool) -> Result<Vec<PathBuf>, ModelError> {
596    if !directory.is_dir() {
597        return Ok(Vec::new());
598    }
599    let unreadable = |error: std::io::Error| {
600        ModelError::in_file(directory, format!("cannot list the directory: {error}"))
601    };
602    let mut kept = Vec::new();
603    for entry in fs::read_dir(directory).map_err(unreadable)? {
604        let path = entry.map_err(unreadable)?.path();
605        if keep(&path) {
606            kept.push(path);
607        }
608    }
609    kept.sort();
610    Ok(kept)
611}
612
613fn directory_name(directory: &Path) -> Result<String, ModelError> {
614    directory
615        .file_name()
616        .and_then(|name| name.to_str())
617        .map(str::to_owned)
618        .ok_or_else(|| {
619            ModelError::in_file(
620                directory,
621                "the directory name is not valid UTF-8; rename it",
622            )
623        })
624}