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