1use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
11pub struct WorkerFixture {
12 pub id: String,
13 pub host: String,
14 pub user: String,
15 pub identity_file: String,
16 pub total_slots: u32,
17 pub priority: u32,
18}
19
20impl WorkerFixture {
21 pub fn mock_local(id: &str) -> Self {
23 #[cfg(unix)]
24 let user = whoami::username().unwrap_or_else(|_| "unknown".to_string());
25 #[cfg(not(unix))]
26 let user = std::env::var("USERNAME")
27 .or_else(|_| std::env::var("USER"))
28 .unwrap_or_else(|_| "unknown".to_string());
29
30 Self {
31 id: id.to_string(),
32 host: "localhost".to_string(),
33 user,
34 identity_file: "~/.ssh/id_rsa".to_string(),
35 total_slots: 4,
36 priority: 100,
37 }
38 }
39
40 pub fn to_toml(&self) -> String {
42 format!(
43 r#"[[workers]]
44id = "{}"
45host = "{}"
46user = "{}"
47identity_file = "{}"
48total_slots = {}
49priority = {}
50"#,
51 self.id, self.host, self.user, self.identity_file, self.total_slots, self.priority
52 )
53 }
54}
55
56pub struct WorkersFixture {
58 pub workers: Vec<WorkerFixture>,
59}
60
61impl WorkersFixture {
62 pub fn empty() -> Self {
64 Self { workers: vec![] }
65 }
66
67 pub fn mock_local(count: usize) -> Self {
69 let workers = (0..count)
70 .map(|i| WorkerFixture::mock_local(&format!("worker{}", i + 1)))
71 .collect();
72 Self { workers }
73 }
74
75 pub fn add_worker(mut self, worker: WorkerFixture) -> Self {
77 self.workers.push(worker);
78 self
79 }
80
81 pub fn to_toml(&self) -> String {
83 if self.workers.is_empty() {
84 "workers = []\n".to_string()
85 } else {
86 self.workers
87 .iter()
88 .map(|w| w.to_toml())
89 .collect::<Vec<_>>()
90 .join("\n")
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct DaemonConfigFixture {
98 pub socket_path: PathBuf,
99 pub log_level: String,
100 pub confidence_threshold: f64,
101 pub min_local_time_ms: u64,
102}
103
104impl DaemonConfigFixture {
105 pub fn minimal(socket_path: &Path) -> Self {
107 Self {
108 socket_path: socket_path.to_path_buf(),
109 log_level: "debug".to_string(),
110 confidence_threshold: 0.85,
111 min_local_time_ms: 2000,
112 }
113 }
114
115 pub fn to_toml(&self) -> String {
117 format!(
118 r#"[general]
119enabled = true
120log_level = "{}"
121socket_path = "{}"
122
123[compilation]
124confidence_threshold = {}
125min_local_time_ms = {}
126
127[transfer]
128compression_level = 3
129exclude_patterns = ["target/", ".git/objects/", "node_modules/"]
130"#,
131 self.log_level,
132 self.socket_path.display(),
133 self.confidence_threshold,
134 self.min_local_time_ms
135 )
136 }
137}
138
139#[derive(Debug, Clone)]
141pub struct RustProjectFixture {
142 pub name: String,
143 pub version: String,
144}
145
146impl RustProjectFixture {
147 pub fn minimal(name: &str) -> Self {
149 Self {
150 name: name.to_string(),
151 version: "0.1.0".to_string(),
152 }
153 }
154
155 pub fn cargo_toml(&self) -> String {
157 format!(
158 r#"[package]
159name = "{}"
160version = "{}"
161edition = "2024"
162
163[dependencies]
164"#,
165 self.name, self.version
166 )
167 }
168
169 pub fn main_rs(&self) -> String {
171 r#"fn main() {
172 println!("Hello from test project!");
173}
174"#
175 .to_string()
176 }
177
178 pub fn lib_rs(&self) -> String {
180 r#"pub fn add(a: i32, b: i32) -> i32 {
181 a + b
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn test_add() {
190 assert_eq!(add(2, 3), 5);
191 }
192}
193"#
194 .to_string()
195 }
196
197 pub fn create_in(&self, dir: &Path) -> std::io::Result<()> {
199 std::fs::create_dir_all(dir)?;
200 std::fs::create_dir_all(dir.join("src"))?;
201 std::fs::write(dir.join("Cargo.toml"), self.cargo_toml())?;
202 std::fs::write(dir.join("src/main.rs"), self.main_rs())?;
203 Ok(())
204 }
205
206 pub fn create_lib_in(&self, dir: &Path) -> std::io::Result<()> {
208 std::fs::create_dir_all(dir)?;
209 std::fs::create_dir_all(dir.join("src"))?;
210 std::fs::write(dir.join("Cargo.toml"), self.cargo_toml())?;
211 std::fs::write(dir.join("src/lib.rs"), self.lib_rs())?;
212 Ok(())
213 }
214}
215
216#[derive(Debug, Clone)]
218pub struct HookInputFixture {
219 pub tool_name: String,
220 pub command: String,
221 pub description: Option<String>,
222 pub session_id: Option<String>,
223}
224
225impl HookInputFixture {
226 pub fn cargo_build() -> Self {
228 Self {
229 tool_name: "Bash".to_string(),
230 command: "cargo build".to_string(),
231 description: Some("Build the project".to_string()),
232 session_id: Some("test-session-001".to_string()),
233 }
234 }
235
236 pub fn cargo_test() -> Self {
238 Self {
239 tool_name: "Bash".to_string(),
240 command: "cargo test".to_string(),
241 description: Some("Run tests".to_string()),
242 session_id: Some("test-session-001".to_string()),
243 }
244 }
245
246 pub fn echo(message: &str) -> Self {
248 Self {
249 tool_name: "Bash".to_string(),
250 command: format!("echo {message}"),
251 description: Some("Echo message".to_string()),
252 session_id: Some("test-session-001".to_string()),
253 }
254 }
255
256 pub fn custom(command: &str) -> Self {
258 Self {
259 tool_name: "Bash".to_string(),
260 command: command.to_string(),
261 description: None,
262 session_id: Some("test-session-001".to_string()),
263 }
264 }
265
266 pub fn to_json(&self) -> String {
268 let desc = match &self.description {
269 Some(d) => format!(r#""description": "{d}","#),
270 None => String::new(),
271 };
272 let session = match &self.session_id {
273 Some(s) => format!(r#", "session_id": "{s}""#),
274 None => String::new(),
275 };
276
277 format!(
278 r#"{{"tool_name": "{}", "tool_input": {{{}"command": "{}"}}{}}}
279"#,
280 self.tool_name, desc, self.command, session
281 )
282 }
283}
284
285#[derive(Debug, Clone)]
287pub struct TestCaseFixture {
288 pub name: String,
289 pub description: String,
290 pub tags: Vec<String>,
291}
292
293impl TestCaseFixture {
294 pub fn new(name: &str, description: &str) -> Self {
296 Self {
297 name: name.to_string(),
298 description: description.to_string(),
299 tags: vec![],
300 }
301 }
302
303 pub fn with_tag(mut self, tag: &str) -> Self {
305 self.tags.push(tag.to_string());
306 self
307 }
308}
309
310pub const DEFAULT_MULTI_REPO_FIXTURE_NAMESPACE: &str = "rch_multi_repo_path_deps";
312
313pub const DEFAULT_MULTI_REPO_CANONICAL_ROOT: &str = "/data/projects";
315
316pub const DEFAULT_MULTI_REPO_ALIAS_ROOT: &str = "/dp";
318
319#[derive(Debug, thiserror::Error)]
321pub enum MultiRepoFixtureError {
322 #[error("I/O failure while managing fixtures: {0}")]
323 Io(#[from] std::io::Error),
324 #[error("Invalid path topology: {0}")]
325 InvalidTopology(String),
326 #[error("Failed to serialize fixture manifest: {0}")]
327 ManifestSerialize(#[from] serde_json::Error),
328}
329
330pub type MultiRepoFixtureResult<T> = Result<T, MultiRepoFixtureError>;
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "snake_case")]
336pub enum FixtureReadiness {
337 Ready,
338 ExpectedFailure,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(rename_all = "snake_case")]
344pub enum FixtureFailureMode {
345 MissingPathDependency,
346 InvalidCargoManifest,
347 OutsideCanonicalRootDependency,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353pub enum FixtureLayer {
354 Unit,
355 Integration,
356 FaultInjection,
357 Soak,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct MultiRepoFixtureMetadata {
363 pub id: String,
364 pub description: String,
365 pub readiness: FixtureReadiness,
366 pub failure_mode: Option<FixtureFailureMode>,
367 pub canonical_entrypoint: PathBuf,
368 pub alias_entrypoint: PathBuf,
369 pub canonical_repo_paths: Vec<PathBuf>,
370 pub assertion_targets: Vec<String>,
371 pub reusable_layers: Vec<FixtureLayer>,
372}
373
374impl MultiRepoFixtureMetadata {
375 pub fn expected_ready(&self) -> bool {
377 matches!(self.readiness, FixtureReadiness::Ready)
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct MultiRepoFixtureConfig {
384 canonical_root: PathBuf,
385 alias_root: PathBuf,
386 namespace: String,
387}
388
389impl Default for MultiRepoFixtureConfig {
390 fn default() -> Self {
391 Self {
392 canonical_root: PathBuf::from(DEFAULT_MULTI_REPO_CANONICAL_ROOT),
393 alias_root: PathBuf::from(DEFAULT_MULTI_REPO_ALIAS_ROOT),
394 namespace: DEFAULT_MULTI_REPO_FIXTURE_NAMESPACE.to_string(),
395 }
396 }
397}
398
399impl MultiRepoFixtureConfig {
400 pub fn new(canonical_root: PathBuf, alias_root: PathBuf, namespace: impl Into<String>) -> Self {
402 Self {
403 canonical_root,
404 alias_root,
405 namespace: namespace.into(),
406 }
407 }
408
409 pub fn canonical_root(&self) -> &Path {
411 &self.canonical_root
412 }
413
414 pub fn alias_root(&self) -> &Path {
416 &self.alias_root
417 }
418
419 pub fn namespace(&self) -> &str {
421 &self.namespace
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427pub struct MultiRepoFixtureSet {
428 pub canonical_root: PathBuf,
429 pub alias_root: PathBuf,
430 pub namespace: String,
431 pub canonical_namespace_root: PathBuf,
432 pub alias_namespace_root: PathBuf,
433 pub manifest_path: PathBuf,
434 pub fixtures: Vec<MultiRepoFixtureMetadata>,
435}
436
437impl MultiRepoFixtureSet {
438 pub fn fixture(&self, id: &str) -> Option<&MultiRepoFixtureMetadata> {
440 self.fixtures.iter().find(|fixture| fixture.id == id)
441 }
442}
443
444pub fn reset_default_multi_repo_fixtures() -> MultiRepoFixtureResult<MultiRepoFixtureSet> {
446 reset_multi_repo_fixtures(&MultiRepoFixtureConfig::default())
447}
448
449pub fn reset_multi_repo_fixtures(
451 config: &MultiRepoFixtureConfig,
452) -> MultiRepoFixtureResult<MultiRepoFixtureSet> {
453 validate_fixture_topology(config.canonical_root(), config.alias_root())?;
454
455 let canonical_root = std::fs::canonicalize(config.canonical_root())?;
456 let alias_root = config.alias_root().to_path_buf();
457 let canonical_namespace_root = canonical_root.join(config.namespace());
458 if canonical_namespace_root.exists() {
459 std::fs::remove_dir_all(&canonical_namespace_root)?;
460 }
461 std::fs::create_dir_all(&canonical_namespace_root)?;
462
463 let fixtures = create_multi_repo_scenarios(
464 &canonical_root,
465 &alias_root,
466 config.namespace(),
467 &canonical_namespace_root,
468 )?;
469 let alias_namespace_root = alias_root.join(config.namespace());
470 let manifest_path = canonical_namespace_root.join("fixture_manifest.json");
471
472 let fixture_set = MultiRepoFixtureSet {
473 canonical_root,
474 alias_root,
475 namespace: config.namespace().to_string(),
476 canonical_namespace_root,
477 alias_namespace_root,
478 manifest_path,
479 fixtures,
480 };
481
482 let serialized = serde_json::to_string_pretty(&fixture_set)?;
483 std::fs::write(&fixture_set.manifest_path, serialized)?;
484 Ok(fixture_set)
485}
486
487fn validate_fixture_topology(
488 canonical_root: &Path,
489 alias_root: &Path,
490) -> MultiRepoFixtureResult<()> {
491 if !canonical_root.is_absolute() {
492 return Err(MultiRepoFixtureError::InvalidTopology(format!(
493 "canonical root must be absolute: {}",
494 canonical_root.display()
495 )));
496 }
497 if !alias_root.is_absolute() {
498 return Err(MultiRepoFixtureError::InvalidTopology(format!(
499 "alias root must be absolute: {}",
500 alias_root.display()
501 )));
502 }
503
504 std::fs::create_dir_all(canonical_root)?;
505 let canonical_resolved = std::fs::canonicalize(canonical_root)?;
506
507 let alias_meta = std::fs::symlink_metadata(alias_root).map_err(|error| {
508 MultiRepoFixtureError::InvalidTopology(format!(
509 "alias root metadata unavailable for {}: {}",
510 alias_root.display(),
511 error
512 ))
513 })?;
514 if !alias_meta.file_type().is_symlink() {
515 return Err(MultiRepoFixtureError::InvalidTopology(format!(
516 "alias root is not a symlink: {}",
517 alias_root.display()
518 )));
519 }
520
521 let raw_target = std::fs::read_link(alias_root)?;
522 let absolute_target = if raw_target.is_absolute() {
523 raw_target
524 } else {
525 alias_root
526 .parent()
527 .unwrap_or_else(|| Path::new("/"))
528 .join(raw_target)
529 };
530 let alias_target = std::fs::canonicalize(&absolute_target)?;
531 if alias_target != canonical_resolved {
532 return Err(MultiRepoFixtureError::InvalidTopology(format!(
533 "alias root {} points to {}, expected {}",
534 alias_root.display(),
535 alias_target.display(),
536 canonical_resolved.display()
537 )));
538 }
539
540 Ok(())
541}
542
543fn create_multi_repo_scenarios(
544 canonical_root: &Path,
545 alias_root: &Path,
546 namespace: &str,
547 namespace_root: &Path,
548) -> MultiRepoFixtureResult<Vec<MultiRepoFixtureMetadata>> {
549 Ok(vec![
550 create_ready_relative_transitive_fixture(canonical_root, alias_root, namespace_root)?,
551 create_ready_alias_absolute_fixture(canonical_root, alias_root, namespace, namespace_root)?,
552 create_missing_dependency_fixture(canonical_root, alias_root, namespace_root)?,
553 create_outside_root_dependency_fixture(canonical_root, alias_root, namespace_root)?,
554 create_invalid_manifest_fixture(canonical_root, alias_root, namespace_root)?,
555 ])
556}
557
558fn all_fixture_layers() -> Vec<FixtureLayer> {
559 vec![
560 FixtureLayer::Unit,
561 FixtureLayer::Integration,
562 FixtureLayer::FaultInjection,
563 FixtureLayer::Soak,
564 ]
565}
566
567fn create_ready_relative_transitive_fixture(
568 canonical_root: &Path,
569 alias_root: &Path,
570 namespace_root: &Path,
571) -> MultiRepoFixtureResult<MultiRepoFixtureMetadata> {
572 let scenario_root = namespace_root.join("ready_relative_transitive");
573 let core_repo = scenario_root.join("core_lib");
574 let util_repo = scenario_root.join("util_lib");
575 let app_repo = scenario_root.join("app_main");
576
577 write_library_repo(
578 &core_repo,
579 "fixture_core_lib",
580 &[],
581 r#"pub fn core_value() -> &'static str {
582 "fixture-core"
583}
584"#,
585 )?;
586
587 write_library_repo(
588 &util_repo,
589 "fixture_util_lib",
590 &[("fixture_core_lib", "../core_lib")],
591 r#"pub fn util_value() -> String {
592 format!("{}-util", fixture_core_lib::core_value())
593}
594"#,
595 )?;
596
597 write_binary_repo(
598 &app_repo,
599 "fixture_app_main",
600 &[("fixture_util_lib", "../util_lib")],
601 r#"fn main() {
602 println!("{}", fixture_util_lib::util_value());
603}
604"#,
605 )?;
606
607 Ok(MultiRepoFixtureMetadata {
608 id: "ready_relative_transitive".to_string(),
609 description: "Three-repo transitive graph using relative Cargo path dependencies."
610 .to_string(),
611 readiness: FixtureReadiness::Ready,
612 failure_mode: None,
613 canonical_entrypoint: app_repo.clone(),
614 alias_entrypoint: to_alias_path(&app_repo, canonical_root, alias_root),
615 canonical_repo_paths: vec![core_repo, util_repo, app_repo],
616 assertion_targets: vec![
617 "cargo metadata succeeds from app_main".to_string(),
618 "transitive dependency resolution includes fixture_core_lib".to_string(),
619 "entrypoint Cargo.toml uses relative path ../util_lib".to_string(),
620 ],
621 reusable_layers: all_fixture_layers(),
622 })
623}
624
625fn create_ready_alias_absolute_fixture(
626 canonical_root: &Path,
627 alias_root: &Path,
628 namespace: &str,
629 namespace_root: &Path,
630) -> MultiRepoFixtureResult<MultiRepoFixtureMetadata> {
631 let scenario_root = namespace_root.join("ready_alias_absolute");
632 let shared_repo = scenario_root.join("alias_shared");
633 let app_repo = scenario_root.join("alias_app");
634
635 write_library_repo(
636 &shared_repo,
637 "fixture_alias_shared",
638 &[],
639 r#"pub fn alias_value() -> &'static str {
640 "fixture-alias"
641}
642"#,
643 )?;
644
645 let alias_dep_path = alias_root
646 .join(namespace)
647 .join("ready_alias_absolute")
648 .join("alias_shared");
649 write_binary_repo(
650 &app_repo,
651 "fixture_alias_app",
652 &[(
653 "fixture_alias_shared",
654 alias_dep_path.to_string_lossy().as_ref(),
655 )],
656 r#"fn main() {
657 println!("{}", fixture_alias_shared::alias_value());
658}
659"#,
660 )?;
661
662 Ok(MultiRepoFixtureMetadata {
663 id: "ready_alias_absolute".to_string(),
664 description: "Two-repo graph using absolute /dp alias path dependency.".to_string(),
665 readiness: FixtureReadiness::Ready,
666 failure_mode: None,
667 canonical_entrypoint: app_repo.clone(),
668 alias_entrypoint: to_alias_path(&app_repo, canonical_root, alias_root),
669 canonical_repo_paths: vec![shared_repo, app_repo],
670 assertion_targets: vec![
671 "cargo metadata succeeds from alias_app".to_string(),
672 format!(
673 "entrypoint dependency path starts with {}",
674 alias_root.display()
675 ),
676 "alias root form and canonical form resolve to same repo graph".to_string(),
677 ],
678 reusable_layers: all_fixture_layers(),
679 })
680}
681
682fn create_missing_dependency_fixture(
683 canonical_root: &Path,
684 alias_root: &Path,
685 namespace_root: &Path,
686) -> MultiRepoFixtureResult<MultiRepoFixtureMetadata> {
687 let scenario_root = namespace_root.join("fail_missing_path_dep");
688 let app_repo = scenario_root.join("missing_app");
689 write_binary_repo(
690 &app_repo,
691 "fixture_missing_dep_app",
692 &[("fixture_missing_dep_lib", "../missing_lib")],
693 r#"fn main() {
694 println!("this should fail dependency resolution");
695}
696"#,
697 )?;
698
699 Ok(MultiRepoFixtureMetadata {
700 id: "fail_missing_path_dep".to_string(),
701 description: "Fixture with missing local path dependency for readiness gating.".to_string(),
702 readiness: FixtureReadiness::ExpectedFailure,
703 failure_mode: Some(FixtureFailureMode::MissingPathDependency),
704 canonical_entrypoint: app_repo.clone(),
705 alias_entrypoint: to_alias_path(&app_repo, canonical_root, alias_root),
706 canonical_repo_paths: vec![app_repo],
707 assertion_targets: vec![
708 "cargo metadata fails with missing path dependency".to_string(),
709 "error output references ../missing_lib".to_string(),
710 ],
711 reusable_layers: all_fixture_layers(),
712 })
713}
714
715fn create_outside_root_dependency_fixture(
716 canonical_root: &Path,
717 alias_root: &Path,
718 namespace_root: &Path,
719) -> MultiRepoFixtureResult<MultiRepoFixtureMetadata> {
720 let scenario_root = namespace_root.join("fail_outside_canonical_dep");
721 let app_repo = scenario_root.join("outside_app");
722 let outside_dep = "/tmp/rch_outside_canonical_dep_lib";
723
724 write_binary_repo(
725 &app_repo,
726 "fixture_outside_dep_app",
727 &[("fixture_outside_dep_lib", outside_dep)],
728 r#"fn main() {
729 println!("outside root dependency fixture");
730}
731"#,
732 )?;
733
734 Ok(MultiRepoFixtureMetadata {
735 id: "fail_outside_canonical_dep".to_string(),
736 description: "Fixture referencing absolute dependency path outside canonical root."
737 .to_string(),
738 readiness: FixtureReadiness::ExpectedFailure,
739 failure_mode: Some(FixtureFailureMode::OutsideCanonicalRootDependency),
740 canonical_entrypoint: app_repo.clone(),
741 alias_entrypoint: to_alias_path(&app_repo, canonical_root, alias_root),
742 canonical_repo_paths: vec![app_repo],
743 assertion_targets: vec![
744 format!("manifest dependency path references {}", outside_dep),
745 "preflight topology checks reject outside-canonical dependency".to_string(),
746 ],
747 reusable_layers: all_fixture_layers(),
748 })
749}
750
751fn create_invalid_manifest_fixture(
752 canonical_root: &Path,
753 alias_root: &Path,
754 namespace_root: &Path,
755) -> MultiRepoFixtureResult<MultiRepoFixtureMetadata> {
756 let scenario_root = namespace_root.join("fail_invalid_manifest");
757 let app_repo = scenario_root.join("invalid_app");
758 std::fs::create_dir_all(app_repo.join("src"))?;
759 std::fs::write(
760 app_repo.join("Cargo.toml"),
761 r#"[package]
762name = "fixture_invalid_manifest"
763version = "0.1.0"
764edition = "2024"
765
766[dependencies
767serde = "1"
768"#,
769 )?;
770 std::fs::write(
771 app_repo.join("src/main.rs"),
772 "fn main() { println!(\"invalid manifest fixture\"); }\n",
773 )?;
774
775 Ok(MultiRepoFixtureMetadata {
776 id: "fail_invalid_manifest".to_string(),
777 description: "Fixture with malformed Cargo.toml syntax to trigger parse failure."
778 .to_string(),
779 readiness: FixtureReadiness::ExpectedFailure,
780 failure_mode: Some(FixtureFailureMode::InvalidCargoManifest),
781 canonical_entrypoint: app_repo.clone(),
782 alias_entrypoint: to_alias_path(&app_repo, canonical_root, alias_root),
783 canonical_repo_paths: vec![app_repo],
784 assertion_targets: vec![
785 "cargo metadata fails with TOML parse error".to_string(),
786 "error output references Cargo.toml parse context".to_string(),
787 ],
788 reusable_layers: all_fixture_layers(),
789 })
790}
791
792fn write_library_repo(
793 repo_dir: &Path,
794 package_name: &str,
795 path_dependencies: &[(&str, &str)],
796 lib_src: &str,
797) -> std::io::Result<()> {
798 std::fs::create_dir_all(repo_dir.join("src"))?;
799 std::fs::write(
800 repo_dir.join("Cargo.toml"),
801 cargo_toml(package_name, path_dependencies),
802 )?;
803 std::fs::write(repo_dir.join("src/lib.rs"), lib_src)?;
804 Ok(())
805}
806
807fn write_binary_repo(
808 repo_dir: &Path,
809 package_name: &str,
810 path_dependencies: &[(&str, &str)],
811 main_src: &str,
812) -> std::io::Result<()> {
813 std::fs::create_dir_all(repo_dir.join("src"))?;
814 std::fs::write(
815 repo_dir.join("Cargo.toml"),
816 cargo_toml(package_name, path_dependencies),
817 )?;
818 std::fs::write(repo_dir.join("src/main.rs"), main_src)?;
819 Ok(())
820}
821
822fn cargo_toml(package_name: &str, path_dependencies: &[(&str, &str)]) -> String {
823 let mut dependencies_block = String::new();
824 for (name, path) in path_dependencies {
825 dependencies_block.push_str(&format!("{name} = {{ path = \"{path}\" }}\n"));
826 }
827
828 format!(
829 r#"[package]
830name = "{package_name}"
831version = "0.1.0"
832edition = "2024"
833
834[dependencies]
835{dependencies_block}"#
836 )
837}
838
839fn to_alias_path(canonical_path: &Path, canonical_root: &Path, alias_root: &Path) -> PathBuf {
840 let relative = canonical_path
841 .strip_prefix(canonical_root)
842 .expect("canonical path must remain under canonical root");
843 alias_root.join(relative)
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use std::fs;
850 use std::sync::atomic::{AtomicU64, Ordering};
851
852 #[cfg(unix)]
853 use std::os::unix::fs::symlink;
854
855 static MULTI_REPO_FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
856
857 #[cfg(unix)]
858 struct MultiRepoPathFixture {
859 root: PathBuf,
860 canonical_root: PathBuf,
861 alias_root: PathBuf,
862 }
863
864 #[cfg(unix)]
865 impl MultiRepoPathFixture {
866 fn new(prefix: &str) -> Self {
867 let id = MULTI_REPO_FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
868 let root = std::env::temp_dir().join(format!(
869 "rch-e2e-multi-repo-fixtures-{}-{}-{}",
870 prefix,
871 std::process::id(),
872 id
873 ));
874 let canonical_root = root.join("data/projects");
875 let alias_root = root.join("dp");
876 fs::create_dir_all(&canonical_root).expect("create canonical root");
877 symlink(&canonical_root, &alias_root).expect("create alias symlink");
878 Self {
879 root,
880 canonical_root,
881 alias_root,
882 }
883 }
884
885 fn config(&self, namespace: &str) -> MultiRepoFixtureConfig {
886 MultiRepoFixtureConfig::new(
887 self.canonical_root.clone(),
888 self.alias_root.clone(),
889 namespace.to_string(),
890 )
891 }
892 }
893
894 #[cfg(unix)]
895 impl Drop for MultiRepoPathFixture {
896 fn drop(&mut self) {
897 let _ = fs::remove_dir_all(&self.root);
898 }
899 }
900
901 #[test]
902 fn test_worker_fixture_toml() {
903 let worker = WorkerFixture::mock_local("test-worker");
904 let toml = worker.to_toml();
905 assert!(toml.contains("id = \"test-worker\""));
906 assert!(toml.contains("host = \"localhost\""));
907 }
908
909 #[test]
910 fn test_workers_fixture_toml() {
911 let fixture = WorkersFixture::mock_local(2);
912 let toml = fixture.to_toml();
913 assert!(toml.contains("id = \"worker1\""));
914 assert!(toml.contains("id = \"worker2\""));
915 }
916
917 #[test]
918 fn test_daemon_config_toml() {
919 let config = DaemonConfigFixture::minimal(Path::new("/tmp/rch.sock"));
920 let toml = config.to_toml();
921 assert!(toml.contains("socket_path = \"/tmp/rch.sock\""));
922 assert!(toml.contains("confidence_threshold = 0.85"));
923 }
924
925 #[test]
926 fn test_rust_project_fixture() {
927 let project = RustProjectFixture::minimal("test-project");
928 let cargo_toml = project.cargo_toml();
929 assert!(cargo_toml.contains("name = \"test-project\""));
930 assert!(cargo_toml.contains("edition = \"2024\""));
931 }
932
933 #[test]
934 fn test_hook_input_fixture() {
935 let input = HookInputFixture::cargo_build();
936 let json = input.to_json();
937 assert!(json.contains("\"tool_name\": \"Bash\""));
938 assert!(json.contains("\"command\": \"cargo build\""));
939 }
940
941 #[test]
942 fn test_hook_input_custom() {
943 let input = HookInputFixture::custom("cargo test --release");
944 let json = input.to_json();
945 assert!(json.contains("\"command\": \"cargo test --release\""));
946 }
947
948 #[cfg(unix)]
949 #[test]
950 fn multi_repo_fixture_reset_is_deterministic() {
951 let fixture = MultiRepoPathFixture::new("deterministic");
952 let config = fixture.config("fixture_pack");
953
954 let first = reset_multi_repo_fixtures(&config).expect("first reset");
955 let first_manifest =
956 fs::read_to_string(&first.manifest_path).expect("read first manifest json");
957 assert!(first.manifest_path.exists());
958 assert_eq!(first.fixtures.len(), 5);
959
960 let drift_file = first.canonical_namespace_root.join("drift_marker.txt");
961 fs::write(&drift_file, "drift").expect("write drift marker");
962 assert!(drift_file.exists());
963
964 let second = reset_multi_repo_fixtures(&config).expect("second reset");
965 let second_manifest =
966 fs::read_to_string(&second.manifest_path).expect("read second manifest json");
967
968 assert!(!drift_file.exists(), "reset should remove stale files");
969 assert_eq!(
970 first
971 .fixtures
972 .iter()
973 .map(|fixture| fixture.id.clone())
974 .collect::<Vec<_>>(),
975 second
976 .fixtures
977 .iter()
978 .map(|fixture| fixture.id.clone())
979 .collect::<Vec<_>>()
980 );
981 assert_eq!(
982 first_manifest, second_manifest,
983 "manifest output must stay deterministic across resets"
984 );
985 }
986
987 #[cfg(unix)]
988 #[test]
989 fn multi_repo_fixture_metadata_includes_readiness_failure_and_layers() {
990 let fixture = MultiRepoPathFixture::new("metadata");
991 let config = fixture.config("fixture_pack");
992 let generated = reset_multi_repo_fixtures(&config).expect("generate fixture set");
993
994 assert_eq!(generated.fixtures.len(), 5);
995 for metadata in &generated.fixtures {
996 assert!(!metadata.assertion_targets.is_empty());
997 assert!(!metadata.reusable_layers.is_empty());
998 assert!(
999 metadata
1000 .canonical_entrypoint
1001 .starts_with(&generated.canonical_namespace_root)
1002 );
1003 assert!(
1004 metadata
1005 .alias_entrypoint
1006 .starts_with(&generated.alias_namespace_root)
1007 );
1008 if metadata.expected_ready() {
1009 assert!(metadata.failure_mode.is_none());
1010 } else {
1011 assert!(metadata.failure_mode.is_some());
1012 }
1013 }
1014 }
1015
1016 #[cfg(unix)]
1017 #[test]
1018 fn multi_repo_fixture_alias_absolute_scenario_uses_alias_prefix() {
1019 let fixture = MultiRepoPathFixture::new("alias");
1020 let config = fixture.config("fixture_pack");
1021 let generated = reset_multi_repo_fixtures(&config).expect("generate fixture set");
1022 let alias_fixture = generated
1023 .fixture("ready_alias_absolute")
1024 .expect("ready alias fixture metadata");
1025
1026 let cargo_toml = fs::read_to_string(alias_fixture.canonical_entrypoint.join("Cargo.toml"))
1027 .expect("read alias app cargo toml");
1028 assert!(
1029 cargo_toml.contains(config.alias_root().to_string_lossy().as_ref()),
1030 "alias fixture manifest must encode alias root path"
1031 );
1032 assert!(
1033 cargo_toml.contains("ready_alias_absolute/alias_shared"),
1034 "alias fixture manifest should reference shared dependency"
1035 );
1036 }
1037
1038 #[cfg(unix)]
1039 #[test]
1040 fn multi_repo_fixture_rejects_non_symlink_alias_root() {
1041 let id = MULTI_REPO_FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
1042 let root = std::env::temp_dir().join(format!(
1043 "rch-e2e-invalid-alias-fixtures-{}-{}",
1044 std::process::id(),
1045 id
1046 ));
1047 let canonical_root = root.join("data/projects");
1048 let alias_root = root.join("dp");
1049 fs::create_dir_all(&canonical_root).expect("create canonical root");
1050 fs::create_dir_all(&alias_root).expect("create alias directory");
1051
1052 let config = MultiRepoFixtureConfig::new(canonical_root, alias_root, "fixture_pack");
1053 let err = reset_multi_repo_fixtures(&config).expect_err("alias root must fail");
1054 assert!(matches!(err, MultiRepoFixtureError::InvalidTopology(_)));
1055
1056 let _ = fs::remove_dir_all(&root);
1057 }
1058}