1use std::fmt::{self, Write as _};
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6
7use sofie_model::naming::{contract_package, relative_path};
8use sofie_model::{App, Module};
9
10use crate::env_example::remote_prefix;
11use crate::gen_command::{GenError, GenMode, GenOutcome, io_error, read_existing, regenerated};
12use crate::new_command::{app_sources, checked_name, find_module, refuse_existing, write_all};
13use crate::source_edits::{
14 DEPENDENCIES, Journal, path_dependency, read, toml_escaped, toml_section, toml_table,
15 with_dependency_line,
16};
17use crate::wiring::{crossing_traits, exposed_aggregates};
18
19const FEATURE: &str = "remote-contracts";
20const PROXY_TABLE: &str = "proxy.toml";
21
22#[derive(Debug)]
23pub struct SplitPlan {
24 pub module: String,
25 pub package: String,
26 pub app: String,
28 pub new_app: String,
29 pub root: PathBuf,
30 pub from: PathBuf,
31 pub to: PathBuf,
32 pub workspace: PathBuf,
33 pub contracts: Vec<String>,
34 pub stays_local: Vec<String>,
35 pub lookups: Vec<String>,
36 pub events: Vec<String>,
37 pub calls_back: Vec<String>,
39 pub reaches: Vec<String>,
41 pub proxy: Vec<String>,
42 pub shared_prefixes: Vec<String>,
43 pub orphaned_tests: Vec<PathBuf>,
45 pub last_module: bool,
46}
47
48#[derive(Debug)]
49pub struct Split {
50 pub plan: SplitPlan,
51 pub generated: Vec<GenOutcome>,
52}
53
54pub fn split_plan(root: &Path, module: &str) -> Result<SplitPlan, GenError> {
55 let root = fs::canonicalize(root).map_err(|error| io_error(root, error))?;
56 let app = sofie_model::load(&root).map_err(GenError::Model)?;
57 let module = find_module(&app, checked_name(module)?)?;
58 let workspace =
59 sofie_model::workspace_root(&app.app.path.join("Cargo.toml")).map_err(GenError::Model)?;
60 let orphaned_tests = tests_naming(&app.app.path.join("tests"), &module.krate.crate_name)?;
61 Ok(plan(&app, module, &root, workspace, orphaned_tests))
62}
63
64fn tests_naming(tests: &Path, crate_name: &str) -> Result<Vec<PathBuf>, GenError> {
66 let mut naming = Vec::new();
67 for file in sofie_model::rust_files(tests).map_err(GenError::Model)? {
68 if names_crate(&read(&file)?, crate_name) {
69 naming.push(file);
70 }
71 }
72 Ok(naming)
73}
74
75fn names_crate(source: &str, crate_name: &str) -> bool {
76 let path = format!("{crate_name}::");
77 source.match_indices(&path).any(|(start, _)| {
78 !source[..start]
79 .chars()
80 .next_back()
81 .is_some_and(|before| before.is_alphanumeric() || before == '_' || before == ':')
82 })
83}
84
85pub fn split_module(root: &Path, module: &str) -> Result<Split, GenError> {
86 let plan = split_plan(root, module)?;
87 refuse_existing(&plan.to)?;
88 let mut journal = Journal::default();
89 let generated = carry_out(&mut journal, &plan);
90 let generated = journal.finish(generated)?;
91 Ok(Split { plan, generated })
92}
93
94fn carry_out(journal: &mut Journal, plan: &SplitPlan) -> Result<Vec<GenOutcome>, GenError> {
97 move_module(journal, plan)?;
98 leave_app(journal, plan)?;
99 write_new_app(journal, plan)?;
100 add_members(journal, plan)?;
101 write_proxy_table(journal, plan)?;
102 let mut generated = regenerated(&plan.to, GenMode::Write)?;
103 generated.extend(regenerated(&plan.root, GenMode::Write)?);
104 Ok(generated)
105}
106
107fn plan(
108 app: &App,
109 module: &Module,
110 root: &Path,
111 workspace: PathBuf,
112 orphaned_tests: Vec<PathBuf>,
113) -> SplitPlan {
114 let name = module.krate.crate_name.clone();
115 let package = module.krate.package.clone();
116 let contract = module
117 .contract
118 .as_ref()
119 .map_or_else(String::new, |krate| krate.crate_name.clone());
120 let stays_local = module
121 .contract_traits
122 .iter()
123 .filter(|contract_trait| !contract_trait.crosses_apps())
124 .flat_map(|contract_trait| {
125 contract_trait.methods.iter().filter_map(move |method| {
126 let reason = method.stays_local?;
127 Some(format!(
128 "{}::{}: {reason}",
129 contract_trait.name, method.name
130 ))
131 })
132 })
133 .collect();
134 let (calls_back, reaches) = called_modules(app, module);
135 let (proxy, shared_prefixes) = proxy_prefixes(app, module);
136 SplitPlan {
137 contracts: crossing_traits(&module.contract_traits)
138 .map(|contract_trait| format!("{contract}::{}", contract_trait.path.join("::")))
139 .collect(),
140 stays_local,
141 lookups: exposed_aggregates(module)
142 .map(|aggregate| format!("{contract}::{}", aggregate.name))
143 .collect(),
144 events: module
145 .events
146 .iter()
147 .map(|event| format!("{name}.{event}"))
148 .collect(),
149 calls_back,
150 reaches,
151 proxy,
152 shared_prefixes,
153 orphaned_tests,
154 last_module: app.modules.len() == 1,
155 app: app.app.package.clone(),
156 new_app: format!("{package}-app"),
157 from: module.krate.path.clone(),
158 to: root.join("split").join(&package),
159 root: root.to_path_buf(),
160 workspace,
161 module: name,
162 package,
163 }
164}
165
166fn called_modules(app: &App, module: &Module) -> (Vec<String>, Vec<String>) {
168 let dependencies = module
169 .krate
170 .dependencies
171 .iter()
172 .chain(module.contract.iter().flat_map(|krate| &krate.dependencies));
173 let mut calls_back = Vec::new();
174 let mut reaches = Vec::new();
175 for dependency in dependencies {
176 let staying = app.modules.iter().find(|other| {
177 other.krate.package != module.krate.package
178 && other
179 .contract
180 .as_ref()
181 .is_some_and(|krate| krate.package == dependency.package)
182 });
183 let remote = app
184 .split
185 .remote
186 .iter()
187 .find(|remote| remote.contract.package == dependency.package);
188 if let Some(staying) = staying {
189 calls_back.push(staying.krate.crate_name.clone());
190 } else if let Some(remote) = remote {
191 reaches.push(remote.name.clone());
192 }
193 }
194 calls_back.sort();
195 calls_back.dedup();
196 reaches.sort();
197 reaches.dedup();
198 (calls_back, reaches)
199}
200
201fn proxy_prefixes(app: &App, module: &Module) -> (Vec<String>, Vec<String>) {
203 let sites = app.route_sites();
204 let static_prefix = |path: &str| {
205 let prefix = path.split('{').next().unwrap_or_default();
206 let trimmed = prefix.trim_end_matches('/');
207 if trimmed.is_empty() { "/" } else { trimmed }.to_owned()
208 };
209 let mut prefixes: Vec<String> = sites
210 .iter()
211 .filter(|site| site.module.krate.package == module.krate.package)
212 .filter_map(|site| site.full_path(app))
213 .map(|path| static_prefix(&path))
214 .collect();
215 prefixes.sort();
216 prefixes.dedup();
217 let staying: Vec<String> = sites
218 .iter()
219 .filter(|site| site.module.krate.package != module.krate.package)
220 .filter_map(|site| site.full_path(app))
221 .collect();
222 let shared = prefixes
223 .iter()
224 .filter(|prefix| staying.iter().any(|path| path.starts_with(prefix.as_str())))
225 .cloned()
226 .collect();
227 (prefixes, shared)
228}
229
230impl SplitPlan {
231 fn shown(&self, path: &Path) -> String {
232 path.strip_prefix(&self.root)
233 .unwrap_or(path)
234 .display()
235 .to_string()
236 }
237
238 fn moved_module(&self) -> PathBuf {
239 self.to.join("modules").join(&self.package)
240 }
241
242 fn contract_package(&self) -> String {
243 contract_package(&self.package)
244 }
245
246 fn write_breakage(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
248 if !self.stays_local.is_empty() {
249 writeln!(
250 formatter,
251 "Cannot cross apps, so callers in {} no longer compile",
252 self.app
253 )?;
254 list(formatter, "method", &self.stays_local)?;
255 }
256 if !self.orphaned_tests.is_empty() {
257 writeln!(
258 formatter,
259 "Tests naming `{}`, which {} no longer depends on: move them to {} \
260 or reach the module through `{}`",
261 self.module,
262 self.app,
263 self.new_app,
264 self.contract_package()
265 )?;
266 for test in &self.orphaned_tests {
267 writeln!(formatter, " test {}", self.shown(test))?;
268 }
269 }
270 Ok(())
271 }
272}
273
274impl fmt::Display for SplitPlan {
275 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
276 let module = &self.module;
277 writeln!(
278 formatter,
279 "sofie split {module}: module `{module}` leaves `{}` for a new app `{}`.\n",
280 self.app, self.new_app
281 )?;
282 writeln!(formatter, "Moves")?;
283 writeln!(
284 formatter,
285 " {} → {}: the crate pair, its areas, migrations, translations, config and \
286 .env.example",
287 self.shown(&self.from),
288 self.shown(&self.moved_module())
289 )?;
290 writeln!(formatter, "Writes")?;
291 writeln!(
292 formatter,
293 " {}/app: the new app, serving `{module}` (`[package.metadata.sofie] serves`)",
294 self.shown(&self.to)
295 )?;
296 writeln!(formatter, " {PROXY_TABLE}: the reverse-proxy table")?;
297 writeln!(formatter, "Changes")?;
298 writeln!(
299 formatter,
300 " app/Cargo.toml: drops `{}`, reaches `{}` at its new path, `remote = [\"{module}\"]`, \
301 sofie feature `{FEATURE}`",
302 self.package,
303 self.contract_package()
304 )?;
305 writeln!(
306 formatter,
307 " {}: the new app's crates join the workspace",
308 self.workspace.join("Cargo.toml").display()
309 )?;
310 writeln!(formatter, " then `sofie gen` in both apps")?;
311 writeln!(formatter, "Served by {} over HTTP", self.new_app)?;
312 list(formatter, "contract", &self.contracts)?;
313 list(formatter, "lookup", &self.lookups)?;
314 list(formatter, "event", &self.events)?;
315 if !self.calls_back.is_empty() {
316 writeln!(formatter, "Served back by {}", self.app)?;
317 list(formatter, "module", &self.calls_back)?;
318 }
319 if !self.reaches.is_empty() {
320 writeln!(formatter, "Reached by {} in their own apps", self.new_app)?;
321 list(formatter, "module", &self.reaches)?;
322 }
323 self.write_breakage(formatter)?;
324 writeln!(formatter, "Reverse proxy (longest prefix wins)")?;
325 for prefix in &self.proxy {
326 writeln!(formatter, " {prefix} → {}", self.new_app)?;
327 }
328 writeln!(formatter, " anything else → {}", self.app)?;
329 for prefix in &self.shared_prefixes {
330 writeln!(
331 formatter,
332 " warning: {prefix} also starts a route {} keeps; list that route's path \
333 in {PROXY_TABLE} by hand",
334 self.app
335 )?;
336 }
337 writeln!(formatter, "Keys")?;
338 let remote = remote_prefix(module);
339 writeln!(
340 formatter,
341 " {}: {remote}_BASE_URL, and {remote}_PRIVATE=true inside the network",
342 self.app
343 )?;
344 for called in self.calls_back.iter().chain(&self.reaches) {
345 let called = remote_prefix(called);
346 writeln!(formatter, " {}: {called}_BASE_URL", self.new_app)?;
347 }
348 writeln!(
349 formatter,
350 " both: the same SOFIE_SIGNING_KEYS; {}: a DATABASE_URL of its own",
351 self.new_app
352 )?;
353 if self.last_module {
354 writeln!(
355 formatter,
356 "Note: `{module}` is the app's last module, so its `modules/*` members leave the \
357 workspace: Cargo refuses a member glob matching nothing"
358 )?;
359 }
360 Ok(())
361 }
362}
363
364fn list(formatter: &mut fmt::Formatter<'_>, kind: &str, items: &[String]) -> fmt::Result {
365 for item in items {
366 writeln!(formatter, " {kind} {item}")?;
367 }
368 Ok(())
369}
370
371fn move_module(journal: &mut Journal, plan: &SplitPlan) -> Result<(), GenError> {
372 let to = plan.moved_module();
373 journal.rename(&plan.from, &to)?;
374 for manifest in [
375 to.join("Cargo.toml"),
376 to.join("contract").join("Cargo.toml"),
377 ] {
378 if manifest.is_file() {
379 let old = manifest_directory(&plan.from, &to, &manifest);
380 let text = rebased_paths(&read(&manifest)?, &old, &manifest_parent(&manifest), plan);
381 journal.write(&manifest, text)?;
382 }
383 }
384 Ok(())
385}
386
387fn manifest_parent(manifest: &Path) -> PathBuf {
388 manifest.parent().map(Path::to_path_buf).unwrap_or_default()
389}
390
391fn manifest_directory(from: &Path, to: &Path, manifest: &Path) -> PathBuf {
392 let within = manifest_parent(manifest);
393 let relative = within.strip_prefix(to).unwrap_or(Path::new(""));
394 from.join(relative)
395}
396
397fn rebased_paths(text: &str, old: &Path, new: &Path, plan: &SplitPlan) -> String {
399 edit_paths(text, |written| {
400 let target = normalized(&old.join(written));
401 if target.starts_with(&plan.from) {
402 return None;
403 }
404 relative_path(new, &target)
405 })
406}
407
408fn edit_paths(text: &str, rewrite: impl Fn(&str) -> Option<String>) -> String {
410 const KEY: &str = "path = \"";
411 let mut edited = String::with_capacity(text.len());
412 let mut rest = text;
413 while let Some(start) = rest.find(KEY) {
414 let value_start = start + KEY.len();
415 let Some(length) = rest[value_start..].find('"') else {
416 break;
417 };
418 let written = &rest[value_start..value_start + length];
419 edited.push_str(&rest[..value_start]);
420 match rewrite(written) {
421 Some(replaced) => edited.push_str(&toml_escaped(&replaced)),
422 None => edited.push_str(written),
423 }
424 rest = &rest[value_start + length..];
425 }
426 edited.push_str(rest);
427 edited
428}
429
430fn normalized(path: &Path) -> PathBuf {
431 let mut parts: Vec<Component> = Vec::new();
432 for component in path.components() {
433 match component {
434 Component::ParentDir if matches!(parts.last(), Some(Component::Normal(_))) => {
435 parts.pop();
436 }
437 Component::CurDir => {}
438 other => parts.push(other),
439 }
440 }
441 parts.iter().collect()
442}
443
444fn leave_app(journal: &mut Journal, plan: &SplitPlan) -> Result<(), GenError> {
445 let old_contract = plan.from.join("contract");
446 let new_contract = plan.moved_module().join("contract");
447 for manifest in staying_manifests(plan)? {
448 let directory = manifest_parent(&manifest);
449 let text = edit_paths(&read(&manifest)?, |written| {
450 (normalized(&directory.join(written)) == old_contract)
451 .then(|| relative_path(&directory, &new_contract))
452 .flatten()
453 });
454 journal.write(&manifest, text)?;
455 }
456 let app_manifest = plan.root.join("app").join("Cargo.toml");
457 let mut text = without_dependency(&read(&app_manifest)?, &plan.package);
458 if !text.contains(&format!("{} =", plan.contract_package())) {
459 let path = relative_path(&plan.root.join("app"), &new_contract).unwrap_or_default();
460 text = with_dependency_line(&text, &path_dependency(&plan.contract_package(), &path));
461 }
462 text = with_sofie_feature(&text);
463 text = with_metadata(&text, "remote", std::slice::from_ref(&plan.module));
464 text = with_metadata(&text, "serves", &plan.calls_back);
465 journal.write(&app_manifest, text)
466}
467
468fn staying_manifests(plan: &SplitPlan) -> Result<Vec<PathBuf>, GenError> {
470 let subdirectories =
471 |directory: &Path| sofie_model::subdirectories(directory).map_err(GenError::Model);
472 let mut apps = vec![plan.root.clone()];
473 apps.extend(subdirectories(&plan.root.join("split"))?);
474 let mut manifests = Vec::new();
475 for app in apps.iter().filter(|app| **app != plan.to) {
476 manifests.push(app.join("app").join("Cargo.toml"));
477 for module in subdirectories(&app.join("modules"))? {
478 manifests.push(module.join("Cargo.toml"));
479 manifests.push(module.join("contract").join("Cargo.toml"));
480 }
481 }
482 manifests.retain(|manifest| manifest.is_file());
483 Ok(manifests)
484}
485
486fn write_new_app(journal: &mut Journal, plan: &SplitPlan) -> Result<(), GenError> {
489 let app = plan.to.join("app");
490 let left = plan.root.join("app");
491 let original = edit_paths(&read(&left.join("Cargo.toml"))?, |written| {
492 relative_path(&app, &normalized(&left.join(written)))
493 });
494 let path = |target: &Path| relative_path(&app, target).unwrap_or_default();
495 let module = plan.moved_module();
496 let mut dependencies = kept_lines(&original, DEPENDENCIES, |line| {
497 line.starts_with("sofie ") || line.starts_with("sofie.")
498 });
499 dependencies.push_str(&path_dependency(&plan.package, &path(&module)));
500 dependencies.push_str(&path_dependency(
501 &plan.contract_package(),
502 &path(&module.join("contract")),
503 ));
504 let remote: Vec<String> = plan
505 .calls_back
506 .iter()
507 .chain(&plan.reaches)
508 .cloned()
509 .collect();
510 for called in &remote {
511 let contract = contract_directory(plan, called);
512 dependencies.push_str(&path_dependency(
513 &contract_package(called),
514 &path(&contract),
515 ));
516 }
517 let package = kept_lines(&original, "[package]", |line| {
518 !line.starts_with("name") && !line.starts_with("description")
519 });
520 let mut manifest = format!(
521 "[package]\nname = \"{}\"\n{}\n\n[package.metadata.sofie]\nserves = [\"{}\"]\nremote = [{}]\n\n\
522 [dependencies]\n{dependencies}",
523 plan.new_app,
524 package.trim_end(),
525 plan.module,
526 quoted(&remote),
527 );
528 for kept in ["[build-dependencies]", "[lints]"] {
529 let body = section(&original, kept);
530 if !body.is_empty() {
531 let _ = write!(manifest, "\n{kept}\n{}\n", body.trim_end());
532 }
533 }
534 let crate_name = sofie_model::naming::crate_name(&plan.new_app);
535 let [build, library, main] = app_sources(&crate_name);
536 let mut files = vec![("app/Cargo.toml", manifest), library, main];
537 if left.join("build.rs").is_file() {
538 files.push(build);
539 }
540 write_all(journal, &plan.to, &files)
541}
542
543fn section<'text>(text: &'text str, header: &str) -> &'text str {
544 toml_section(text, header).map_or("", |(start, end)| &text[start..end])
545}
546
547fn kept_lines(text: &str, header: &str, keep: impl Fn(&str) -> bool) -> String {
548 section(text, header)
549 .lines()
550 .filter(|line| keep(line))
551 .fold(String::new(), |mut kept, line| {
552 let _ = writeln!(kept, "{line}");
553 kept
554 })
555}
556
557fn contract_directory(plan: &SplitPlan, module: &str) -> PathBuf {
559 let staying = plan.root.join("modules").join(module).join("contract");
560 if staying.is_dir() {
561 return staying;
562 }
563 plan.root
564 .join("split")
565 .join(module)
566 .join("modules")
567 .join(module)
568 .join("contract")
569}
570
571const APP_MEMBERS: [&str; 3] = ["app", "modules/*", "modules/*/contract"];
572
573fn add_members(journal: &mut Journal, plan: &SplitPlan) -> Result<(), GenError> {
574 let manifest = plan.workspace.join("Cargo.toml");
575 let mut text = read(&manifest)?;
576 if plan.last_module {
577 for member in &APP_MEMBERS[1..] {
578 text = without_member(&text, &workspace_member(plan, &plan.root, member));
579 }
580 }
581 let members = APP_MEMBERS
582 .map(|member| {
583 format!(
584 "\"{}\", ",
585 toml_escaped(&workspace_member(plan, &plan.to, member))
586 )
587 })
588 .concat();
589 let Some(start) = text.find("members = [") else {
590 return Err(GenError::Usage(format!(
591 "{} lists no `members = [`; add the new app's crates to the workspace by hand",
592 manifest.display()
593 )));
594 };
595 let at = start + "members = [".len();
596 journal.write(
597 &manifest,
598 format!("{}{members}{}", &text[..at], &text[at..]),
599 )
600}
601
602fn workspace_member(plan: &SplitPlan, app: &Path, member: &str) -> String {
603 match relative_path(&plan.workspace, app)
604 .unwrap_or_default()
605 .as_str()
606 {
607 "" => member.to_owned(),
608 within => format!("{within}/{member}"),
609 }
610}
611
612fn without_member(text: &str, member: &str) -> String {
614 let quoted = format!("\"{}\"", toml_escaped(member));
615 let Some(start) = text.find("ed) else {
616 return text.to_owned();
617 };
618 let after = &text[start + quoted.len()..];
619 let end = text.len() - after.trim_start_matches([',', ' ']).len();
620 let line_start = text[..start].rfind('\n').map_or(0, |newline| newline + 1);
621 if text[line_start..start].trim().is_empty() && text[end..].starts_with('\n') {
622 return format!("{}{}", &text[..line_start], &text[end + 1..]);
623 }
624 format!("{}{}", &text[..start], &text[end..])
625}
626
627fn write_proxy_table(journal: &mut Journal, plan: &SplitPlan) -> Result<(), GenError> {
629 let table = plan.root.join(PROXY_TABLE);
630 let mut entries: Vec<String> = read_existing(&table)?
631 .unwrap_or_default()
632 .lines()
633 .filter(|line| line.starts_with('"'))
634 .map(str::to_owned)
635 .collect();
636 entries.extend(
637 plan.proxy
638 .iter()
639 .map(|prefix| format!("\"{}\" = \"{}\"", toml_escaped(prefix), plan.new_app)),
640 );
641 let text = format!(
642 "# Written by `sofie split`: the app answering each path, the longest matching\n\
643 # prefix first; any other path goes to `default`. `/_sofie/` stays inside the\n\
644 # network: only apps call it, with principal tokens.\n\
645 default = \"{}\"\n\n[prefixes]\n{}\n",
646 plan.app,
647 entries.join("\n")
648 );
649 journal.write(&table, text)
650}
651
652fn quoted(names: &[String]) -> String {
653 names
654 .iter()
655 .map(|name| format!("\"{name}\""))
656 .collect::<Vec<_>>()
657 .join(", ")
658}
659
660fn without_dependency(text: &str, package: &str) -> String {
661 let Some((start, end)) = toml_section(text, DEPENDENCIES) else {
662 return text.to_owned();
663 };
664 let kept: String = text[start..end]
665 .split_inclusive('\n')
666 .filter(|line| {
667 let key = line.split(['=', '.']).next().unwrap_or_default().trim();
668 key != package
669 })
670 .collect();
671 format!("{}{kept}{}", &text[..start], &text[end..])
672}
673
674fn with_sofie_feature(text: &str) -> String {
675 let Some((start, end)) = toml_section(text, DEPENDENCIES) else {
676 return text.to_owned();
677 };
678 let edited: String = text[start..end]
679 .split_inclusive('\n')
680 .map(|line| {
681 let trimmed = line.trim_end();
682 if trimmed == "sofie.workspace = true" {
683 return format!("sofie = {{ workspace = true, features = [\"{FEATURE}\"] }}\n");
684 }
685 if !trimmed.starts_with("sofie = {") || trimmed.contains(FEATURE) {
686 return line.to_owned();
687 }
688 match trimmed.find("features = [") {
689 Some(at) => {
690 let at = at + "features = [".len();
691 format!("{}\"{FEATURE}\", {}\n", &trimmed[..at], &trimmed[at..])
692 }
693 None => format!(
694 "{}, features = [\"{FEATURE}\"] }}\n",
695 trimmed.trim_end_matches('}').trim_end()
696 ),
697 }
698 })
699 .collect();
700 format!("{}{edited}{}", &text[..start], &text[end..])
701}
702
703fn with_metadata(text: &str, key: &str, names: &[String]) -> String {
705 const TABLE: &str = "[package.metadata.sofie]";
706 if names.is_empty() {
707 return text.to_owned();
708 }
709 let Some((table, end)) = toml_section(text, TABLE) else {
710 let package_end =
711 toml_table(text, DEPENDENCIES).map_or(text.len(), |(header_start, ..)| header_start);
712 return format!(
713 "{}{TABLE}\n{key} = [{}]\n\n{}",
714 &text[..package_end],
715 quoted(names),
716 &text[package_end..]
717 );
718 };
719 let prefix = format!("{key} = [");
720 match text[table..end].find(&prefix) {
721 Some(offset) => {
722 let at = table + offset + prefix.len();
723 let listed = format!("{}, ", quoted(names));
724 format!("{}{listed}{}", &text[..at], &text[at..])
725 }
726 None => format!(
727 "{}{key} = [{}]\n{}",
728 &text[..table],
729 quoted(names),
730 &text[table..]
731 ),
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738
739 #[test]
740 fn a_test_names_a_crate_by_a_path_starting_with_it() {
741 assert!(names_crate("use order::Order;", "order"));
742 assert!(names_crate("let total = <order::Order>::total;", "order"));
743 assert!(!names_crate("let order = place(&shop);", "order"));
744 assert!(!names_crate("use crate::order::Order;", "order"));
745 assert!(!names_crate("use reorder::Order;", "order"));
746 assert!(!names_crate("use order_contract::routes;", "order"));
747 }
748
749 const MANIFEST: &str = "[package]\nname = \"shop\"\n\n[dependencies]\nsofie.workspace = true\n\
750 order = { path = \"../modules/order\" }\n\
751 order-contract = { path = \"../modules/order/contract\" }\n\n\
752 [lints]\nworkspace = true\n";
753
754 #[test]
755 fn a_metadata_key_goes_into_its_own_table_only() {
756 let text = "[package.metadata.sofie]\nserves = [\"a\"]\n\n[package.metadata.other]\n\
757 remote = [\"x\"]\n";
758
759 let text = with_metadata(text, "remote", &["order".to_owned()]);
760
761 assert_eq!(
762 text,
763 "[package.metadata.sofie]\nremote = [\"order\"]\nserves = [\"a\"]\n\n\
764 [package.metadata.other]\nremote = [\"x\"]\n"
765 );
766 }
767
768 #[test]
769 fn the_app_left_drops_the_module_and_turns_on_remote_contracts() {
770 let text = without_dependency(MANIFEST, "order");
771 let text = with_sofie_feature(&text);
772 let text = with_metadata(&text, "remote", &["order".to_owned()]);
773 let text = with_metadata(&text, "remote", &["billing".to_owned()]);
774
775 assert_eq!(
776 text,
777 "[package]\nname = \"shop\"\n\n[package.metadata.sofie]\nremote = [\"billing\", \"order\"]\n\n\
778 [dependencies]\nsofie = { workspace = true, features = [\"remote-contracts\"] }\n\
779 order-contract = { path = \"../modules/order/contract\" }\n\n[lints]\nworkspace = true\n"
780 );
781 }
782
783 #[test]
784 fn an_sofie_dependency_with_features_gains_one_more() {
785 let text = "[dependencies]\nsofie = { workspace = true, features = [\"config\"] }\n";
786
787 assert_eq!(
788 with_sofie_feature(text),
789 "[dependencies]\nsofie = { workspace = true, features = [\"remote-contracts\", \"config\"] }\n"
790 );
791 }
792
793 #[test]
794 fn a_member_leaves_the_workspace_with_its_line_when_it_had_one() {
795 let listed = "members = [\n \"shop/app\",\n \"shop/modules/*\",\n]\n";
796 let inline = "members = [\"app\", \"modules/*\", \"modules/*/contract\"]\n";
797
798 assert_eq!(
799 without_member(listed, "shop/modules/*"),
800 "members = [\n \"shop/app\",\n]\n"
801 );
802 assert_eq!(
803 without_member(inline, "modules/*"),
804 "members = [\"app\", \"modules/*/contract\"]\n"
805 );
806 assert_eq!(without_member(inline, "other/*"), inline);
807 }
808
809 #[test]
810 fn a_proxy_table_it_cannot_read_is_left_as_it_is() {
811 let directory = sofie_model::testing::TemporaryDirectory::new("sofie-split-proxy");
812 let table = directory.path(PROXY_TABLE);
813 fs::write(&table, b"\"/orders\" = \"order-app\"\n\xff\n").unwrap();
814 let plan = SplitPlan {
815 module: "billing".to_owned(),
816 package: "billing".to_owned(),
817 app: "shop".to_owned(),
818 new_app: "billing-app".to_owned(),
819 root: directory.root().to_path_buf(),
820 from: PathBuf::new(),
821 to: PathBuf::new(),
822 workspace: PathBuf::new(),
823 contracts: Vec::new(),
824 stays_local: Vec::new(),
825 lookups: Vec::new(),
826 events: Vec::new(),
827 calls_back: Vec::new(),
828 reaches: Vec::new(),
829 proxy: vec!["/invoices".to_owned()],
830 shared_prefixes: Vec::new(),
831 orphaned_tests: Vec::new(),
832 last_module: false,
833 };
834
835 assert!(write_proxy_table(&mut Journal::default(), &plan).is_err());
836 assert_eq!(
837 fs::read(&table).unwrap(),
838 b"\"/orders\" = \"order-app\"\n\xff\n"
839 );
840 }
841
842 #[test]
843 fn paths_are_rewritten_only_where_asked() {
844 let text = "a = { path = \"contract\" }\nb = { path = \"../../crates/sofie-identity\" }\n";
845
846 let edited = edit_paths(text, |written| {
847 written
848 .starts_with("..")
849 .then(|| format!("../../{written}"))
850 });
851
852 assert_eq!(
853 edited,
854 "a = { path = \"contract\" }\nb = { path = \"../../../../crates/sofie-identity\" }\n"
855 );
856 assert_eq!(
857 normalized(Path::new("/shop/modules/order/../../crates")),
858 Path::new("/shop/crates")
859 );
860 }
861}