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 AccountScreen {
42 area: String,
43 screen: AccountScreen,
44 },
45 Area {
47 module: String,
48 name: String,
49 access: AreaAccess,
50 },
51 Impersonation {
53 permitted: String,
54 member: String,
55 },
56 Tenancy {
58 organization: String,
59 member: String,
60 },
61 Mail {
63 module: String,
64 name: String,
65 list: Option<String>,
66 },
67 Migration {
68 module: String,
69 name: String,
70 },
71 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 Plan {
92 module: String,
93 name: String,
94 },
95 Route(NewRoute),
96 Task {
97 module: String,
98 name: String,
99 },
100 Topic {
102 module: String,
103 name: String,
104 },
105 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
135pub(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
203pub 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
215fn 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
350fn 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
367const 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];
375const 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];
466const 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
484fn 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
496fn 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
547enum 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
563pub(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}