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
62fn 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
173fn 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
348fn 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
375fn 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
414fn 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 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
447fn 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
507fn 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
564fn 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
594fn 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}