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    /// The areas its contract defines, which the apps' wirings name through it.
54    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
73/// Only `<crate>::` paths count: the crate's name alone is often a word, `order` a variable.
74fn 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
103/// Every step through the journal, so a failure, `sofie gen`'s findings on
104/// either app among them, undoes the split. The new app's generated code goes
105/// with its directory; the old app's is written outside the journal, so its
106/// findings are looked for before anything is written there.
107fn 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
180/// Contracts the module depends on: of modules staying behind, or of ones already split off.
181fn 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
225/// Each route's path up to its first parameter: the prefixes a reverse proxy sends to the new app.
226fn 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    /// An app depending on the contract otherwise names nothing of it, which `sofie check`
271    /// refuses as an unused dependency.
272    fn wires_contract(&self) -> bool {
273        !self.contracts.is_empty() || !self.lookups.is_empty() || self.defines_areas
274    }
275
276    /// What stops compiling in the app the module leaves.
277    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
427/// A path out of the moved module still names the same crate from its new place.
428fn 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
438/// Rewrites every `path = "…"` whose replacement `rewrite` gives.
439fn 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
474/// Every table's path into the module, a dev-dependency's too, follows it; the app it
475/// leaves then drops its own dependency on the module.
476fn 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
512/// The app's and every earlier split's: any of them may reach the moved contract.
513fn 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
530/// Its manifest follows the app it leaves: the same package keys, `sofie` dependency,
531/// build dependencies and lints, each path rebased.
532fn 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
629/// Just after the `[` of `[workspace]`'s `members`, not of `default-members`.
630fn 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
657/// Its line goes too when nothing else is on it.
658fn without_member(text: &str, member: &str) -> String {
659    let quoted = format!("\"{}\"", toml_escaped(member));
660    let Some(start) = text.find(&quoted) 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
672/// Written beside the app it leaves; a later split adds its prefixes to the same table.
673fn 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
727/// Adds `names` to `key` under `[package.metadata.sofie]`, the table `sofie_model` reads.
728fn 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}