Skip to main content

sofie_cli/
split_command.rs

1//! Dry by design: the plan is printed, and only `--write` carries it out.
2
3use 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    /// The app the module leaves.
32    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    /// Modules of the app it leaves whose contracts the module calls, served back to it.
43    pub calls_back: Vec<String>,
44    /// Modules it calls that an earlier split already moved elsewhere.
45    pub reaches: Vec<String>,
46    /// The contracts of `calls_back` and `reaches`, which the new app depends on.
47    pub called_contracts: Vec<Crate>,
48    pub proxy: Vec<String>,
49    pub shared_prefixes: Vec<String>,
50    /// The app's test files naming the module's crate, which it no longer depends on.
51    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
71/// Only `<crate>::` paths count: the crate's name alone is often a word, `order` a variable.
72fn 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
101/// Every step through the journal, so a failure, `sofie gen`'s findings on
102/// either app among them, undoes the split. The new app's generated code goes
103/// with its directory; the old app's is written outside the journal, so its
104/// findings are looked for before anything is written there.
105fn 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
177/// Contracts the module depends on: of modules staying behind, or of ones already split off.
178fn 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
222/// Each route's path up to its first parameter: the prefixes a reverse proxy sends to the new app.
223fn 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    /// What stops compiling in the app the module leaves.
268    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
418/// A path out of the moved module still names the same crate from its new place.
419fn 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
429/// Rewrites every `path = "…"` whose replacement `rewrite` gives.
430fn 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
465/// Every table's path into the module, a dev-dependency's too, follows it; the app it
466/// leaves then drops its own dependency on the module.
467fn 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
503/// The app's and every earlier split's: any of them may reach the moved contract.
504fn 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
521/// Its manifest follows the app it leaves: the same package keys, `sofie` dependency,
522/// build dependencies and lints, each path rebased.
523fn 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
618/// Just after the `[` of `[workspace]`'s `members`, not of `default-members`.
619fn 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
646/// Its line goes too when nothing else is on it.
647fn without_member(text: &str, member: &str) -> String {
648    let quoted = format!("\"{}\"", toml_escaped(member));
649    let Some(start) = text.find(&quoted) 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
661/// Written beside the app it leaves; a later split adds its prefixes to the same table.
662fn 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
716/// Adds `names` to `key` under `[package.metadata.sofie]`, the table `sofie_model` reads.
717fn 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}