1mod account_screen;
2mod area;
3mod commands;
4mod declared_tables;
5mod detail_screen;
6pub(crate) mod edits;
7mod events;
8mod mail;
9mod menu;
10mod plan;
11mod read_model;
12mod route;
13mod schema;
14mod schema_migration;
15mod search;
16pub(crate) mod task;
17
18use std::path::Path;
19
20use sofie_model::naming::{contract_package, crate_name, snake_case};
21use sofie_model::{App, Module};
22
23use crate::fix_command::generated_export_lines;
24use crate::gen_command::{GenError, GenMode, GenOutcome, gen_app};
25use crate::source_edits::{
26 Journal, path_dependency, read, toml_escaped, with_dependency_line, with_lines_appended,
27};
28
29pub use account_screen::AccountScreen;
30pub use area::AreaAccess;
31pub use commands::{NEW_COMMANDS, NewCommand};
32use edits::Edits;
33pub use edits::FileChange;
34pub(crate) use edits::{dependency_line_start, workspace_declaration};
35pub use menu::NewMenuEntry;
36pub use route::NewRoute;
37
38pub enum NewCode {
39 AccountScreen {
41 area: String,
42 screen: AccountScreen,
43 },
44 Area {
46 module: String,
47 name: String,
48 access: AreaAccess,
49 },
50 Impersonation {
52 permitted: String,
53 member: String,
54 },
55 Tenancy {
57 organization: String,
58 member: String,
59 },
60 Mail {
62 module: String,
63 name: String,
64 list: Option<String>,
65 },
66 Migration {
67 module: String,
68 name: String,
69 },
70 SchemaMigration {
72 module: String,
73 name: String,
74 },
75 Aggregate {
76 module: String,
77 name: String,
78 },
79 Event {
80 module: String,
81 name: String,
82 },
83 Listener {
84 module: String,
85 event: String,
86 handler: String,
87 },
88 MenuEntry(NewMenuEntry),
89 Plan {
91 module: String,
92 name: String,
93 },
94 Route(NewRoute),
95 Task {
96 module: String,
97 name: String,
98 },
99 Search {
101 module: String,
102 aggregate: String,
103 },
104}
105
106const KEEP: &str = ".gitkeep";
107
108pub fn new_app(parent: &Path, name: &str) -> Result<Vec<GenOutcome>, GenError> {
109 let name = checked_package_name(name)?;
110 let root = parent.join(name);
111 refuse_existing(&root)?;
112 let crate_name = crate_name(name);
113 let [build, library, main] = app_sources(&crate_name);
114 let files = [
115 ("Cargo.toml", workspace_manifest()),
116 (".gitignore", "/target\n/.env\n".to_owned()),
117 ("app/Cargo.toml", app_manifest(name)),
118 build,
119 library,
120 main,
121 (&format!("modules/{KEEP}"), String::new()),
122 ];
123 let mut journal = Journal::default();
124 let written =
125 write_all(&mut journal, &root, &files).and_then(|()| gen_app(&root, GenMode::Write));
126 journal.finish(written)
127}
128
129pub(crate) fn app_sources(crate_name: &str) -> [(&'static str, String); 3] {
131 [
132 (
133 "app/build.rs",
134 "fn main() -> std::process::ExitCode {\n \
135 sofie_cli::check_in_build_script(std::path::Path::new(\"..\"))\n}\n"
136 .to_owned(),
137 ),
138 ("app/src/lib.rs", APP_LIBRARY.to_owned()),
139 (
140 "app/src/main.rs",
141 format!(
142 "fn main() -> sofie::core::Result<()> {{\n \
143 sofie::run({crate_name}::MODULES, {crate_name}::app, {crate_name}::worker)\n}}\n"
144 ),
145 ),
146 ]
147}
148
149pub fn new_module(root: &Path, name: &str) -> Result<Vec<GenOutcome>, GenError> {
150 let name = checked_package_name(name)?;
151 let directory = root.join("modules").join(name);
152 refuse_existing(&directory)?;
153 let app = sofie_model::load(root).map_err(GenError::Model)?;
154 if crate_name(&app.app.package) == name {
155 return Err(GenError::Usage(format!(
156 "`{name}` is the app's own package; choose another name for the module"
157 )));
158 }
159 let crate_name = crate_name(name);
160 let config = sofie_model::config_type_name(&crate_name);
161 let files = [
162 ("Cargo.toml", module_manifest(name)),
163 ("contract/Cargo.toml", contract_manifest(name)),
164 (
165 "contract/src/lib.rs",
166 format!("//! What other modules may use of module `{name}`.\n"),
167 ),
168 ("src/lib.rs", MODULE_LIBRARY.to_owned()),
169 ("src/module.rs", module_declaration(&crate_name)),
170 (
171 "src/config.rs",
172 format!("#[derive(sofie::Config)]\npub struct {config} {{}}\n"),
173 ),
174 (
175 "src/areas/web/routes.rs",
176 "use sofie::prelude::*;\n\npub fn routes(_routes: &mut Routes<Web>) {}\n".to_owned(),
177 ),
178 (&format!("src/areas/web/screens/{KEEP}"), String::new()),
179 (&format!("migrations/{KEEP}"), String::new()),
180 (&format!("lang/{KEEP}"), String::new()),
181 ];
182 let mut journal = Journal::default();
183 let written = write_all(&mut journal, &directory, &files)
184 .and_then(|()| include_contracts(&mut journal, &root.join("Cargo.toml")))
185 .and_then(|()| {
186 let manifest = root.join("app").join("Cargo.toml");
187 let dependency = path_dependency(name, &format!("../modules/{name}"));
188 journal.write(
189 &manifest,
190 with_dependency_line(&read(&manifest)?, &dependency),
191 )
192 })
193 .and_then(|()| gen_app(root, GenMode::Write));
194 journal.finish(written)
195}
196
197pub fn new_code(root: &Path, code: &NewCode) -> Result<Vec<FileChange>, GenError> {
200 let app = sofie_model::load(root).map_err(GenError::Model)?;
201 let mut edits = Edits::default();
202 let mut generated = generate(&mut edits, &app, code);
203 if matches!(code, NewCode::Route(_) | NewCode::Area { .. }) {
204 generated = generated.and_then(|()| export_generated(&mut edits, root));
205 }
206 edits.finish(generated)
207}
208
209fn export_generated(edits: &mut Edits, root: &Path) -> Result<(), GenError> {
211 let app = sofie_model::load(root).map_err(GenError::Model)?;
212 for module in &app.modules {
213 let Some(contract) = &module.contract else {
214 continue;
215 };
216 let lines = generated_export_lines(&app, module);
217 if lines.is_empty() {
218 continue;
219 }
220 let library = contract.path.join("src").join("lib.rs");
221 edits.edit_rust(&library, |text| Ok(with_lines_appended(text, &lines)))?;
222 }
223 Ok(())
224}
225
226fn generate(edits: &mut Edits, app: &App, code: &NewCode) -> Result<(), GenError> {
227 match code {
228 NewCode::Migration { module, name } => schema::new_migration(
229 edits,
230 find_module(app, module)?,
231 checked_name(name)?,
232 "",
233 |_| String::new(),
234 ),
235 NewCode::SchemaMigration { module, name } => schema_migration::new_schema_migration(
236 edits,
237 find_module(app, module)?,
238 checked_name(name)?,
239 ),
240 NewCode::Aggregate { module, name } => {
241 schema::new_aggregate(edits, find_module(app, module)?, checked_type_name(name)?)
242 }
243 NewCode::Event { module, name } => events::new_event(
244 edits,
245 app,
246 find_module(app, module)?,
247 checked_type_name(name)?,
248 ),
249 NewCode::Listener {
250 module,
251 event,
252 handler,
253 } => events::new_listener(
254 edits,
255 app,
256 find_module(app, module)?,
257 checked_type_name(event)?,
258 checked_name(handler)?,
259 ),
260 NewCode::MenuEntry(entry) => menu::new_menu_entry(edits, app, entry),
261 NewCode::Area {
262 module,
263 name,
264 access,
265 } => area::new_area(edits, app, module, name, *access),
266 NewCode::AccountScreen { area, screen } => {
267 account_screen::new_account_screen(edits, app, area, *screen)
268 }
269 NewCode::Impersonation { permitted, member } => {
270 account_screen::new_impersonation(edits, app, permitted, member)
271 }
272 NewCode::Tenancy {
273 organization,
274 member,
275 } => account_screen::new_tenancy(edits, app, organization, member),
276 NewCode::Mail { module, name, list } => mail::new_mail(
277 edits,
278 find_module(app, module)?,
279 checked_name(name)?,
280 list.as_deref(),
281 ),
282 NewCode::Plan { module, name } => {
283 plan::new_plan(edits, app, find_module(app, module)?, checked_name(name)?)
284 }
285 NewCode::Route(new_route) => route::new_route(edits, app, new_route),
286 NewCode::Task { module, name } => {
287 task::new_task(edits, app, find_module(app, module)?, checked_name(name)?)
288 }
289 NewCode::Search { module, aggregate } => search::new_search(
290 edits,
291 find_module(app, module)?,
292 checked_type_name(aggregate)?,
293 ),
294 }
295}
296
297pub(crate) fn find_module<'app>(app: &'app App, name: &str) -> Result<&'app Module, GenError> {
298 app.modules
299 .iter()
300 .find(|module| module.krate.crate_name == name || module.krate.package == name)
301 .ok_or_else(|| {
302 let known: Vec<&str> = app
303 .modules
304 .iter()
305 .map(|module| module.krate.crate_name.as_str())
306 .collect();
307 GenError::Usage(format!(
308 "the app has no module `{name}`; its modules: {}",
309 known.join(", ")
310 ))
311 })
312}
313
314fn checked_type_name(name: &str) -> Result<&str, GenError> {
315 let pascal = name.starts_with(|first: char| first.is_ascii_uppercase())
316 && name
317 .chars()
318 .all(|character| character.is_ascii_alphanumeric());
319 if !pascal {
320 return Err(GenError::Usage(format!(
321 "`{name}` is not a type name `sofie new` takes: start with an uppercase letter and use \
322 only letters and digits, as in `OrderPlaced`"
323 )));
324 }
325 refuse_keyword(name, name)?;
326 refuse_keyword(name, &snake_case(name))?;
327 refuse_prelude_type(name)?;
328 Ok(name)
329}
330
331fn refuse_prelude_type(name: &str) -> Result<(), GenError> {
332 if !PRELUDE_TYPES.contains(&name) {
333 return Ok(());
334 }
335 Err(GenError::Usage(format!(
336 "`{name}` is a name a prelude brings into every file (`sofie::prelude` or Rust's), and the \
337 generated code naming that one would reach the new type instead; choose another name"
338 )))
339}
340
341fn include_contracts(journal: &mut Journal, workspace: &Path) -> Result<(), GenError> {
343 const MODULES: &str = "\"modules/*\"";
344 const CONTRACTS: &str = "\"modules/*/contract\"";
345 if !workspace.is_file() {
346 return Ok(());
347 }
348 let text = read(workspace)?;
349 if text.contains(CONTRACTS) || !text.contains(MODULES) {
350 return Ok(());
351 }
352 journal.write(
353 workspace,
354 text.replacen(MODULES, &format!("{MODULES}, {CONTRACTS}"), 1),
355 )
356}
357
358const RUST_KEYWORDS: [&str; 51] = [
360 "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate",
361 "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl",
362 "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref",
363 "return", "self", "Self", "static", "struct", "super", "trait", "true", "try", "type",
364 "typeof", "unsafe", "unsized", "use", "virtual", "where", "while",
365];
366const PRELUDE_TYPES: [&str; 87] = [
369 "Access",
370 "Aggregate",
371 "App",
372 "Area",
373 "Child",
374 "Children",
375 "Clock",
376 "Currency",
377 "Date",
378 "Dep",
379 "Email",
380 "Enum",
381 "Error",
382 "Event",
383 "Extensions",
384 "Groups",
385 "Id",
386 "Inbox",
387 "Input",
388 "Instant",
389 "Jobs",
390 "Load",
391 "LocalJobs",
392 "Lookup",
393 "Mailer",
394 "Menu",
395 "Meta",
396 "Money",
397 "Navigation",
398 "Notify",
399 "Page",
400 "Paginated",
401 "PersonName",
402 "Preferences",
403 "Principal",
404 "Prune",
405 "Read",
406 "ReadModel",
407 "Repeat",
408 "Resources",
409 "Result",
410 "Routes",
411 "Select",
412 "Session",
413 "SoftDelete",
414 "SoftDeletes",
415 "Timestamped",
416 "Url",
417 "Value",
418 "Web",
419 "AsMut",
420 "AsRef",
421 "Box",
422 "Clone",
423 "Copy",
424 "Default",
425 "DoubleEndedIterator",
426 "Drop",
427 "Eq",
428 "Err",
429 "ExactSizeIterator",
430 "Extend",
431 "Fn",
432 "FnMut",
433 "FnOnce",
434 "From",
435 "FromIterator",
436 "Into",
437 "IntoIterator",
438 "Iterator",
439 "None",
440 "Ok",
441 "Option",
442 "Ord",
443 "PartialEq",
444 "PartialOrd",
445 "Send",
446 "Sized",
447 "Some",
448 "String",
449 "Sync",
450 "ToOwned",
451 "ToString",
452 "TryFrom",
453 "TryInto",
454 "Unpin",
455 "Vec",
456];
457const TAKEN_CRATES: [&str; 6] = ["sofie", "std", "core", "alloc", "test", "proc_macro"];
459
460pub(crate) fn checked_name(name: &str) -> Result<&str, GenError> {
461 let plain = name.starts_with(|first: char| first.is_ascii_lowercase())
462 && name.chars().all(|character| {
463 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
464 });
465 if !plain {
466 return Err(GenError::Usage(format!(
467 "`{name}` is not a name `sofie new` takes: start with a lowercase letter and use only \
468 lowercase letters, digits and `_`"
469 )));
470 }
471 refuse_keyword(name, name)?;
472 Ok(name)
473}
474
475fn checked_package_name(name: &str) -> Result<&str, GenError> {
477 let name = checked_name(name)?;
478 if TAKEN_CRATES.contains(&name) {
479 return Err(GenError::Usage(format!(
480 "`{name}` names a crate every app links ({}); choose another name",
481 TAKEN_CRATES.join(", ")
482 )));
483 }
484 Ok(name)
485}
486
487fn refuse_keyword(name: &str, identifier: &str) -> Result<(), GenError> {
488 if !RUST_KEYWORDS.contains(&identifier) {
489 return Ok(());
490 }
491 Err(GenError::Usage(format!(
492 "`{name}` would become the Rust keyword `{identifier}` in the generated code; choose \
493 another name"
494 )))
495}
496
497pub(crate) fn refuse_existing(directory: &Path) -> Result<(), GenError> {
498 if !directory.exists() {
499 return Ok(());
500 }
501 Err(GenError::Usage(format!(
502 "{} exists already; choose another name or remove it",
503 directory.display()
504 )))
505}
506
507pub(crate) fn write_all(
508 journal: &mut Journal,
509 directory: &Path,
510 files: &[(&str, String)],
511) -> Result<(), GenError> {
512 for (relative, text) in files {
513 journal.write(&directory.join(relative), text)?;
514 }
515 Ok(())
516}
517
518enum Origin {
520 Registry,
521 Commit(&'static str),
522 Checkout,
523}
524
525impl Origin {
526 fn of_this_binary() -> Self {
527 if option_env!("SOFIE_CLI_REGISTRY").is_some() {
528 return Self::Registry;
529 }
530 option_env!("SOFIE_CLI_COMMIT").map_or(Self::Checkout, Self::Commit)
531 }
532}
533
534pub(super) fn framework_source(package: &str) -> String {
537 source_of(package, &Origin::of_this_binary())
538}
539
540fn source_of(package: &str, origin: &Origin) -> String {
541 match origin {
542 Origin::Registry => format!("version = \"{}\"", env!("CARGO_PKG_VERSION")),
543 Origin::Commit(commit) => format!(
544 "git = \"{}\", rev = \"{commit}\"",
545 env!("CARGO_PKG_REPOSITORY")
546 ),
547 Origin::Checkout => checkout_path(package),
548 }
549}
550
551fn checkout_path(package: &str) -> String {
552 let crates = Path::new(env!("CARGO_MANIFEST_DIR"))
553 .parent()
554 .map(Path::to_path_buf)
555 .unwrap_or_default();
556 let directory = if package == "sofie-framework" {
557 "sofie"
558 } else {
559 package
560 };
561 let path = crates.join(directory).display().to_string();
562 format!("path = \"{}\"", toml_escaped(&path))
563}
564
565fn workspace_manifest() -> String {
566 format!(
567 "[workspace]\nresolver = \"3\"\nmembers = [\"app\", \"modules/*\"]\n\n\
568 [workspace.package]\nedition = \"2024\"\nversion = \"0.1.0\"\npublish = false\n\n\
569 [workspace.dependencies]\nsofie = {{ package = \"sofie-framework\", {} }}\nsofie-cli = {{ {} }}\n\n\
570 [workspace.lints.rust]\nunsafe_code = \"forbid\"\n\n\
571 [workspace.lints.clippy]\nall = {{ level = \"warn\", priority = -1 }}\n\
572 pedantic = {{ level = \"warn\", priority = -1 }}\nmissing_errors_doc = \"allow\"\n\
573 missing_panics_doc = \"allow\"\n# Handlers are `async fn` by contract, whether or not \
574 their body awaits.\nunused_async = \"allow\"\n\n\
575 # What `sofie build` builds with: about half the binary and less CPU per request.\n\
576 [profile.dist]\ninherits = \"release\"\nlto = \"fat\"\ncodegen-units = 1\nstrip = \"symbols\"\n",
577 framework_source("sofie-framework"),
578 framework_source("sofie-cli"),
579 )
580}
581
582fn app_manifest(name: &str) -> String {
583 format!(
584 "[package]\nname = \"{name}\"\nedition.workspace = true\nversion.workspace = true\n\
585 publish.workspace = true\n\n[dependencies]\nsofie.workspace = true\n\n\
586 [build-dependencies]\nsofie-cli.workspace = true\n\n[lints]\nworkspace = true\n"
587 )
588}
589
590fn module_manifest(name: &str) -> String {
591 format!(
592 "[package]\nname = \"{name}\"\nedition.workspace = true\nversion.workspace = true\n\
593 publish.workspace = true\n\n[dependencies]\n\
594 sofie = {{ workspace = true, features = [\"config\"] }}\n\n[lints]\nworkspace = true\n"
595 )
596}
597
598fn contract_manifest(name: &str) -> String {
599 format!(
600 "[package]\nname = \"{}\"\nedition.workspace = true\n\
601 version.workspace = true\npublish.workspace = true\n\n[lints]\nworkspace = true\n",
602 contract_package(name)
603 )
604}
605
606fn module_declaration(crate_name: &str) -> String {
607 format!(
608 "use sofie::{{Module, Tier}};\n\npub const MODULE: Module = Module {{\n \
609 name: \"{crate_name}\",\n tier: Tier::Core,\n context: None,\n settings: &[],\n \
610 ..crate::wiring::GENERATED\n}};\n"
611 )
612}
613
614const APP_LIBRARY: &str = "use sofie::db::Db;\nuse sofie::prelude::*;\n\nmod wiring;\n\n\
615 pub use wiring::{MODULES, worker};\n\npub async fn app(db: Db) -> Result<App> {\n \
616 wiring::app(db)\n}\n";
617
618const MODULE_LIBRARY: &str = "pub mod config;\npub mod module;\nmod wiring;\n\nmod areas {\n \
619 pub mod web {\n pub mod routes;\n }\n}\n";
620
621#[cfg(test)]
622mod tests {
623 use super::{
624 Origin, PRELUDE_TYPES, checked_name, checked_package_name, checked_type_name, source_of,
625 };
626
627 #[test]
628 fn refuses_names_that_become_rust_keywords() {
629 for name in ["type", "fn", "self", "crate", "loop", "gen"] {
630 let refused = checked_name(name).unwrap_err().to_string();
631 assert!(refused.contains("Rust keyword"), "{name}: {refused}");
632 }
633 for name in [
634 "Match", "Type", "Ref", "Impl", "Trait", "Loop", "Move", "Self",
635 ] {
636 let refused = checked_type_name(name).unwrap_err().to_string();
637 assert!(refused.contains("Rust keyword"), "{name}: {refused}");
638 }
639 assert_eq!(checked_name("typed").unwrap(), "typed");
640 assert_eq!(checked_type_name("Matched").unwrap(), "Matched");
641 }
642
643 #[test]
644 fn refuses_type_names_a_prelude_brings_into_every_file() {
645 for name in ["Meta", "Result", "Id", "Read", "Money", "Option", "String"] {
646 let refused = checked_type_name(name).unwrap_err().to_string();
647 assert!(refused.contains("prelude"), "{name}: {refused}");
648 }
649 assert_eq!(checked_type_name("Metadata").unwrap(), "Metadata");
650 }
651
652 #[test]
653 fn the_prelude_types_hold_every_type_the_framework_prelude_exports() {
654 let library = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sofie/src/lib.rs");
655 let file = syn::parse_file(&std::fs::read_to_string(library).unwrap()).unwrap();
656 let prelude = file
657 .items
658 .iter()
659 .find_map(|item| match item {
660 syn::Item::Mod(module) if module.ident == "prelude" => module.content.as_ref(),
661 _ => None,
662 })
663 .expect("sofie has a prelude");
664 let mut exported = Vec::new();
665 for item in &prelude.1 {
666 if let syn::Item::Use(used) = item {
667 exported_names(&used.tree, &mut exported);
668 }
669 }
670 let types: Vec<&String> = exported
671 .iter()
672 .filter(|name| name.starts_with(|first: char| first.is_ascii_uppercase()))
673 .collect();
674
675 assert!(types.len() > 20, "{types:?}");
676 let missing: Vec<_> = types
677 .into_iter()
678 .filter(|name| !PRELUDE_TYPES.contains(&name.as_str()))
679 .collect();
680 assert_eq!(missing, Vec::<&String>::new());
681 }
682
683 fn exported_names(tree: &syn::UseTree, names: &mut Vec<String>) {
684 match tree {
685 syn::UseTree::Path(path) => exported_names(&path.tree, names),
686 syn::UseTree::Name(name) => names.push(name.ident.to_string()),
687 syn::UseTree::Group(group) => {
688 for item in &group.items {
689 exported_names(item, names);
690 }
691 }
692 syn::UseTree::Rename(_) | syn::UseTree::Glob(_) => {}
693 }
694 }
695
696 #[test]
697 fn refuses_a_package_named_after_a_crate_every_app_links() {
698 for name in ["sofie", "std", "core", "alloc", "test"] {
699 let refused = checked_package_name(name).unwrap_err().to_string();
700 assert!(
701 refused.contains("names a crate every app links"),
702 "{refused}"
703 );
704 }
705 assert_eq!(checked_package_name("shop").unwrap(), "shop");
706 }
707
708 #[test]
709 fn the_framework_is_named_by_version_commit_or_path_after_where_the_cli_came_from() {
710 assert_eq!(
711 source_of("sofie-framework", &Origin::Registry),
712 format!("version = \"{}\"", env!("CARGO_PKG_VERSION"))
713 );
714 let commit = "318950386cebddd99c40181813ae4f4e8da5c6a7";
715 assert_eq!(
716 source_of("sofie-framework", &Origin::Commit(commit)),
717 format!("git = \"https://github.com/sofie-rs/sofie\", rev = \"{commit}\"")
718 );
719 let local = source_of("sofie-framework", &Origin::Checkout);
720 assert!(local.starts_with("path = \""), "{local}");
721 assert!(local.ends_with("crates/sofie\""), "{local}");
722 assert!(source_of("sofie-cli", &Origin::Checkout).ends_with("crates/sofie-cli\""));
723 }
724}