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    let islands = crate::islands::read_islands(directory, &areas)?;
249    Ok(Module {
250        krate: tree.krate,
251        manifest,
252        config,
253        contract: contract.krate,
254        defined_areas: contract.areas,
255        contract_declares_gen: contract.declares_gen,
256        contract_exports: contract.exports,
257        contract_traits: contract.traits,
258        migrations,
259        sqlite_migrations: files_with_extension(&directory.join("migrations/sqlite"), "sql")?,
260        areas,
261        listeners: tree.found.listeners,
262        schedules: tree.found.schedules,
263        events: contract
264            .events
265            .iter()
266            .chain(&tree.found.events)
267            .map(|event| event.name.clone())
268            .collect(),
269        module_events: tree.found.events,
270        jobs: tree.found.jobs,
271        tasks: tree.found.tasks,
272        implementations,
273        extensions,
274        gateways,
275        islands,
276        components: files_with_extension(&directory.join("web"), "html")?,
277        languages: files_with_extension(&directory.join("lang"), "toml")?,
278        mails,
279        plans: Some(directory.join("src").join("plans.rs")).filter(|file| file.is_file()),
280        private_items: tree.found.private_items,
281        code,
282    })
283}
284
285#[derive(Default)]
286struct Contract {
287    krate: Option<Crate>,
288    areas: Vec<AreaDefinition>,
289    declares_gen: bool,
290    exports: Vec<String>,
291    events: Vec<EventDeclaration>,
292    traits: Vec<ContractTrait>,
293    sensitive_values: Vec<String>,
294}
295
296fn read_contract(directory: &Path, packages: &Packages) -> Result<Contract, ModelError> {
297    if !directory.join("Cargo.toml").is_file() {
298        return Ok(Contract::default());
299    }
300    let tree = read_member(directory, packages)?;
301    let contract = tree.krate;
302    let member_areas = tree.found.member_areas;
303    let sign_up_areas = tree.found.sign_up_areas;
304    let areas = tree
305        .found
306        .areas
307        .into_iter()
308        .map(|mut area| {
309            let name = area.type_path.last();
310            area.implements_member_area = name.is_some_and(|name| member_areas.contains(name));
311            area.opens_sign_up = name.is_some_and(|name| sign_up_areas.contains(name));
312            area.type_path.insert(0, contract.crate_name.clone());
313            area
314        })
315        .collect();
316    let root = tree.syntax.get(&directory.join("src").join("lib.rs"));
317    Ok(Contract {
318        krate: Some(contract),
319        areas,
320        declares_gen: root.is_some_and(declares_gen_module),
321        exports: root.map(gen_exports).unwrap_or_default(),
322        events: tree.found.events,
323        traits: tree.found.contract_traits,
324        sensitive_values: tree.found.code.telemetry.sensitive_values,
325    })
326}
327
328fn declares_gen_module(root: &syn::File) -> bool {
329    root.items
330        .iter()
331        .any(|item| matches!(item, syn::Item::Mod(module) if module.ident.unraw() == GEN_MODULE))
332}
333
334fn gen_exports(root: &syn::File) -> Vec<String> {
335    let generated = format!("r#{GEN_MODULE}");
336    public_uses(&root.items)
337        .filter_map(|used| match used.segments.as_slice() {
338            [module, name] if *module == generated => Some(name.clone()),
339            _ => None,
340        })
341        .collect()
342}
343
344/// Read from the crate itself so no list of identity's screens is kept beside it.
345fn read_identity(app: &CrateTree, packages: &Packages) -> Result<Option<Identity>, ModelError> {
346    if !app.krate.depends_on(IDENTITY_PACKAGE) {
347        return Ok(None);
348    }
349    let identity = listed_dependency(&app.krate, packages, IDENTITY_PACKAGE)?;
350    let screens = files_with_extension(&identity.directory.join("src").join("screens"), "html")?
351        .iter()
352        .map(|file| {
353            screen_key(
354                &crate_name(IDENTITY_PACKAGE),
355                IDENTITY_AREA,
356                &file_stem(file),
357            )
358        })
359        .collect();
360    let features = packages.enabled_features(IDENTITY_PACKAGE)?;
361    Ok(Some(Identity {
362        directory: identity.directory.clone(),
363        screens,
364        routes: read_route_tables(&identity.directory, &features)?,
365        mounts: read_mounts(&app.syntax, IDENTITY_CRATE),
366        shipped: crate::shipped::read_shipped(&identity.directory)?,
367    }))
368}
369
370/// Mounted by the app's code, or by the package's `Module` itself.
371fn read_routed_packages(
372    app: &CrateTree,
373    packages: &Packages,
374) -> Result<Vec<RoutedPackage>, ModelError> {
375    let mut routed = Vec::new();
376    for package in [TENANCY_PACKAGE, CONTENT_PACKAGE] {
377        if !app.krate.depends_on(package) {
378            continue;
379        }
380        let found = listed_dependency(&app.krate, packages, package)?;
381        let mut mounts = read_mounts(&app.syntax, &found.crate_name);
382        for mount in read_module_mounts(&found.directory)? {
383            if !mounts.contains(&mount) {
384                mounts.push(mount);
385            }
386        }
387        routed.push(RoutedPackage {
388            routes: read_route_tables(&found.directory, &packages.enabled_features(package)?)?,
389            crate_name: found.crate_name,
390            mounts,
391        });
392    }
393    Ok(routed)
394}
395
396fn listed_dependency(
397    app: &Crate,
398    packages: &Packages,
399    package: &str,
400) -> Result<Package, ModelError> {
401    packages.dependency(package)?.ok_or_else(|| {
402        ModelError::in_file(
403            &app.path.join("Cargo.toml"),
404            format!("`cargo metadata` did not list `{package}` among the app's dependencies"),
405        )
406    })
407}
408
409/// A listener may hear what a framework package raises; billing is reached through the facade.
410fn read_package_events(
411    app: &Crate,
412    modules: &[Module],
413    packages: &Packages,
414) -> Result<Vec<EventDeclaration>, ModelError> {
415    let depended_on = |package: &str| {
416        app.depends_on(package)
417            || modules
418                .iter()
419                .any(|module| module.krate.depends_on(package))
420    };
421    let billed = modules.iter().any(|module| module.plans.is_some());
422    let mut events = Vec::new();
423    for package in [IDENTITY_PACKAGE, TENANCY_PACKAGE, BILLING_PACKAGE] {
424        let used = depended_on(package) || (package == BILLING_PACKAGE && billed);
425        if !used {
426            continue;
427        }
428        let found = if depended_on(package) {
429            listed_dependency(app, packages, package)?
430        } else {
431            // Reached through the facade's `billing` feature, which a stand-in facade may lack.
432            let Some(found) = packages.dependency(package)? else {
433                continue;
434            };
435            found
436        };
437        events.extend(crate::package_events::read_events(&found.directory)?);
438    }
439    Ok(events)
440}
441
442fn area_contributions(
443    tree: &CrateTree,
444    deletable: &[String],
445) -> Result<Vec<AreaContribution>, ModelError> {
446    let areas_dir = tree.krate.path.join("src").join("areas");
447    let mut contributions = Vec::new();
448    for area_dir in subdirectories(&areas_dir)? {
449        let routes_file = area_dir.join("routes.rs");
450        if !routes_file.is_file() {
451            continue;
452        }
453        let (area, module_path) = area_module(tree, &routes_file, &area_dir)?;
454        if syn::parse_str::<syn::Ident>(&area).is_err() {
455            return Err(ModelError::in_file(
456                &routes_file,
457                format!(
458                    "the area folder `{area}` is not a Rust identifier, yet the wiring names its \
459                     module after it; rename the folder, such as to `{}`",
460                    area.replace(|character: char| !character.is_alphanumeric(), "_")
461                ),
462            ));
463        }
464        let routes = read_routes(
465            &routes_file,
466            parsed_or_read(&tree.syntax, &routes_file)?.as_ref(),
467            module_path,
468            deletable,
469        )?;
470        contributions.push(AreaContribution {
471            declared: tree.syntax.contains_key(&routes_file),
472            routes,
473            area,
474            routes_file,
475            screens: files_with_extension(&area_dir.join("screens"), "html")?,
476            overrides: screen_overrides(&area_dir.join("screens"))?,
477            menu: menu_file(tree, &area_dir.join("menu.rs"))?,
478        });
479    }
480    Ok(contributions)
481}
482
483/// A `#[path]` may give the area's module another name than its folder; the wiring names the module.
484fn area_module(
485    tree: &CrateTree,
486    routes_file: &Path,
487    area_dir: &Path,
488) -> Result<(String, Vec<String>), ModelError> {
489    if let Some(module_path) = tree.module_paths.get(routes_file)
490        && let [areas, area, routes] = module_path.as_slice()
491        && areas == "areas"
492        && routes == "routes"
493    {
494        return Ok((area.clone(), module_path.clone()));
495    }
496    let area = directory_name(area_dir)?;
497    let module_path = ["areas", &area, "routes"].map(str::to_owned).to_vec();
498    Ok((area, module_path))
499}
500
501fn menu_file(tree: &CrateTree, file: &Path) -> Result<Option<MenuFile>, ModelError> {
502    if !file.is_file() {
503        return Ok(None);
504    }
505    let entries = read_menu(file, parsed_or_read(&tree.syntax, file)?.as_ref())?;
506    Ok(Some(MenuFile {
507        file: file.to_path_buf(),
508        declared: tree.syntax.contains_key(file),
509        entries,
510    }))
511}
512
513fn screen_overrides(screens_dir: &Path) -> Result<Vec<ScreenOverride>, ModelError> {
514    let mut overrides = Vec::new();
515    for module_dir in subdirectories(screens_dir)? {
516        let module = directory_name(&module_dir)?;
517        for file in files_with_extension(&module_dir, "html")? {
518            overrides.push(ScreenOverride {
519                module: module.clone(),
520                file,
521            });
522        }
523    }
524    Ok(overrides)
525}
526
527fn module_dirs(modules_dir: &Path) -> Result<Vec<PathBuf>, ModelError> {
528    Ok(subdirectories(modules_dir)?
529        .into_iter()
530        .filter(|directory| directory.join("Cargo.toml").is_file())
531        .collect())
532}
533
534pub fn rust_files(directory: &Path) -> Result<Vec<PathBuf>, ModelError> {
535    let mut files = Vec::new();
536    collect_rust_files(directory, &mut BTreeSet::new(), &mut files)?;
537    Ok(files)
538}
539
540/// `walked` holds canonical folders, so a symlink back to one is not walked again.
541fn collect_rust_files(
542    directory: &Path,
543    walked: &mut BTreeSet<PathBuf>,
544    files: &mut Vec<PathBuf>,
545) -> Result<(), ModelError> {
546    let canonical = fs::canonicalize(directory).unwrap_or_else(|_| directory.to_path_buf());
547    if !walked.insert(canonical) {
548        return Ok(());
549    }
550    for entry in entries(directory, |_| true)? {
551        if entry.is_dir() {
552            collect_rust_files(&entry, walked, files)?;
553        } else if entry.extension().is_some_and(|extension| extension == "rs") {
554            files.push(entry);
555        }
556    }
557    Ok(())
558}
559
560pub fn subdirectories(directory: &Path) -> Result<Vec<PathBuf>, ModelError> {
561    entries(directory, Path::is_dir)
562}
563
564pub fn files_with_extension(directory: &Path, extension: &str) -> Result<Vec<PathBuf>, ModelError> {
565    entries(directory, |path| {
566        path.is_file() && path.extension().is_some_and(|found| found == extension)
567    })
568}
569
570/// Sorted, so the model does not depend on the order the OS lists files in.
571fn entries(directory: &Path, keep: impl Fn(&Path) -> bool) -> Result<Vec<PathBuf>, ModelError> {
572    if !directory.is_dir() {
573        return Ok(Vec::new());
574    }
575    let unreadable = |error: std::io::Error| {
576        ModelError::in_file(directory, format!("cannot list the directory: {error}"))
577    };
578    let mut kept = Vec::new();
579    for entry in fs::read_dir(directory).map_err(unreadable)? {
580        let path = entry.map_err(unreadable)?.path();
581        if keep(&path) {
582            kept.push(path);
583        }
584    }
585    kept.sort();
586    Ok(kept)
587}
588
589fn directory_name(directory: &Path) -> Result<String, ModelError> {
590    directory
591        .file_name()
592        .and_then(|name| name.to_str())
593        .map(str::to_owned)
594        .ok_or_else(|| {
595            ModelError::in_file(
596                directory,
597                "the directory name is not valid UTF-8; rename it",
598            )
599        })
600}