Skip to main content

cargo_statum_graph/
lib.rs

1use std::env;
2use std::ffi::OsString;
3use std::fmt;
4use std::fmt::Write as _;
5use std::fs;
6use std::io;
7use std::path::Component;
8use std::path::{Path, PathBuf};
9use std::process::{Command, ExitStatus, Stdio};
10
11use cargo_metadata::{Metadata, MetadataCommand, Package, PackageId};
12use statum_graph::CodebaseDoc;
13
14mod heuristics;
15mod inspect;
16mod suggestions;
17
18pub use heuristics::{
19    collect_heuristic_overlay, HeuristicDiagnostic, HeuristicEvidenceKind,
20    HeuristicMachineRelationGroup, HeuristicOverlay, HeuristicRelation, HeuristicRelationCount,
21    HeuristicRelationDetail, HeuristicRelationSource, HeuristicStatusKind, InspectPackageSource,
22};
23pub use suggestions::{
24    collect_composition_suggestions, render_composition_suggestions, CompositionSuggestion,
25    CompositionSuggestionKind, CompositionSuggestionOverlay, CompositionSuggestionSeverity,
26};
27
28const GRAPH_EXTENSIONS: [&str; 4] = ["mmd", "dot", "puml", "json"];
29const GRAPH_PACKAGE_NAME: &str = "statum-graph";
30const HELPER_PACKAGE_NAME: &str = "cargo-statum-graph";
31const STATUM_WORKSPACE_PACKAGES: [&str; 6] = [
32    "macro_registry",
33    "module_path_extractor",
34    "statum",
35    "statum-core",
36    "statum-graph",
37    "statum-macros",
38];
39const RUNNER_SCHEMA_VERSION: u32 = 1;
40const NO_LINKED_MACHINES_MESSAGE: &str = "statum-graph: no linked state machines were found in the target workspace. This can mean the workspace has no Statum machines, or that it depends on incompatible `statum`, `statum-core`, or `statum-graph` versions so linked inventories do not unify. If you expected machines here, ensure those crates use compatible versions.";
41const NO_TTY_INSPECT_MESSAGE: &str =
42    "statum-graph inspect requires an interactive terminal on stdin and stdout.";
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct ExportOptions {
46    pub input_path: PathBuf,
47    pub package: Option<String>,
48    pub out_dir: Option<PathBuf>,
49    pub stem: String,
50    pub patch_statum_root: Option<PathBuf>,
51}
52
53#[doc(hidden)]
54pub type Options = ExportOptions;
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct InspectOptions {
58    pub input_path: PathBuf,
59    pub package: Option<String>,
60    pub patch_statum_root: Option<PathBuf>,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct SuggestOptions {
65    pub input_path: PathBuf,
66    pub package: Option<String>,
67    pub patch_statum_root: Option<PathBuf>,
68}
69
70#[derive(Debug)]
71pub enum Error {
72    CurrentDir(io::Error),
73    Metadata(cargo_metadata::Error),
74    PackageNotFound {
75        manifest_path: PathBuf,
76        package: String,
77    },
78    AmbiguousPackage {
79        manifest_path: PathBuf,
80        candidates: Vec<String>,
81    },
82    PackageHasNoLibrary {
83        manifest_path: PathBuf,
84        package: String,
85    },
86    AmbiguousPatchStatumRoots {
87        manifest_path: PathBuf,
88        candidates: Vec<PathBuf>,
89    },
90    InvalidStem {
91        stem: String,
92    },
93    NonUtf8Path {
94        role: &'static str,
95        path: PathBuf,
96    },
97    Io {
98        action: &'static str,
99        path: PathBuf,
100        source: io::Error,
101    },
102    RunnerFailed {
103        operation: &'static str,
104        manifest_path: PathBuf,
105        status: ExitStatus,
106        details: Option<String>,
107        diagnostics_reported: bool,
108    },
109}
110
111impl fmt::Display for Error {
112    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            Self::CurrentDir(source) => {
115                write!(formatter, "failed to read current directory: {source}")
116            }
117            Self::Metadata(source) => write!(formatter, "failed to load cargo metadata: {source}"),
118            Self::PackageNotFound {
119                manifest_path,
120                package,
121            } => write!(
122                formatter,
123                "manifest `{}` does not contain package `{package}`",
124                manifest_path.display()
125            ),
126            Self::AmbiguousPackage {
127                manifest_path,
128                candidates,
129            } => {
130                if candidates.is_empty() {
131                    write!(
132                        formatter,
133                        "manifest `{}` does not contain a library package",
134                        manifest_path.display()
135                    )
136                } else {
137                    write!(
138                        formatter,
139                        "manifest `{}` does not identify one library package; choose one of: {}",
140                        manifest_path.display(),
141                        candidates.join(", ")
142                    )
143                }
144            }
145            Self::PackageHasNoLibrary {
146                manifest_path,
147                package,
148            } => write!(
149                formatter,
150                "package `{package}` from manifest `{}` does not expose a library target",
151                manifest_path.display()
152            ),
153            Self::AmbiguousPatchStatumRoots {
154                manifest_path,
155                candidates,
156            } => write!(
157                formatter,
158                "manifest `{}` reaches multiple local Statum workspace roots; use --patch-statum-root to choose one: {}",
159                manifest_path.display(),
160                candidates
161                    .iter()
162                    .map(|candidate| candidate.display().to_string())
163                    .collect::<Vec<_>>()
164                    .join(", ")
165            ),
166            Self::InvalidStem { stem } => write!(
167                formatter,
168                "invalid output stem `{stem}`: expected a simple file name without path separators"
169            ),
170            Self::NonUtf8Path { role, path } => write!(
171                formatter,
172                "cannot generate runner {role} from non-UTF-8 path `{}`",
173                path.display()
174            ),
175            Self::Io {
176                action,
177                path,
178                source,
179            } => write!(
180                formatter,
181                "failed to {action} `{}`: {source}",
182                path.display()
183            ),
184            Self::RunnerFailed {
185                operation,
186                manifest_path,
187                status,
188                details,
189                diagnostics_reported: _,
190            } => match details {
191                Some(details) => write!(
192                    formatter,
193                    "{operation} for `{}` failed:\n{details}",
194                    manifest_path.display()
195                ),
196                None => write!(
197                    formatter,
198                    "{operation} for `{}` failed with status {status}",
199                    manifest_path.display()
200                ),
201            },
202        }
203    }
204}
205
206impl Error {
207    pub fn diagnostics_reported(&self) -> bool {
208        matches!(
209            self,
210            Self::RunnerFailed {
211                diagnostics_reported: true,
212                ..
213            }
214        )
215    }
216}
217
218impl std::error::Error for Error {
219    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
220        match self {
221            Self::CurrentDir(source) => Some(source),
222            Self::Metadata(source) => Some(source),
223            Self::Io { source, .. } => Some(source),
224            Self::PackageNotFound { .. }
225            | Self::AmbiguousPackage { .. }
226            | Self::PackageHasNoLibrary { .. }
227            | Self::AmbiguousPatchStatumRoots { .. }
228            | Self::InvalidStem { .. }
229            | Self::NonUtf8Path { .. }
230            | Self::RunnerFailed { .. } => None,
231        }
232    }
233}
234
235pub fn export(options: ExportOptions) -> Result<Vec<PathBuf>, Error> {
236    validate_output_stem(&options.stem)?;
237    let prepared = prepare_run(
238        &options.input_path,
239        options.package.as_deref(),
240        options.patch_statum_root.as_deref(),
241    )?;
242    let out_dir = resolve_out_dir(&prepared.input, options.out_dir.as_deref())?;
243    let runner = materialize_cached_runner(
244        &prepared.target_directory,
245        &prepared.selections,
246        prepared.patch_root.as_deref(),
247    )?;
248    run_runner(
249        &runner.runner,
250        &prepared.input.manifest_path,
251        "workspace export",
252        RunnerStdio::Captured,
253        &[
254            OsString::from("export"),
255            out_dir.as_os_str().to_owned(),
256            OsString::from(options.stem.clone()),
257        ],
258    )?;
259
260    Ok(bundle_paths(&out_dir, &options.stem))
261}
262
263#[doc(hidden)]
264pub fn run(options: Options) -> Result<Vec<PathBuf>, Error> {
265    export(options)
266}
267
268pub fn inspect(options: InspectOptions) -> Result<(), Error> {
269    let prepared = prepare_run(
270        &options.input_path,
271        options.package.as_deref(),
272        options.patch_statum_root.as_deref(),
273    )?;
274    let workspace_label = prepared.input.manifest_path.display().to_string();
275    let runner = materialize_cached_runner(
276        &prepared.target_directory,
277        &prepared.selections,
278        prepared.patch_root.as_deref(),
279    )?;
280    run_runner(
281        &runner.runner,
282        &prepared.input.manifest_path,
283        "inspect session",
284        RunnerStdio::Inherited,
285        &[OsString::from("inspect"), OsString::from(workspace_label)],
286    )
287}
288
289pub fn suggest(options: SuggestOptions) -> Result<String, Error> {
290    let prepared = prepare_run(
291        &options.input_path,
292        options.package.as_deref(),
293        options.patch_statum_root.as_deref(),
294    )?;
295    let runner = materialize_cached_runner(
296        &prepared.target_directory,
297        &prepared.selections,
298        prepared.patch_root.as_deref(),
299    )?;
300
301    run_runner_captured(
302        runner.runner.manifest_path,
303        &prepared.target_directory,
304        &prepared.input.manifest_path,
305        "composition suggestion report",
306        &[OsString::from("suggest")],
307    )
308}
309
310pub fn run_inspector(
311    doc: CodebaseDoc,
312    heuristic: HeuristicOverlay,
313    workspace_label: String,
314) -> Result<(), InspectError> {
315    let suggestions = suggestions::collect_composition_suggestions(&doc, &heuristic);
316    inspect::run(doc, heuristic, suggestions, workspace_label).map_err(InspectError::Io)
317}
318
319#[derive(Debug)]
320pub enum InspectError {
321    Io(io::Error),
322}
323
324impl fmt::Display for InspectError {
325    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
326        match self {
327            Self::Io(source) => write!(formatter, "{source}"),
328        }
329    }
330}
331
332impl std::error::Error for InspectError {
333    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
334        match self {
335            Self::Io(source) => Some(source),
336        }
337    }
338}
339
340fn load_metadata(manifest_path: &Path) -> Result<Metadata, Error> {
341    MetadataCommand::new()
342        .manifest_path(manifest_path)
343        .no_deps()
344        .exec()
345        .map_err(Error::Metadata)
346}
347
348fn load_metadata_with_deps(manifest_path: &Path) -> Result<Metadata, Error> {
349    MetadataCommand::new()
350        .manifest_path(manifest_path)
351        .exec()
352        .map_err(Error::Metadata)
353}
354
355fn select_packages(
356    metadata: &Metadata,
357    input: &ResolvedInput,
358    requested: Option<&str>,
359) -> Result<Vec<SelectedPackage>, Error> {
360    let manifest_path = input.manifest_path.as_path();
361    if let Some(package) = requested {
362        let selected = metadata
363            .packages
364            .iter()
365            .find(|candidate| candidate.name.as_ref() == package)
366            .ok_or_else(|| Error::PackageNotFound {
367                manifest_path: manifest_path.to_path_buf(),
368                package: package.to_owned(),
369            })?;
370        return SelectedPackage::new(selected, manifest_path).map(|package| vec![package]);
371    }
372
373    if manifest_path == workspace_root_manifest(metadata) {
374        let mut packages = workspace_packages(metadata, &metadata.workspace_members)
375            .into_iter()
376            .filter(|package| has_library_target(package))
377            .collect::<Vec<_>>();
378        packages.sort_by(|left, right| {
379            left.name
380                .as_ref()
381                .cmp(right.name.as_ref())
382                .then_with(|| left.manifest_path.cmp(&right.manifest_path))
383        });
384
385        if packages.is_empty() {
386            return Err(Error::AmbiguousPackage {
387                manifest_path: manifest_path.to_path_buf(),
388                candidates: Vec::new(),
389            });
390        }
391
392        return packages
393            .into_iter()
394            .map(|package| SelectedPackage::new(package, manifest_path))
395            .collect();
396    }
397
398    if let Some(root_package) = metadata.root_package() {
399        if has_library_target(root_package) {
400            return SelectedPackage::new(root_package, manifest_path).map(|package| vec![package]);
401        }
402    }
403
404    let default_members = workspace_packages(metadata, &metadata.workspace_default_members);
405    let default_library_members = default_members
406        .into_iter()
407        .filter(|package| has_library_target(package))
408        .collect::<Vec<_>>();
409    if default_library_members.len() == 1 {
410        return SelectedPackage::new(default_library_members[0], manifest_path)
411            .map(|package| vec![package]);
412    }
413
414    let workspace_members = workspace_packages(metadata, &metadata.workspace_members);
415    let library_members = workspace_members
416        .into_iter()
417        .filter(|package| has_library_target(package))
418        .collect::<Vec<_>>();
419
420    match library_members.as_slice() {
421        [package] => SelectedPackage::new(package, manifest_path).map(|package| vec![package]),
422        [] => Err(Error::AmbiguousPackage {
423            manifest_path: manifest_path.to_path_buf(),
424            candidates: Vec::new(),
425        }),
426        _ => Err(Error::AmbiguousPackage {
427            manifest_path: manifest_path.to_path_buf(),
428            candidates: library_members
429                .iter()
430                .map(|package| package.name.to_string())
431                .collect(),
432        }),
433    }
434}
435
436fn workspace_root_manifest(metadata: &Metadata) -> PathBuf {
437    normalize_absolute_path(&metadata.workspace_root.as_std_path().join("Cargo.toml"))
438}
439
440fn workspace_packages<'a>(metadata: &'a Metadata, ids: &[PackageId]) -> Vec<&'a Package> {
441    ids.iter()
442        .filter_map(|id| metadata.packages.iter().find(|package| package.id == *id))
443        .collect()
444}
445
446fn has_library_target(package: &Package) -> bool {
447    library_target(package).is_some()
448}
449
450fn library_target(package: &Package) -> Option<&cargo_metadata::Target> {
451    package.targets.iter().find(|target| {
452        target.kind.iter().any(|kind| {
453            matches!(
454                kind,
455                cargo_metadata::TargetKind::Lib
456                    | cargo_metadata::TargetKind::RLib
457                    | cargo_metadata::TargetKind::DyLib
458            )
459        })
460    })
461}
462
463fn resolve_out_dir(input: &ResolvedInput, out_dir: Option<&Path>) -> Result<PathBuf, Error> {
464    match out_dir {
465        Some(path) => absolutize(path).map_err(Error::CurrentDir),
466        None => Ok(input.default_output_dir.clone()),
467    }
468}
469
470fn prepare_run(
471    input_path: &Path,
472    requested_package: Option<&str>,
473    patch_statum_root: Option<&Path>,
474) -> Result<PreparedRun, Error> {
475    let input_path = absolutize(input_path).map_err(Error::CurrentDir)?;
476    let input = resolve_input(&input_path);
477    let metadata = load_metadata(&input.manifest_path)?;
478    let selections = select_packages(&metadata, &input, requested_package)?;
479    let target_directory = normalize_absolute_path(metadata.target_directory.as_std_path());
480    let patch_root = match patch_statum_root {
481        Some(path) => Some(absolutize(path).map_err(Error::CurrentDir)?),
482        None => detect_patch_root().or(detect_patch_root_from_target_workspace(
483            &input.manifest_path,
484        )?),
485    };
486
487    Ok(PreparedRun {
488        input,
489        selections,
490        target_directory,
491        patch_root,
492    })
493}
494
495fn detect_patch_root() -> Option<PathBuf> {
496    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
497    let candidate = manifest_dir.parent()?;
498    if looks_like_statum_workspace(candidate) {
499        Some(candidate.to_path_buf())
500    } else {
501        None
502    }
503}
504
505fn looks_like_statum_workspace(path: &Path) -> bool {
506    [
507        "Cargo.toml",
508        "statum/Cargo.toml",
509        "statum-core/Cargo.toml",
510        "statum-graph/Cargo.toml",
511        "statum-macros/Cargo.toml",
512    ]
513    .into_iter()
514    .all(|relative| path.join(relative).is_file())
515}
516
517fn detect_patch_root_from_target_workspace(manifest_path: &Path) -> Result<Option<PathBuf>, Error> {
518    let metadata = load_metadata_with_deps(manifest_path)?;
519    let manifest_dirs = metadata
520        .packages
521        .iter()
522        .filter(|package| is_statum_workspace_package(package.name.as_ref()))
523        .filter_map(|package| {
524            package
525                .manifest_path
526                .as_std_path()
527                .parent()
528                .map(normalize_absolute_path)
529        })
530        .collect::<Vec<_>>();
531    detect_patch_root_from_manifest_dirs(manifest_path, manifest_dirs)
532}
533
534fn is_statum_workspace_package(package_name: &str) -> bool {
535    STATUM_WORKSPACE_PACKAGES.contains(&package_name)
536}
537
538fn detect_patch_root_from_manifest_dirs(
539    manifest_path: &Path,
540    manifest_dirs: impl IntoIterator<Item = PathBuf>,
541) -> Result<Option<PathBuf>, Error> {
542    let mut candidates = manifest_dirs
543        .into_iter()
544        .filter_map(|manifest_dir| {
545            manifest_dir
546                .parent()
547                .map(normalize_absolute_path)
548                .and_then(|root| {
549                    if looks_like_statum_workspace(&root) {
550                        Some(root)
551                    } else {
552                        None
553                    }
554                })
555        })
556        .collect::<Vec<_>>();
557    candidates.sort();
558    candidates.dedup();
559
560    match candidates.as_slice() {
561        [] => Ok(None),
562        [root] => Ok(Some(root.clone())),
563        _ => Err(Error::AmbiguousPatchStatumRoots {
564            manifest_path: manifest_path.to_path_buf(),
565            candidates,
566        }),
567    }
568}
569
570fn resolve_input(path: &Path) -> ResolvedInput {
571    if path.is_dir() {
572        ResolvedInput {
573            manifest_path: path.join("Cargo.toml"),
574            default_output_dir: path.to_path_buf(),
575        }
576    } else {
577        ResolvedInput {
578            manifest_path: path.to_path_buf(),
579            default_output_dir: path
580                .parent()
581                .expect("absolute file path should have a parent")
582                .to_path_buf(),
583        }
584    }
585}
586
587#[derive(Clone, Debug, Eq, PartialEq)]
588struct CachedRunner {
589    key: String,
590    home_dir: PathBuf,
591    manifest_path: PathBuf,
592    target_directory: PathBuf,
593}
594
595#[derive(Clone, Debug, Eq, PartialEq)]
596struct MaterializedCachedRunner {
597    runner: CachedRunner,
598    manifest_rewritten: bool,
599    source_rewritten: bool,
600}
601
602fn materialize_cached_runner(
603    target_directory: &Path,
604    selections: &[SelectedPackage],
605    patch_root: Option<&Path>,
606) -> Result<MaterializedCachedRunner, Error> {
607    let key = runner_key(selections, patch_root)?;
608    let home_dir = cached_runner_home(target_directory, &key);
609    let src_dir = home_dir.join("src");
610    fs::create_dir_all(&src_dir).map_err(|source| Error::Io {
611        action: "create cached runner source directory",
612        path: src_dir.clone(),
613        source,
614    })?;
615
616    let manifest_path = home_dir.join("Cargo.toml");
617    let manifest_rewritten = write_file_if_changed(
618        &manifest_path,
619        &build_runner_manifest(selections, patch_root)?,
620        "write cached runner manifest",
621    )?;
622
623    let main_path = src_dir.join("main.rs");
624    let source_rewritten = write_file_if_changed(
625        &main_path,
626        &build_runner_main(selections)?,
627        "write cached runner source",
628    )?;
629
630    Ok(MaterializedCachedRunner {
631        runner: CachedRunner {
632            key,
633            home_dir,
634            manifest_path,
635            target_directory: target_directory.to_path_buf(),
636        },
637        manifest_rewritten,
638        source_rewritten,
639    })
640}
641
642fn build_runner_manifest(
643    selections: &[SelectedPackage],
644    patch_root: Option<&Path>,
645) -> Result<String, Error> {
646    let selections = normalized_runner_selections(selections);
647    let mut manifest = String::from(
648        "[package]\nname = \"statum-graph-runner\"\nversion = \"0.0.0\"\nedition = \"2021\"\npublish = false\n\n[workspace]\n\n[dependencies]\n",
649    );
650    for (index, selection) in selections.iter().enumerate() {
651        manifest.push_str(&format!(
652            "{} = {{ package = {}, path = {} }}\n",
653            selection.dependency_alias(index),
654            toml_str(&selection.package_name),
655            toml_path(&selection.manifest_dir, "dependency package path")?,
656        ));
657    }
658
659    match patch_root {
660        Some(root) => {
661            if !selections
662                .iter()
663                .any(|selection| selection.package_name == GRAPH_PACKAGE_NAME)
664            {
665                manifest.push_str(&format!(
666                    "statum-graph = {{ path = {} }}\n",
667                    toml_path(root.join(GRAPH_PACKAGE_NAME), "patched statum-graph path")?
668                ));
669            }
670            if !selections
671                .iter()
672                .any(|selection| selection.package_name == HELPER_PACKAGE_NAME)
673            {
674                manifest.push_str(&format!(
675                    "cargo-statum-graph = {{ path = {} }}\n",
676                    toml_path(
677                        root.join(HELPER_PACKAGE_NAME),
678                        "patched cargo-statum-graph path",
679                    )?
680                ));
681            }
682            push_patch_tables(&mut manifest, root)?;
683        }
684        None => {
685            if !selections
686                .iter()
687                .any(|selection| selection.package_name == GRAPH_PACKAGE_NAME)
688            {
689                manifest.push_str(&format!(
690                    "statum-graph = {{ version = {} }}\n",
691                    toml_str(&format!("={}", env!("CARGO_PKG_VERSION")))
692                ));
693            }
694            if !selections
695                .iter()
696                .any(|selection| selection.package_name == HELPER_PACKAGE_NAME)
697            {
698                manifest.push_str(&format!(
699                    "cargo-statum-graph = {{ version = {} }}\n",
700                    toml_str(&format!("={}", env!("CARGO_PKG_VERSION")))
701                ));
702            }
703        }
704    }
705
706    Ok(manifest)
707}
708
709fn push_patch_tables(manifest: &mut String, root: &Path) -> Result<(), Error> {
710    for source in ["crates-io", "https://github.com/eboody/statum"] {
711        if source == "crates-io" {
712            manifest.push_str("\n[patch.crates-io]\n");
713        } else {
714            manifest.push_str(&format!("\n[patch.{}]\n", toml_str(source)));
715        }
716        for package in [
717            "macro_registry",
718            "module_path_extractor",
719            "statum",
720            "statum-core",
721            "statum-graph",
722            "statum-macros",
723        ] {
724            manifest.push_str(&format!(
725                "{package} = {{ path = {} }}\n",
726                toml_path(root.join(package), "patched workspace package path")?
727            ));
728        }
729    }
730
731    Ok(())
732}
733
734fn build_runner_main(selections: &[SelectedPackage]) -> Result<String, Error> {
735    let selections = normalized_runner_selections(selections);
736    let mut source = String::from("#[allow(unused_imports)]\n");
737    source.push_str("use std::ffi::OsString;\n");
738    source.push_str("use std::io::IsTerminal as _;\n");
739    source.push_str("use std::path::PathBuf;\n");
740    for (index, selection) in selections.iter().enumerate() {
741        source.push_str(&format!(
742            "use {} as _;\n",
743            selection.dependency_alias(index)
744        ));
745    }
746    source.push_str("\nfn main() -> std::process::ExitCode {\n");
747    source.push_str("    match run() {\n");
748    source.push_str("        Ok(()) => std::process::ExitCode::SUCCESS,\n");
749    source.push_str("        Err(error) => {\n");
750    source.push_str("            eprintln!(\"{}\", error);\n");
751    source.push_str("            std::process::ExitCode::FAILURE\n");
752    source.push_str("        }\n");
753    source.push_str("    }\n");
754    source.push_str("}\n\n");
755    source.push_str("fn run() -> Result<(), Box<dyn std::error::Error>> {\n");
756    source.push_str("    let mut args = std::env::args_os();\n");
757    source.push_str("    let _binary = args.next();\n");
758    source.push_str("    let command = take_string_arg(&mut args, \"runner command\")?;\n");
759    source.push_str("    let doc = statum_graph::CodebaseDoc::linked()?;\n");
760    source.push_str("    if doc.machines().is_empty() {\n");
761    source.push_str("        return Err(std::io::Error::other(");
762    source.push_str(&rust_str(NO_LINKED_MACHINES_MESSAGE));
763    source.push_str(").into());\n");
764    source.push_str("    }\n");
765    source.push_str("    match command.as_str() {\n");
766    source.push_str("        \"inspect\" => {\n");
767    source.push_str(
768        "            let workspace_label = take_string_arg(&mut args, \"workspace label\")?;\n",
769    );
770    source.push_str("            ensure_no_extra_args(&mut args, \"inspect\")?;\n");
771    source.push_str(
772        "            if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {\n",
773    );
774    source.push_str("                return Err(std::io::Error::other(");
775    source.push_str(&rust_str(NO_TTY_INSPECT_MESSAGE));
776    source.push_str(").into());\n");
777    source.push_str("            }\n");
778    source.push_str("            let heuristic = cargo_statum_graph::collect_heuristic_overlay(\n");
779    source.push_str("                &doc,\n");
780    source.push_str("                &[\n");
781    source.push_str(&inspect_package_sources_literal(&selections)?);
782    source.push_str("                ],\n");
783    source.push_str("            );\n");
784    source.push_str(
785        "            cargo_statum_graph::run_inspector(doc, heuristic, workspace_label)?;\n",
786    );
787    source.push_str("        }\n");
788    source.push_str("        \"export\" | \"codebase\" => {\n");
789    source.push_str(
790        "            let out_dir = PathBuf::from(take_os_arg(&mut args, \"output directory\")?);\n",
791    );
792    source.push_str("            let stem = take_string_arg(&mut args, \"output stem\")?;\n");
793    source.push_str("            ensure_no_extra_args(&mut args, \"export\")?;\n");
794    source.push_str(
795        "            statum_graph::codebase::render::write_all_to_dir(&doc, &out_dir, &stem)?;\n",
796    );
797    source.push_str("        }\n");
798    source.push_str("        \"suggest\" => {\n");
799    source.push_str("            ensure_no_extra_args(&mut args, \"suggest\")?;\n");
800    source.push_str("            let heuristic = cargo_statum_graph::collect_heuristic_overlay(\n");
801    source.push_str("                &doc,\n");
802    source.push_str("                &[\n");
803    source.push_str(&inspect_package_sources_literal(&selections)?);
804    source.push_str("                ],\n");
805    source.push_str("            );\n");
806    source.push_str("            print!(\n");
807    source.push_str("                \"{}\",\n");
808    source.push_str(
809        "                cargo_statum_graph::render_composition_suggestions(&doc, &heuristic),\n",
810    );
811    source.push_str("            );\n");
812    source.push_str("        }\n");
813    source.push_str("        other => {\n");
814    source.push_str(
815        "            return Err(std::io::Error::other(format!(\"unknown runner command `{other}`\")).into());\n",
816    );
817    source.push_str("        }\n");
818    source.push_str("    }\n");
819    source.push_str("    Ok(())\n");
820    source.push_str("}\n");
821    source.push_str("\nfn take_os_arg(\n");
822    source.push_str("    args: &mut impl Iterator<Item = OsString>,\n");
823    source.push_str("    label: &str,\n");
824    source.push_str(") -> Result<OsString, Box<dyn std::error::Error>> {\n");
825    source.push_str("    args.next().ok_or_else(|| std::io::Error::other(format!(\"missing {label}\" )).into())\n");
826    source.push_str("}\n");
827    source.push_str("\nfn take_string_arg(\n");
828    source.push_str("    args: &mut impl Iterator<Item = OsString>,\n");
829    source.push_str("    label: &str,\n");
830    source.push_str(") -> Result<String, Box<dyn std::error::Error>> {\n");
831    source.push_str("    let value = take_os_arg(args, label)?;\n");
832    source.push_str("    value.into_string().map_err(|value| {\n");
833    source.push_str(
834        "        std::io::Error::other(format!(\"{label} must be valid UTF-8: {:?}\", value)).into()\n",
835    );
836    source.push_str("    })\n");
837    source.push_str("}\n");
838    source.push_str("\nfn ensure_no_extra_args(\n");
839    source.push_str("    args: &mut impl Iterator<Item = OsString>,\n");
840    source.push_str("    command: &str,\n");
841    source.push_str(") -> Result<(), Box<dyn std::error::Error>> {\n");
842    source.push_str("    if let Some(extra) = args.next() {\n");
843    source.push_str(
844        "        Err(std::io::Error::other(format!(\"unexpected extra argument for {command}: {:?}\", extra)).into())\n",
845    );
846    source.push_str("    } else {\n");
847    source.push_str("        Ok(())\n");
848    source.push_str("    }\n");
849    source.push_str("}\n");
850    Ok(source)
851}
852
853fn inspect_package_sources_literal(selections: &[SelectedPackage]) -> Result<String, Error> {
854    let mut literal = String::new();
855    for selection in normalized_runner_selections(selections) {
856        literal.push_str("            cargo_statum_graph::InspectPackageSource {\n");
857        literal.push_str(&format!(
858            "                package_name: {}.to_owned(),\n",
859            rust_str(&selection.package_name)
860        ));
861        literal.push_str(&format!(
862            "                manifest_dir: std::path::PathBuf::from({}),\n",
863            rust_path(&selection.manifest_dir, "selected package manifest dir")?
864        ));
865        literal.push_str(&format!(
866            "                lib_target_path: std::path::PathBuf::from({}),\n",
867            rust_path(
868                &selection.lib_target_path,
869                "selected package library target"
870            )?
871        ));
872        literal.push_str("            },\n");
873    }
874    Ok(literal)
875}
876
877fn normalized_runner_selections(selections: &[SelectedPackage]) -> Vec<SelectedPackage> {
878    let mut normalized = selections.to_vec();
879    normalized.sort_by(|left, right| {
880        left.package_name
881            .cmp(&right.package_name)
882            .then_with(|| left.manifest_dir.cmp(&right.manifest_dir))
883            .then_with(|| left.lib_target_path.cmp(&right.lib_target_path))
884    });
885    normalized
886}
887
888fn runner_key(selections: &[SelectedPackage], patch_root: Option<&Path>) -> Result<String, Error> {
889    let mut canonical = format!("schema={RUNNER_SCHEMA_VERSION}\n");
890    match patch_root {
891        Some(root) => {
892            canonical.push_str("patch=");
893            canonical.push_str(path_utf8(root, "patched statum root")?);
894            canonical.push('\n');
895        }
896        None => canonical.push_str("patch=<none>\n"),
897    }
898    for selection in normalized_runner_selections(selections) {
899        canonical.push_str("package=");
900        canonical.push_str(&selection.runner_key_fragment()?);
901        canonical.push('\n');
902    }
903
904    Ok(format!(
905        "v{RUNNER_SCHEMA_VERSION}-{:016x}",
906        stable_runner_hash(&canonical)
907    ))
908}
909
910fn stable_runner_hash(input: &str) -> u64 {
911    const OFFSET: u64 = 0xcbf29ce484222325;
912    const PRIME: u64 = 0x100000001b3;
913
914    let mut hash = OFFSET;
915    for byte in input.as_bytes() {
916        hash ^= u64::from(*byte);
917        hash = hash.wrapping_mul(PRIME);
918    }
919    hash
920}
921
922fn cached_runner_home(target_directory: &Path, runner_key: &str) -> PathBuf {
923    target_directory
924        .join("statum-graph")
925        .join("runner")
926        .join(runner_key)
927}
928
929fn write_file_if_changed(path: &Path, contents: &str, action: &'static str) -> Result<bool, Error> {
930    if fs::read_to_string(path)
931        .ok()
932        .as_deref()
933        .is_some_and(|existing| existing == contents)
934    {
935        return Ok(false);
936    }
937
938    fs::write(path, contents).map_err(|source| Error::Io {
939        action,
940        path: path.to_path_buf(),
941        source,
942    })?;
943    Ok(true)
944}
945
946fn run_runner(
947    runner: &CachedRunner,
948    target_manifest_path: &Path,
949    operation: &'static str,
950    stdio: RunnerStdio,
951    runtime_args: &[OsString],
952) -> Result<(), Error> {
953    match stdio {
954        RunnerStdio::Captured => run_runner_captured(
955            runner.manifest_path.clone(),
956            &runner.target_directory,
957            target_manifest_path,
958            operation,
959            runtime_args,
960        )
961        .map(|_| ()),
962        RunnerStdio::Inherited => {
963            let mut command = Command::new("cargo");
964            command
965                .arg("run")
966                .arg("--quiet")
967                .arg("--manifest-path")
968                .arg(&runner.manifest_path)
969                .arg("--target-dir")
970                .arg(&runner.target_directory)
971                .arg("--");
972            command.args(runtime_args);
973
974            let status = command
975                .stdin(Stdio::inherit())
976                .stdout(Stdio::inherit())
977                .stderr(Stdio::inherit())
978                .status()
979                .map_err(|source| Error::Io {
980                    action: "run generated cargo runner",
981                    path: runner.manifest_path.clone(),
982                    source,
983                })?;
984
985            if status.success() {
986                Ok(())
987            } else {
988                Err(Error::RunnerFailed {
989                    operation,
990                    manifest_path: target_manifest_path.to_path_buf(),
991                    status,
992                    details: None,
993                    diagnostics_reported: true,
994                })
995            }
996        }
997    }
998}
999
1000fn run_runner_captured(
1001    runner_manifest_path: PathBuf,
1002    target_directory: &Path,
1003    target_manifest_path: &Path,
1004    operation: &'static str,
1005    runtime_args: &[OsString],
1006) -> Result<String, Error> {
1007    let mut command = Command::new("cargo");
1008    command
1009        .arg("run")
1010        .arg("--quiet")
1011        .arg("--manifest-path")
1012        .arg(&runner_manifest_path)
1013        .arg("--target-dir")
1014        .arg(target_directory)
1015        .arg("--");
1016    command.args(runtime_args);
1017
1018    let output = command.output().map_err(|source| Error::Io {
1019        action: "run generated cargo runner",
1020        path: runner_manifest_path.clone(),
1021        source,
1022    })?;
1023
1024    if output.status.success() {
1025        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1026    } else {
1027        Err(Error::RunnerFailed {
1028            operation,
1029            manifest_path: target_manifest_path.to_path_buf(),
1030            status: output.status,
1031            details: normalize_runner_failure_details(&output.stderr, &output.stdout),
1032            diagnostics_reported: false,
1033        })
1034    }
1035}
1036
1037fn normalize_runner_failure_details(stderr: &[u8], stdout: &[u8]) -> Option<String> {
1038    let text = if stderr.is_empty() {
1039        String::from_utf8_lossy(stdout).into_owned()
1040    } else {
1041        String::from_utf8_lossy(stderr).into_owned()
1042    };
1043    let trimmed = text.trim();
1044    if trimmed.is_empty() {
1045        None
1046    } else {
1047        Some(
1048            trimmed
1049                .strip_prefix("Error: ")
1050                .unwrap_or(trimmed)
1051                .to_owned(),
1052        )
1053    }
1054}
1055
1056fn bundle_paths(out_dir: &Path, stem: &str) -> Vec<PathBuf> {
1057    GRAPH_EXTENSIONS
1058        .into_iter()
1059        .map(|extension| out_dir.join(format!("{stem}.{extension}")))
1060        .collect()
1061}
1062
1063fn absolutize(path: &Path) -> io::Result<PathBuf> {
1064    let absolute = if path.is_absolute() {
1065        path.to_path_buf()
1066    } else {
1067        env::current_dir()?.join(path)
1068    };
1069    Ok(normalize_absolute_path(&absolute))
1070}
1071
1072fn rust_path(value: &Path, role: &'static str) -> Result<String, Error> {
1073    Ok(rust_str(path_utf8(value, role)?))
1074}
1075
1076fn toml_path(value: impl AsRef<Path>, role: &'static str) -> Result<String, Error> {
1077    Ok(toml_str(path_utf8(value.as_ref(), role)?))
1078}
1079
1080fn rust_str(value: &str) -> String {
1081    let escaped: String = value.chars().flat_map(char::escape_default).collect();
1082    format!("\"{escaped}\"")
1083}
1084
1085fn toml_str(value: &str) -> String {
1086    let mut escaped = String::with_capacity(value.len());
1087    for character in value.chars() {
1088        match character {
1089            '\\' => escaped.push_str("\\\\"),
1090            '"' => escaped.push_str("\\\""),
1091            '\u{08}' => escaped.push_str("\\b"),
1092            '\t' => escaped.push_str("\\t"),
1093            '\n' => escaped.push_str("\\n"),
1094            '\u{0C}' => escaped.push_str("\\f"),
1095            '\r' => escaped.push_str("\\r"),
1096            control if control.is_control() => {
1097                let code = control as u32;
1098                if code <= 0xFFFF {
1099                    write!(&mut escaped, "\\u{code:04X}")
1100                        .expect("writing to a String should not fail");
1101                } else {
1102                    write!(&mut escaped, "\\U{code:08X}")
1103                        .expect("writing to a String should not fail");
1104                }
1105            }
1106            other => escaped.push(other),
1107        }
1108    }
1109
1110    format!("\"{escaped}\"")
1111}
1112
1113fn validate_output_stem(stem: &str) -> Result<(), Error> {
1114    let mut components = Path::new(stem).components();
1115    match (components.next(), components.next()) {
1116        (Some(Component::Normal(_)), None) => Ok(()),
1117        _ => Err(Error::InvalidStem {
1118            stem: stem.to_owned(),
1119        }),
1120    }
1121}
1122
1123fn path_utf8<'a>(path: &'a Path, role: &'static str) -> Result<&'a str, Error> {
1124    path.to_str().ok_or_else(|| Error::NonUtf8Path {
1125        role,
1126        path: path.to_path_buf(),
1127    })
1128}
1129
1130fn normalize_absolute_path(path: &Path) -> PathBuf {
1131    debug_assert!(
1132        path.is_absolute(),
1133        "path should be absolute before normalization"
1134    );
1135
1136    let mut normalized = PathBuf::new();
1137    for component in path.components() {
1138        match component {
1139            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
1140            Component::RootDir => {
1141                normalized.push(std::path::MAIN_SEPARATOR.to_string());
1142            }
1143            Component::CurDir => {}
1144            Component::ParentDir => {
1145                if normalized.file_name().is_some() {
1146                    normalized.pop();
1147                }
1148            }
1149            Component::Normal(segment) => normalized.push(segment),
1150        }
1151    }
1152
1153    normalized
1154}
1155
1156#[derive(Clone, Debug, Eq, PartialEq)]
1157struct SelectedPackage {
1158    package_name: String,
1159    manifest_dir: PathBuf,
1160    lib_target_path: PathBuf,
1161}
1162
1163impl SelectedPackage {
1164    fn new(package: &Package, manifest_path: &Path) -> Result<Self, Error> {
1165        let Some(library_target) = library_target(package) else {
1166            return Err(Error::PackageHasNoLibrary {
1167                manifest_path: manifest_path.to_path_buf(),
1168                package: package.name.to_string(),
1169            });
1170        };
1171
1172        Ok(Self {
1173            package_name: package.name.to_string(),
1174            manifest_dir: package
1175                .manifest_path
1176                .as_std_path()
1177                .parent()
1178                .expect("package manifest should have a parent")
1179                .to_path_buf(),
1180            lib_target_path: normalize_absolute_path(library_target.src_path.as_std_path()),
1181        })
1182    }
1183
1184    fn dependency_alias(&self, index: usize) -> String {
1185        if self.package_name == GRAPH_PACKAGE_NAME {
1186            Self::graph_dependency_alias().to_owned()
1187        } else if self.package_name == HELPER_PACKAGE_NAME {
1188            Self::helper_dependency_alias().to_owned()
1189        } else {
1190            format!("graph_target_{index}")
1191        }
1192    }
1193
1194    fn graph_dependency_alias() -> &'static str {
1195        "statum_graph"
1196    }
1197
1198    fn helper_dependency_alias() -> &'static str {
1199        "cargo_statum_graph"
1200    }
1201
1202    fn runner_key_fragment(&self) -> Result<String, Error> {
1203        Ok(format!(
1204            "{}|{}|{}",
1205            self.package_name,
1206            path_utf8(&self.manifest_dir, "selected package manifest dir")?,
1207            path_utf8(&self.lib_target_path, "selected package library target")?,
1208        ))
1209    }
1210}
1211
1212struct PreparedRun {
1213    input: ResolvedInput,
1214    selections: Vec<SelectedPackage>,
1215    target_directory: PathBuf,
1216    patch_root: Option<PathBuf>,
1217}
1218
1219#[derive(Clone, Copy)]
1220enum RunnerStdio {
1221    Captured,
1222    Inherited,
1223}
1224
1225struct ResolvedInput {
1226    manifest_path: PathBuf,
1227    default_output_dir: PathBuf,
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233
1234    #[test]
1235    fn rust_str_escapes_control_characters() {
1236        assert_eq!(
1237            rust_str("line 1\n\"quoted\"\t\\tail"),
1238            "\"line 1\\n\\\"quoted\\\"\\t\\\\tail\""
1239        );
1240    }
1241
1242    #[test]
1243    fn toml_str_escapes_control_characters() {
1244        assert_eq!(
1245            toml_str("line 1\n\"quoted\"\t\\tail\u{1F}"),
1246            "\"line 1\\n\\\"quoted\\\"\\t\\\\tail\\u001F\""
1247        );
1248    }
1249
1250    #[cfg(unix)]
1251    #[test]
1252    fn rust_path_rejects_non_utf8_path() {
1253        use std::ffi::OsString;
1254        use std::os::unix::ffi::OsStringExt;
1255
1256        let path = PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6F]));
1257
1258        let error = rust_path(&path, "output directory").expect_err("non-UTF-8 path should fail");
1259
1260        assert!(matches!(
1261            error,
1262            Error::NonUtf8Path {
1263                role: "output directory",
1264                ..
1265            }
1266        ));
1267    }
1268
1269    #[cfg(unix)]
1270    #[test]
1271    fn toml_path_rejects_non_utf8_path() {
1272        use std::ffi::OsString;
1273        use std::os::unix::ffi::OsStringExt;
1274
1275        let path = PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6F]));
1276
1277        let error =
1278            toml_path(&path, "dependency package path").expect_err("non-UTF-8 path should fail");
1279
1280        assert!(matches!(
1281            error,
1282            Error::NonUtf8Path {
1283                role: "dependency package path",
1284                ..
1285            }
1286        ));
1287    }
1288
1289    #[test]
1290    fn absolutize_normalizes_cur_dir_components() {
1291        let current_dir = env::current_dir().expect("current dir");
1292        let normalized = absolutize(Path::new(".").join("Cargo.toml").as_path()).expect("path");
1293
1294        assert_eq!(normalized, current_dir.join("Cargo.toml"));
1295    }
1296
1297    #[test]
1298    fn runner_key_is_stable_across_selection_order() {
1299        let selections = vec![sample_selection("app"), sample_selection("domain")];
1300        let mut reversed = selections.clone();
1301        reversed.reverse();
1302
1303        let left = runner_key(&selections, None).expect("runner key");
1304        let right = runner_key(&reversed, None).expect("runner key");
1305
1306        assert_eq!(left, right);
1307    }
1308
1309    #[test]
1310    fn runner_key_changes_for_different_package_sets_and_patch_roots() {
1311        let target_dir = tempfile::tempdir().expect("target tempdir");
1312        let all_packages = vec![sample_selection("app"), sample_selection("domain")];
1313        let app_only = vec![sample_selection("app")];
1314        let patch_a = PathBuf::from("/tmp/statum-a");
1315        let patch_b = PathBuf::from("/tmp/statum-b");
1316
1317        let all_runner =
1318            materialize_cached_runner(target_dir.path(), &all_packages, Some(&patch_a))
1319                .expect("all-packages runner");
1320        let app_runner = materialize_cached_runner(target_dir.path(), &app_only, Some(&patch_a))
1321            .expect("app-only runner");
1322        let patch_runner =
1323            materialize_cached_runner(target_dir.path(), &all_packages, Some(&patch_b))
1324                .expect("patch-b runner");
1325
1326        assert_ne!(all_runner.runner.key, app_runner.runner.key);
1327        assert_ne!(all_runner.runner.home_dir, app_runner.runner.home_dir);
1328        assert_ne!(all_runner.runner.key, patch_runner.runner.key);
1329        assert_ne!(all_runner.runner.home_dir, patch_runner.runner.home_dir);
1330    }
1331
1332    #[test]
1333    fn detect_patch_root_from_target_workspace_finds_local_statum_checkout_dependency() {
1334        let temp = tempfile::tempdir().expect("fixture tempdir");
1335        let statum_root = temp.path().join("local-statum");
1336        write_fake_statum_workspace(&statum_root);
1337
1338        let consumer_root = temp.path().join("consumer");
1339        fs::create_dir_all(consumer_root.join("src")).expect("consumer src dir");
1340        fs::write(
1341            consumer_root.join("Cargo.toml"),
1342            format!(
1343                "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nstatum = {{ path = {:?} }}\n",
1344                statum_root.join("statum")
1345            ),
1346        )
1347        .expect("consumer manifest");
1348        fs::write(consumer_root.join("src/lib.rs"), "pub fn marker() {}\n").expect("consumer lib");
1349
1350        let detected = detect_patch_root_from_target_workspace(&consumer_root.join("Cargo.toml"))
1351            .expect("patch root detection should succeed");
1352
1353        assert_eq!(detected, Some(statum_root));
1354    }
1355
1356    #[test]
1357    fn detect_patch_root_from_manifest_dirs_rejects_multiple_local_statum_roots() {
1358        let temp = tempfile::tempdir().expect("fixture tempdir");
1359        let root_a = temp.path().join("statum-a");
1360        let root_b = temp.path().join("statum-b");
1361        write_fake_statum_workspace(&root_a);
1362        write_fake_statum_workspace(&root_b);
1363
1364        let error = detect_patch_root_from_manifest_dirs(
1365            Path::new("/tmp/consumer/Cargo.toml"),
1366            [root_a.join("statum"), root_b.join("statum")],
1367        )
1368        .expect_err("multiple local statum roots should fail closed");
1369
1370        let Error::AmbiguousPatchStatumRoots { candidates, .. } = error else {
1371            panic!("expected ambiguous local statum root error");
1372        };
1373        assert_eq!(candidates, vec![root_a, root_b]);
1374    }
1375
1376    #[test]
1377    fn build_runner_main_supports_generic_runtime_commands() {
1378        let selections = vec![sample_selection("app")];
1379
1380        let source = build_runner_main(&selections).expect("runner source");
1381
1382        assert!(source.contains("collect_heuristic_overlay"));
1383        assert!(source.contains("InspectPackageSource"));
1384        assert!(source.contains("cargo_statum_graph::run_inspector"));
1385        assert!(source.contains("is_terminal()"));
1386        assert!(source.contains("\"inspect\""));
1387        assert!(source.contains("\"export\" | \"codebase\""));
1388        assert!(source.contains("\"codebase\""));
1389        assert!(source.contains("\"suggest\""));
1390        assert!(source.contains("write_all_to_dir(&doc, &out_dir, &stem)?;"));
1391        assert!(source.contains("render_composition_suggestions(&doc, &heuristic)"));
1392        assert!(source.contains("take_os_arg"));
1393        assert!(source.contains("ensure_no_extra_args"));
1394        assert!(!source.contains("/tmp/workspace/Cargo.toml"));
1395    }
1396
1397    #[test]
1398    fn materialize_cached_runner_is_idempotent() {
1399        let target_dir = tempfile::tempdir().expect("target tempdir");
1400        let selections = vec![sample_selection("app")];
1401
1402        let first =
1403            materialize_cached_runner(target_dir.path(), &selections, None).expect("first write");
1404        let second =
1405            materialize_cached_runner(target_dir.path(), &selections, None).expect("second write");
1406
1407        assert!(first.manifest_rewritten);
1408        assert!(first.source_rewritten);
1409        assert!(!second.manifest_rewritten);
1410        assert!(!second.source_rewritten);
1411        assert_eq!(first.runner.home_dir, second.runner.home_dir);
1412        assert_eq!(first.runner.manifest_path, second.runner.manifest_path);
1413    }
1414
1415    #[test]
1416    fn build_runner_manifest_reuses_selected_helper_dependency() {
1417        let selections = vec![
1418            SelectedPackage {
1419                package_name: GRAPH_PACKAGE_NAME.to_owned(),
1420                manifest_dir: PathBuf::from("/tmp/graph"),
1421                lib_target_path: PathBuf::from("/tmp/graph/src/lib.rs"),
1422            },
1423            SelectedPackage {
1424                package_name: HELPER_PACKAGE_NAME.to_owned(),
1425                manifest_dir: PathBuf::from("/tmp/helper"),
1426                lib_target_path: PathBuf::from("/tmp/helper/src/lib.rs"),
1427            },
1428        ];
1429
1430        let manifest = build_runner_manifest(&selections, None).expect("runner manifest");
1431
1432        assert_eq!(manifest.matches("package = \"statum-graph\"").count(), 1);
1433        assert_eq!(
1434            manifest.matches("package = \"cargo-statum-graph\"").count(),
1435            1
1436        );
1437        assert!(manifest.contains("statum_graph = { package = \"statum-graph\""));
1438        assert!(manifest.contains("cargo_statum_graph = { package = \"cargo-statum-graph\""));
1439    }
1440
1441    fn sample_selection(package_name: &str) -> SelectedPackage {
1442        SelectedPackage {
1443            package_name: package_name.to_owned(),
1444            manifest_dir: PathBuf::from(format!("/tmp/{package_name}")),
1445            lib_target_path: PathBuf::from(format!("/tmp/{package_name}/src/lib.rs")),
1446        }
1447    }
1448
1449    fn write_fake_statum_workspace(root: &Path) {
1450        fs::create_dir_all(root).expect("fake statum root");
1451        fs::write(
1452            root.join("Cargo.toml"),
1453            "[workspace]\nresolver = \"2\"\nmembers = [\"macro_registry\", \"module_path_extractor\", \"statum\", \"statum-core\", \"statum-graph\", \"statum-macros\"]\n",
1454        )
1455        .expect("fake statum workspace manifest");
1456
1457        for package in STATUM_WORKSPACE_PACKAGES {
1458            let package_dir = root.join(package);
1459            fs::create_dir_all(package_dir.join("src")).expect("fake package src dir");
1460            fs::write(
1461                package_dir.join("Cargo.toml"),
1462                format!(
1463                    "[package]\nname = \"{package}\"\nversion = \"0.7.0\"\nedition = \"2021\"\n"
1464                ),
1465            )
1466            .expect("fake package manifest");
1467            fs::write(package_dir.join("src/lib.rs"), "pub fn marker() {}\n")
1468                .expect("fake package lib");
1469        }
1470    }
1471}