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};
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_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
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(|()| gen_app(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
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
518/// Where this binary was built from, which decides where a new app finds the framework.
519enum 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
534/// The version a CLI from crates.io was published as, the commit `cargo install --git` built
535/// it from, or this checkout by path, so the app and the CLI always match.
536pub(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}