Skip to main content

rch_common/e2e/
fixtures.rs

1//! E2E Test Fixtures
2//!
3//! Provides pre-built configurations, sample data, and test fixtures
4//! for end-to-end testing.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Sample worker configuration for tests
10#[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    /// Create a mock local worker (uses localhost)
22    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    /// Generate TOML configuration for this worker
41    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
56/// Collection of worker fixtures
57pub struct WorkersFixture {
58    pub workers: Vec<WorkerFixture>,
59}
60
61impl WorkersFixture {
62    /// Create an empty fixture
63    pub fn empty() -> Self {
64        Self { workers: vec![] }
65    }
66
67    /// Create a fixture with mock local workers
68    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    /// Add a worker to the fixture
76    pub fn add_worker(mut self, worker: WorkerFixture) -> Self {
77        self.workers.push(worker);
78        self
79    }
80
81    /// Generate TOML configuration for all workers
82    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/// Sample daemon configuration for tests
96#[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    /// Create a minimal daemon configuration
106    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    /// Generate TOML configuration
116    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/// Sample Rust project for testing
140#[derive(Debug, Clone)]
141pub struct RustProjectFixture {
142    pub name: String,
143    pub version: String,
144}
145
146impl RustProjectFixture {
147    /// Create a minimal Rust project fixture
148    pub fn minimal(name: &str) -> Self {
149        Self {
150            name: name.to_string(),
151            version: "0.1.0".to_string(),
152        }
153    }
154
155    /// Generate Cargo.toml content
156    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    /// Generate main.rs content
170    pub fn main_rs(&self) -> String {
171        r#"fn main() {
172    println!("Hello from test project!");
173}
174"#
175        .to_string()
176    }
177
178    /// Generate lib.rs content for a library project
179    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    /// Create the project files in the given directory
198    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    /// Create a library project in the given directory
207    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/// Sample hook input for testing
217#[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    /// Create a cargo build hook input
227    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    /// Create a cargo test hook input
237    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    /// Create a non-compilation hook input
247    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    /// Create a custom hook input
257    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    /// Convert to JSON string
267    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/// Test case metadata
286#[derive(Debug, Clone)]
287pub struct TestCaseFixture {
288    pub name: String,
289    pub description: String,
290    pub tags: Vec<String>,
291}
292
293impl TestCaseFixture {
294    /// Create a new test case fixture
295    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    /// Add a tag to the test case
304    pub fn with_tag(mut self, tag: &str) -> Self {
305        self.tags.push(tag.to_string());
306        self
307    }
308}
309
310/// Default namespace used for deterministic multi-repo path dependency fixtures.
311pub const DEFAULT_MULTI_REPO_FIXTURE_NAMESPACE: &str = "rch_multi_repo_path_deps";
312
313/// Canonical root expected for multi-repo fixture generation.
314pub const DEFAULT_MULTI_REPO_CANONICAL_ROOT: &str = "/data/projects";
315
316/// Alias root expected to symlink to [`DEFAULT_MULTI_REPO_CANONICAL_ROOT`].
317pub const DEFAULT_MULTI_REPO_ALIAS_ROOT: &str = "/dp";
318
319/// Error type for multi-repo fixture generation and reset.
320#[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
330/// Result type for multi-repo fixture operations.
331pub type MultiRepoFixtureResult<T> = Result<T, MultiRepoFixtureError>;
332
333/// Readiness expectation for a fixture scenario.
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "snake_case")]
336pub enum FixtureReadiness {
337    Ready,
338    ExpectedFailure,
339}
340
341/// Expected failure mode for a non-ready fixture scenario.
342#[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/// Test layers that can consume the fixture scenario.
351#[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/// Metadata describing one deterministic multi-repo fixture scenario.
361#[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    /// Returns true when the scenario is expected to be build-ready.
376    pub fn expected_ready(&self) -> bool {
377        matches!(self.readiness, FixtureReadiness::Ready)
378    }
379}
380
381/// Configuration for deterministic multi-repo fixture generation.
382#[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    /// Build a config with explicit topology roots and namespace.
401    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    /// Canonical root where fixture namespace will be created.
410    pub fn canonical_root(&self) -> &Path {
411        &self.canonical_root
412    }
413
414    /// Alias root expected to resolve to canonical root.
415    pub fn alias_root(&self) -> &Path {
416        &self.alias_root
417    }
418
419    /// Namespace directory under canonical root.
420    pub fn namespace(&self) -> &str {
421        &self.namespace
422    }
423}
424
425/// Deterministic multi-repo fixture set rooted under a namespace.
426#[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    /// Returns fixture metadata by id.
439    pub fn fixture(&self, id: &str) -> Option<&MultiRepoFixtureMetadata> {
440        self.fixtures.iter().find(|fixture| fixture.id == id)
441    }
442}
443
444/// Resets and recreates deterministic multi-repo fixtures under default `/data/projects` + `/dp`.
445pub fn reset_default_multi_repo_fixtures() -> MultiRepoFixtureResult<MultiRepoFixtureSet> {
446    reset_multi_repo_fixtures(&MultiRepoFixtureConfig::default())
447}
448
449/// Resets and recreates deterministic multi-repo fixtures for the provided topology.
450pub 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}