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