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;
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, regenerated};
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    /// One of sofie-identity's screens of a member's own account, mounted for a member area.
40    AccountScreen {
41        area: String,
42        screen: AccountScreen,
43    },
44    /// An area defined in the module's contract, with a first page.
45    Area {
46        module: String,
47        name: String,
48        access: AreaAccess,
49    },
50    /// Signing in as a member of `member`, for a permitted principal of `permitted`.
51    Impersonation {
52        permitted: String,
53        member: String,
54    },
55    /// Organizations acting in `organization`, listed and joined in `member`.
56    Tenancy {
57        organization: String,
58        member: String,
59    },
60    /// A mail template and the function sending it; bulk mail of a list with one.
61    Mail {
62        module: String,
63        name: String,
64        list: Option<String>,
65    },
66    Migration {
67        module: String,
68        name: String,
69    },
70    /// `sofie gen migration`: what the aggregates declare beyond what the migrations create.
71    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    /// A plan in the module's `src/plans.rs`, sofie-billing's catalog.
90    Plan {
91        module: String,
92        name: String,
93    },
94    Route(NewRoute),
95    Task {
96        module: String,
97        name: String,
98    },
99    /// Full-text search over an aggregate's searched fields.
100    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_app_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
129/// The app crate's code besides its manifest, which only `sofie gen`'s wiring adds to.
130pub(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(|()| regenerated(root, GenMode::Write));
194    journal.finish(written)
195}
196
197/// Writes only the sources; `sofie gen` and `sofie check` follow, as after any edit.
198/// A failure part-way undoes what was written.
199pub 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
209/// A route's builder and permission are written by `sofie gen` into the contract, which must export them.
210fn 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
341/// Cargo refuses a member glob matching nothing, so contracts join the workspace only once a module has one.
342fn 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
358/// Rust 2024's strict and reserved words: a generated `mod`, `fn` or crate cannot take one.
359const 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];
366/// The types `sofie::prelude::*` and Rust's prelude bring into every generated file: a
367/// generated type of the same name hides one the generated code names.
368const 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];
457/// Crates every app reaches by name, which a package of the same name would shadow.
458const 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
475/// A name that becomes a package: no keyword, and none of the crates every app links.
476fn 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
487/// An app is a package only, never a Rust path, so it may take `-` between words as crates
488/// commonly do; its crate name, with `_`, is what has to be free.
489fn checked_app_name(name: &str) -> Result<&str, GenError> {
490    let plain = name.starts_with(|first: char| first.is_ascii_lowercase())
491        && name.split('-').all(|word| {
492            !word.is_empty()
493                && word.chars().all(|character| {
494                    character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
495                })
496        });
497    if !plain {
498        return Err(GenError::Usage(format!(
499            "`{name}` is not a name `sofie new app` takes: start with a lowercase letter and use \
500             only lowercase letters, digits, `_` and single `-` between words"
501        )));
502    }
503    checked_package_name(&crate_name(name))?;
504    Ok(name)
505}
506
507fn refuse_keyword(name: &str, identifier: &str) -> Result<(), GenError> {
508    if !RUST_KEYWORDS.contains(&identifier) {
509        return Ok(());
510    }
511    Err(GenError::Usage(format!(
512        "`{name}` would become the Rust keyword `{identifier}` in the generated code; choose \
513         another name"
514    )))
515}
516
517pub(crate) fn refuse_existing(directory: &Path) -> Result<(), GenError> {
518    if !directory.exists() {
519        return Ok(());
520    }
521    Err(GenError::Usage(format!(
522        "{} exists already; choose another name or remove it",
523        directory.display()
524    )))
525}
526
527pub(crate) fn write_all(
528    journal: &mut Journal,
529    directory: &Path,
530    files: &[(&str, String)],
531) -> Result<(), GenError> {
532    for (relative, text) in files {
533        journal.write(&directory.join(relative), text)?;
534    }
535    Ok(())
536}
537
538/// Where this binary was built from, which decides where a new app finds the framework.
539enum Origin {
540    Registry,
541    Commit(&'static str),
542    Checkout,
543}
544
545impl Origin {
546    fn of_this_binary() -> Self {
547        if option_env!("SOFIE_CLI_REGISTRY").is_some() {
548            return Self::Registry;
549        }
550        option_env!("SOFIE_CLI_COMMIT").map_or(Self::Checkout, Self::Commit)
551    }
552}
553
554/// The version a CLI from crates.io was published as, the commit `cargo install --git` built
555/// it from, or this checkout by path, so the app and the CLI always match.
556pub(super) fn framework_source(package: &str) -> String {
557    source_of(package, &Origin::of_this_binary())
558}
559
560fn source_of(package: &str, origin: &Origin) -> String {
561    match origin {
562        Origin::Registry => format!("version = \"{}\"", env!("CARGO_PKG_VERSION")),
563        Origin::Commit(commit) => format!(
564            "git = \"{}\", rev = \"{commit}\"",
565            env!("CARGO_PKG_REPOSITORY")
566        ),
567        Origin::Checkout => checkout_path(package),
568    }
569}
570
571fn checkout_path(package: &str) -> String {
572    let crates = Path::new(env!("CARGO_MANIFEST_DIR"))
573        .parent()
574        .map(Path::to_path_buf)
575        .unwrap_or_default();
576    let directory = if package == "sofie-framework" {
577        "sofie"
578    } else {
579        package
580    };
581    let path = crates.join(directory).display().to_string();
582    format!("path = \"{}\"", toml_escaped(&path))
583}
584
585fn workspace_manifest() -> String {
586    format!(
587        "[workspace]\nresolver = \"3\"\nmembers = [\"app\", \"modules/*\"]\n\n\
588         [workspace.package]\nedition = \"2024\"\nversion = \"0.1.0\"\npublish = false\n\n\
589         [workspace.dependencies]\nsofie = {{ package = \"sofie-framework\", {} }}\nsofie-cli = {{ {} }}\n\n\
590         [workspace.lints.rust]\nunsafe_code = \"forbid\"\n\n\
591         [workspace.lints.clippy]\nall = {{ level = \"warn\", priority = -1 }}\n\
592         pedantic = {{ level = \"warn\", priority = -1 }}\nmissing_errors_doc = \"allow\"\n\
593         missing_panics_doc = \"allow\"\n# Handlers are `async fn` by contract, whether or not \
594         their body awaits.\nunused_async = \"allow\"\n\n\
595         # What `sofie build` builds with: about half the binary and less CPU per request.\n\
596         [profile.dist]\ninherits = \"release\"\nlto = \"fat\"\ncodegen-units = 1\nstrip = \"symbols\"\n",
597        framework_source("sofie-framework"),
598        framework_source("sofie-cli"),
599    )
600}
601
602fn app_manifest(name: &str) -> String {
603    format!(
604        "[package]\nname = \"{name}\"\nedition.workspace = true\nversion.workspace = true\n\
605         publish.workspace = true\n\n[dependencies]\nsofie.workspace = true\n\n\
606         [build-dependencies]\nsofie-cli.workspace = true\n\n[lints]\nworkspace = true\n"
607    )
608}
609
610fn module_manifest(name: &str) -> String {
611    format!(
612        "[package]\nname = \"{name}\"\nedition.workspace = true\nversion.workspace = true\n\
613         publish.workspace = true\n\n[dependencies]\n\
614         sofie = {{ workspace = true, features = [\"config\"] }}\n\n[lints]\nworkspace = true\n"
615    )
616}
617
618fn contract_manifest(name: &str) -> String {
619    format!(
620        "[package]\nname = \"{}\"\nedition.workspace = true\n\
621         version.workspace = true\npublish.workspace = true\n\n[lints]\nworkspace = true\n",
622        contract_package(name)
623    )
624}
625
626fn module_declaration(crate_name: &str) -> String {
627    format!(
628        "use sofie::{{Module, Tier}};\n\npub const MODULE: Module = Module {{\n    \
629         name: \"{crate_name}\",\n    tier: Tier::Core,\n    context: None,\n    settings: &[],\n    \
630         ..crate::wiring::GENERATED\n}};\n"
631    )
632}
633
634const APP_LIBRARY: &str = "use sofie::db::Db;\nuse sofie::prelude::*;\n\nmod wiring;\n\n\
635     pub use wiring::{MODULES, worker};\n\npub async fn app(db: Db) -> Result<App> {\n    \
636     wiring::app(db)\n}\n";
637
638const MODULE_LIBRARY: &str = "pub mod config;\npub mod module;\nmod wiring;\n\nmod areas {\n    \
639     pub mod web {\n        pub mod routes;\n    }\n}\n";
640
641#[cfg(test)]
642mod tests {
643    use super::{
644        Origin, PRELUDE_TYPES, checked_app_name, checked_name, checked_package_name,
645        checked_type_name, source_of,
646    };
647
648    #[test]
649    fn refuses_names_that_become_rust_keywords() {
650        for name in ["type", "fn", "self", "crate", "loop", "gen"] {
651            let refused = checked_name(name).unwrap_err().to_string();
652            assert!(refused.contains("Rust keyword"), "{name}: {refused}");
653        }
654        for name in [
655            "Match", "Type", "Ref", "Impl", "Trait", "Loop", "Move", "Self",
656        ] {
657            let refused = checked_type_name(name).unwrap_err().to_string();
658            assert!(refused.contains("Rust keyword"), "{name}: {refused}");
659        }
660        assert_eq!(checked_name("typed").unwrap(), "typed");
661        assert_eq!(checked_type_name("Matched").unwrap(), "Matched");
662    }
663
664    #[test]
665    fn refuses_type_names_a_prelude_brings_into_every_file() {
666        for name in ["Meta", "Result", "Id", "Read", "Money", "Option", "String"] {
667            let refused = checked_type_name(name).unwrap_err().to_string();
668            assert!(refused.contains("prelude"), "{name}: {refused}");
669        }
670        assert_eq!(checked_type_name("Metadata").unwrap(), "Metadata");
671    }
672
673    #[test]
674    fn the_prelude_types_hold_every_type_the_framework_prelude_exports() {
675        let library = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sofie/src/lib.rs");
676        let file = syn::parse_file(&std::fs::read_to_string(library).unwrap()).unwrap();
677        let prelude = file
678            .items
679            .iter()
680            .find_map(|item| match item {
681                syn::Item::Mod(module) if module.ident == "prelude" => module.content.as_ref(),
682                _ => None,
683            })
684            .expect("sofie has a prelude");
685        let mut exported = Vec::new();
686        for item in &prelude.1 {
687            if let syn::Item::Use(used) = item {
688                exported_names(&used.tree, &mut exported);
689            }
690        }
691        let types: Vec<&String> = exported
692            .iter()
693            .filter(|name| name.starts_with(|first: char| first.is_ascii_uppercase()))
694            .collect();
695
696        assert!(types.len() > 20, "{types:?}");
697        let missing: Vec<_> = types
698            .into_iter()
699            .filter(|name| !PRELUDE_TYPES.contains(&name.as_str()))
700            .collect();
701        assert_eq!(missing, Vec::<&String>::new());
702    }
703
704    fn exported_names(tree: &syn::UseTree, names: &mut Vec<String>) {
705        match tree {
706            syn::UseTree::Path(path) => exported_names(&path.tree, names),
707            syn::UseTree::Name(name) => names.push(name.ident.to_string()),
708            syn::UseTree::Group(group) => {
709                for item in &group.items {
710                    exported_names(item, names);
711                }
712            }
713            syn::UseTree::Rename(_) | syn::UseTree::Glob(_) => {}
714        }
715    }
716
717    #[test]
718    fn refuses_a_package_named_after_a_crate_every_app_links() {
719        for name in ["sofie", "std", "core", "alloc", "test"] {
720            let refused = checked_package_name(name).unwrap_err().to_string();
721            assert!(
722                refused.contains("names a crate every app links"),
723                "{refused}"
724            );
725        }
726        assert_eq!(checked_package_name("shop").unwrap(), "shop");
727    }
728
729    #[test]
730    fn an_app_name_takes_single_hyphens_between_words() {
731        assert_eq!(
732            checked_app_name("restarts-pilot").unwrap(),
733            "restarts-pilot"
734        );
735        assert_eq!(checked_app_name("shop_2").unwrap(), "shop_2");
736        for name in ["-shop", "shop-", "re--start", "Shop", "2shop", "shop.app"] {
737            let refused = checked_app_name(name).unwrap_err().to_string();
738            assert!(refused.contains("single `-`"), "{name}: {refused}");
739        }
740        assert!(checked_app_name("proc-macro").is_err());
741    }
742
743    #[test]
744    fn the_framework_is_named_by_version_commit_or_path_after_where_the_cli_came_from() {
745        assert_eq!(
746            source_of("sofie-framework", &Origin::Registry),
747            format!("version = \"{}\"", env!("CARGO_PKG_VERSION"))
748        );
749        let commit = "318950386cebddd99c40181813ae4f4e8da5c6a7";
750        assert_eq!(
751            source_of("sofie-framework", &Origin::Commit(commit)),
752            format!("git = \"https://github.com/sofie-rs/sofie\", rev = \"{commit}\"")
753        );
754        let local = source_of("sofie-framework", &Origin::Checkout);
755        assert!(local.starts_with("path = \""), "{local}");
756        assert!(local.ends_with("crates/sofie\""), "{local}");
757        assert!(source_of("sofie-cli", &Origin::Checkout).ends_with("crates/sofie-cli\""));
758    }
759}