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/", "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[workspace]
166resolver = "3"
167"#,
168            self.name, self.version
169        )
170    }
171
172    /// Generate main.rs content
173    pub fn main_rs(&self) -> String {
174        r#"fn main() {
175    println!("Hello from test project!");
176}
177"#
178        .to_string()
179    }
180
181    /// Generate lib.rs content for a library project
182    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    /// Create the project files in the given directory
201    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    /// Create a library project in the given directory
210    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/// Sample hook input for testing
220#[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    /// Create a cargo build hook input
230    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    /// Create a cargo test hook input
240    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    /// Create a non-compilation hook input
250    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    /// Create a custom hook input
260    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    /// Convert to JSON string
270    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/// Test case metadata
289#[derive(Debug, Clone)]
290pub struct TestCaseFixture {
291    pub name: String,
292    pub description: String,
293    pub tags: Vec<String>,
294}
295
296impl TestCaseFixture {
297    /// Create a new test case fixture
298    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    /// Add a tag to the test case
307    pub fn with_tag(mut self, tag: &str) -> Self {
308        self.tags.push(tag.to_string());
309        self
310    }
311}
312
313/// Default namespace used for deterministic multi-repo path dependency fixtures.
314pub const DEFAULT_MULTI_REPO_FIXTURE_NAMESPACE: &str = "rch_multi_repo_path_deps";
315
316/// Canonical root expected for multi-repo fixture generation.
317pub const DEFAULT_MULTI_REPO_CANONICAL_ROOT: &str = "/data/projects";
318
319/// Alias root expected to symlink to [`DEFAULT_MULTI_REPO_CANONICAL_ROOT`].
320pub const DEFAULT_MULTI_REPO_ALIAS_ROOT: &str = "/dp";
321
322/// Error type for multi-repo fixture generation and reset.
323#[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
333/// Result type for multi-repo fixture operations.
334pub type MultiRepoFixtureResult<T> = Result<T, MultiRepoFixtureError>;
335
336/// Readiness expectation for a fixture scenario.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "snake_case")]
339pub enum FixtureReadiness {
340    Ready,
341    ExpectedFailure,
342}
343
344/// Expected failure mode for a non-ready fixture scenario.
345#[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/// Test layers that can consume the fixture scenario.
354#[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/// Metadata describing one deterministic multi-repo fixture scenario.
364#[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    /// Returns true when the scenario is expected to be build-ready.
379    pub fn expected_ready(&self) -> bool {
380        matches!(self.readiness, FixtureReadiness::Ready)
381    }
382}
383
384/// Configuration for deterministic multi-repo fixture generation.
385#[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    /// Build a config with explicit topology roots and namespace.
404    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    /// Canonical root where fixture namespace will be created.
413    pub fn canonical_root(&self) -> &Path {
414        &self.canonical_root
415    }
416
417    /// Alias root expected to resolve to canonical root.
418    pub fn alias_root(&self) -> &Path {
419        &self.alias_root
420    }
421
422    /// Namespace directory under canonical root.
423    pub fn namespace(&self) -> &str {
424        &self.namespace
425    }
426}
427
428/// Deterministic multi-repo fixture set rooted under a namespace.
429#[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    /// Returns fixture metadata by id.
442    pub fn fixture(&self, id: &str) -> Option<&MultiRepoFixtureMetadata> {
443        self.fixtures.iter().find(|fixture| fixture.id == id)
444    }
445}
446
447/// Resets and recreates deterministic multi-repo fixtures under default `/data/projects` + `/dp`.
448pub fn reset_default_multi_repo_fixtures() -> MultiRepoFixtureResult<MultiRepoFixtureSet> {
449    reset_multi_repo_fixtures(&MultiRepoFixtureConfig::default())
450}
451
452/// Resets and recreates deterministic multi-repo fixtures for the provided topology.
453pub 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}