Skip to main content

sofie_cli/new_command/
mod.rs

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