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 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
344fn 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
370fn 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
409fn 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 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
483fn 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
540fn 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
570fn 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}