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