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 AccountScreen {
41 area: String,
42 screen: AccountScreen,
43 },
44 Area {
46 module: String,
47 name: String,
48 access: AreaAccess,
49 },
50 Impersonation {
52 permitted: String,
53 member: String,
54 },
55 Tenancy {
57 organization: String,
58 member: String,
59 },
60 Mail {
62 module: String,
63 name: String,
64 list: Option<String>,
65 },
66 Migration {
67 module: String,
68 name: String,
69 },
70 SchemaMigration {
72 module: String,
73 name: String,
74 },
75 Aggregate {
76 module: String,
77 name: String,
78 },
79 Event {
80 module: String,
81 name: String,
82 },
83 Listener {
84 module: String,
85 event: String,
86 handler: String,
87 },
88 MenuEntry(NewMenuEntry),
89 Plan {
91 module: String,
92 name: String,
93 },
94 Route(NewRoute),
95 Task {
96 module: String,
97 name: String,
98 },
99 Search {
101 module: String,
102 aggregate: String,
103 },
104}
105
106const KEEP: &str = ".gitkeep";
107
108pub fn new_app(parent: &Path, name: &str) -> Result<Vec<GenOutcome>, GenError> {
109 let name = checked_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
129pub(crate) fn app_sources(crate_name: &str) -> [(&'static str, String); 3] {
131 [
132 (
133 "app/build.rs",
134 "fn main() -> std::process::ExitCode {\n \
135 sofie_cli::check_in_build_script(std::path::Path::new(\"..\"))\n}\n"
136 .to_owned(),
137 ),
138 ("app/src/lib.rs", APP_LIBRARY.to_owned()),
139 (
140 "app/src/main.rs",
141 format!(
142 "fn main() -> sofie::core::Result<()> {{\n \
143 sofie::run({crate_name}::MODULES, {crate_name}::app, {crate_name}::worker)\n}}\n"
144 ),
145 ),
146 ]
147}
148
149pub fn new_module(root: &Path, name: &str) -> Result<Vec<GenOutcome>, GenError> {
150 let name = checked_package_name(name)?;
151 let directory = root.join("modules").join(name);
152 refuse_existing(&directory)?;
153 let app = sofie_model::load(root).map_err(GenError::Model)?;
154 if crate_name(&app.app.package) == name {
155 return Err(GenError::Usage(format!(
156 "`{name}` is the app's own package; choose another name for the module"
157 )));
158 }
159 let crate_name = crate_name(name);
160 let config = sofie_model::config_type_name(&crate_name);
161 let files = [
162 ("Cargo.toml", module_manifest(name)),
163 ("contract/Cargo.toml", contract_manifest(name)),
164 (
165 "contract/src/lib.rs",
166 format!("//! What other modules may use of module `{name}`.\n"),
167 ),
168 ("src/lib.rs", MODULE_LIBRARY.to_owned()),
169 ("src/module.rs", module_declaration(&crate_name)),
170 (
171 "src/config.rs",
172 format!("#[derive(sofie::Config)]\npub struct {config} {{}}\n"),
173 ),
174 (
175 "src/areas/web/routes.rs",
176 "use sofie::prelude::*;\n\npub fn routes(_routes: &mut Routes<Web>) {}\n".to_owned(),
177 ),
178 (&format!("src/areas/web/screens/{KEEP}"), String::new()),
179 (&format!("migrations/{KEEP}"), String::new()),
180 (&format!("lang/{KEEP}"), String::new()),
181 ];
182 let mut journal = Journal::default();
183 let written = write_all(&mut journal, &directory, &files)
184 .and_then(|()| include_contracts(&mut journal, &root.join("Cargo.toml")))
185 .and_then(|()| {
186 let manifest = root.join("app").join("Cargo.toml");
187 let dependency = path_dependency(name, &format!("../modules/{name}"));
188 journal.write(
189 &manifest,
190 with_dependency_line(&read(&manifest)?, &dependency),
191 )
192 })
193 .and_then(|()| regenerated(root, GenMode::Write));
194 journal.finish(written)
195}
196
197pub fn new_code(root: &Path, code: &NewCode) -> Result<Vec<FileChange>, GenError> {
200 let app = sofie_model::load(root).map_err(GenError::Model)?;
201 let mut edits = Edits::default();
202 let mut generated = generate(&mut edits, &app, code);
203 if matches!(code, NewCode::Route(_) | NewCode::Area { .. }) {
204 generated = generated.and_then(|()| export_generated(&mut edits, root));
205 }
206 edits.finish(generated)
207}
208
209fn export_generated(edits: &mut Edits, root: &Path) -> Result<(), GenError> {
211 let app = sofie_model::load(root).map_err(GenError::Model)?;
212 for module in &app.modules {
213 let Some(contract) = &module.contract else {
214 continue;
215 };
216 let lines = generated_export_lines(&app, module);
217 if lines.is_empty() {
218 continue;
219 }
220 let library = contract.path.join("src").join("lib.rs");
221 edits.edit_rust(&library, |text| Ok(with_lines_appended(text, &lines)))?;
222 }
223 Ok(())
224}
225
226fn generate(edits: &mut Edits, app: &App, code: &NewCode) -> Result<(), GenError> {
227 match code {
228 NewCode::Migration { module, name } => schema::new_migration(
229 edits,
230 find_module(app, module)?,
231 checked_name(name)?,
232 "",
233 |_| String::new(),
234 ),
235 NewCode::SchemaMigration { module, name } => schema_migration::new_schema_migration(
236 edits,
237 find_module(app, module)?,
238 checked_name(name)?,
239 ),
240 NewCode::Aggregate { module, name } => {
241 schema::new_aggregate(edits, find_module(app, module)?, checked_type_name(name)?)
242 }
243 NewCode::Event { module, name } => events::new_event(
244 edits,
245 app,
246 find_module(app, module)?,
247 checked_type_name(name)?,
248 ),
249 NewCode::Listener {
250 module,
251 event,
252 handler,
253 } => events::new_listener(
254 edits,
255 app,
256 find_module(app, module)?,
257 checked_type_name(event)?,
258 checked_name(handler)?,
259 ),
260 NewCode::MenuEntry(entry) => menu::new_menu_entry(edits, app, entry),
261 NewCode::Area {
262 module,
263 name,
264 access,
265 } => area::new_area(edits, app, module, name, *access),
266 NewCode::AccountScreen { area, screen } => {
267 account_screen::new_account_screen(edits, app, area, *screen)
268 }
269 NewCode::Impersonation { permitted, member } => {
270 account_screen::new_impersonation(edits, app, permitted, member)
271 }
272 NewCode::Tenancy {
273 organization,
274 member,
275 } => account_screen::new_tenancy(edits, app, organization, member),
276 NewCode::Mail { module, name, list } => mail::new_mail(
277 edits,
278 find_module(app, module)?,
279 checked_name(name)?,
280 list.as_deref(),
281 ),
282 NewCode::Plan { module, name } => {
283 plan::new_plan(edits, app, find_module(app, module)?, checked_name(name)?)
284 }
285 NewCode::Route(new_route) => route::new_route(edits, app, new_route),
286 NewCode::Task { module, name } => {
287 task::new_task(edits, app, find_module(app, module)?, checked_name(name)?)
288 }
289 NewCode::Search { module, aggregate } => search::new_search(
290 edits,
291 find_module(app, module)?,
292 checked_type_name(aggregate)?,
293 ),
294 }
295}
296
297pub(crate) fn find_module<'app>(app: &'app App, name: &str) -> Result<&'app Module, GenError> {
298 app.modules
299 .iter()
300 .find(|module| module.krate.crate_name == name || module.krate.package == name)
301 .ok_or_else(|| {
302 let known: Vec<&str> = app
303 .modules
304 .iter()
305 .map(|module| module.krate.crate_name.as_str())
306 .collect();
307 GenError::Usage(format!(
308 "the app has no module `{name}`; its modules: {}",
309 known.join(", ")
310 ))
311 })
312}
313
314fn checked_type_name(name: &str) -> Result<&str, GenError> {
315 let pascal = name.starts_with(|first: char| first.is_ascii_uppercase())
316 && name
317 .chars()
318 .all(|character| character.is_ascii_alphanumeric());
319 if !pascal {
320 return Err(GenError::Usage(format!(
321 "`{name}` is not a type name `sofie new` takes: start with an uppercase letter and use \
322 only letters and digits, as in `OrderPlaced`"
323 )));
324 }
325 refuse_keyword(name, name)?;
326 refuse_keyword(name, &snake_case(name))?;
327 refuse_prelude_type(name)?;
328 Ok(name)
329}
330
331fn refuse_prelude_type(name: &str) -> Result<(), GenError> {
332 if !PRELUDE_TYPES.contains(&name) {
333 return Ok(());
334 }
335 Err(GenError::Usage(format!(
336 "`{name}` is a name a prelude brings into every file (`sofie::prelude` or Rust's), and the \
337 generated code naming that one would reach the new type instead; choose another name"
338 )))
339}
340
341fn include_contracts(journal: &mut Journal, workspace: &Path) -> Result<(), GenError> {
343 const MODULES: &str = "\"modules/*\"";
344 const CONTRACTS: &str = "\"modules/*/contract\"";
345 if !workspace.is_file() {
346 return Ok(());
347 }
348 let text = read(workspace)?;
349 if text.contains(CONTRACTS) || !text.contains(MODULES) {
350 return Ok(());
351 }
352 journal.write(
353 workspace,
354 text.replacen(MODULES, &format!("{MODULES}, {CONTRACTS}"), 1),
355 )
356}
357
358const RUST_KEYWORDS: [&str; 51] = [
360 "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate",
361 "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl",
362 "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref",
363 "return", "self", "Self", "static", "struct", "super", "trait", "true", "try", "type",
364 "typeof", "unsafe", "unsized", "use", "virtual", "where", "while",
365];
366const PRELUDE_TYPES: [&str; 87] = [
369 "Access",
370 "Aggregate",
371 "App",
372 "Area",
373 "Child",
374 "Children",
375 "Clock",
376 "Currency",
377 "Date",
378 "Dep",
379 "Email",
380 "Enum",
381 "Error",
382 "Event",
383 "Extensions",
384 "Groups",
385 "Id",
386 "Inbox",
387 "Input",
388 "Instant",
389 "Jobs",
390 "Load",
391 "LocalJobs",
392 "Lookup",
393 "Mailer",
394 "Menu",
395 "Meta",
396 "Money",
397 "Navigation",
398 "Notify",
399 "Page",
400 "Paginated",
401 "PersonName",
402 "Preferences",
403 "Principal",
404 "Prune",
405 "Read",
406 "ReadModel",
407 "Repeat",
408 "Resources",
409 "Result",
410 "Routes",
411 "Select",
412 "Session",
413 "SoftDelete",
414 "SoftDeletes",
415 "Timestamped",
416 "Url",
417 "Value",
418 "Web",
419 "AsMut",
420 "AsRef",
421 "Box",
422 "Clone",
423 "Copy",
424 "Default",
425 "DoubleEndedIterator",
426 "Drop",
427 "Eq",
428 "Err",
429 "ExactSizeIterator",
430 "Extend",
431 "Fn",
432 "FnMut",
433 "FnOnce",
434 "From",
435 "FromIterator",
436 "Into",
437 "IntoIterator",
438 "Iterator",
439 "None",
440 "Ok",
441 "Option",
442 "Ord",
443 "PartialEq",
444 "PartialOrd",
445 "Send",
446 "Sized",
447 "Some",
448 "String",
449 "Sync",
450 "ToOwned",
451 "ToString",
452 "TryFrom",
453 "TryInto",
454 "Unpin",
455 "Vec",
456];
457const TAKEN_CRATES: [&str; 6] = ["sofie", "std", "core", "alloc", "test", "proc_macro"];
459
460pub(crate) fn checked_name(name: &str) -> Result<&str, GenError> {
461 let plain = name.starts_with(|first: char| first.is_ascii_lowercase())
462 && name.chars().all(|character| {
463 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
464 });
465 if !plain {
466 return Err(GenError::Usage(format!(
467 "`{name}` is not a name `sofie new` takes: start with a lowercase letter and use only \
468 lowercase letters, digits and `_`"
469 )));
470 }
471 refuse_keyword(name, name)?;
472 Ok(name)
473}
474
475fn checked_package_name(name: &str) -> Result<&str, GenError> {
477 let name = checked_name(name)?;
478 if TAKEN_CRATES.contains(&name) {
479 return Err(GenError::Usage(format!(
480 "`{name}` names a crate every app links ({}); choose another name",
481 TAKEN_CRATES.join(", ")
482 )));
483 }
484 Ok(name)
485}
486
487fn 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
538enum 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
554pub(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}