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