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}