agtrace_testing/
fixtures.rs

1//! Fixtures for sample data generation and placement.
2//!
3//! Provides utilities to:
4//! - Copy sample files to test environments
5//! - Modify sample data for isolation (e.g., cwd, sessionId)
6//! - Generate provider-specific directory structures
7
8use anyhow::Result;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// Sample file manager for test data.
13pub struct SampleFiles {
14    samples_dir: PathBuf,
15}
16
17impl Default for SampleFiles {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl SampleFiles {
24    /// Create a new sample file manager.
25    ///
26    /// Assumes samples are in `crates/agtrace-providers/tests/samples/`.
27    pub fn new() -> Self {
28        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
29        let samples_dir = manifest_dir
30            .parent()
31            .unwrap()
32            .join("agtrace-providers/tests/samples");
33
34        Self { samples_dir }
35    }
36
37    /// Copy a sample file to a destination.
38    pub fn copy_to(&self, sample_name: &str, dest: &Path) -> Result<()> {
39        let source = self.samples_dir.join(sample_name);
40        fs::copy(source, dest)?;
41        Ok(())
42    }
43
44    /// Copy a sample file to a Claude-encoded project directory.
45    ///
46    /// Claude encodes project paths like: `/Users/foo/bar` -> `-Users-foo-bar`
47    pub fn copy_to_project(
48        &self,
49        sample_name: &str,
50        dest_name: &str,
51        project_dir: &str,
52        log_root: &Path,
53    ) -> Result<()> {
54        let source = self.samples_dir.join(sample_name);
55
56        // Encode project directory (Claude format)
57        let encoded = project_dir
58            .replace(['/', '.'], "-")
59            .trim_start_matches('-')
60            .to_string();
61        let encoded_dir = format!("-{}", encoded);
62
63        let project_log_dir = log_root.join(encoded_dir);
64        fs::create_dir_all(&project_log_dir)?;
65
66        let dest = project_log_dir.join(dest_name);
67        fs::copy(source, dest)?;
68        Ok(())
69    }
70
71    /// Copy a sample file with cwd and sessionId replacement.
72    ///
73    /// This creates isolated test sessions by:
74    /// 1. Replacing the embedded `cwd` field with `target_project_dir` (canonicalized)
75    /// 2. Generating a unique `sessionId` based on project dir + filename
76    /// 3. Using provider-specific directory encoding via the provider adapter
77    pub fn copy_to_project_with_cwd(
78        &self,
79        sample_name: &str,
80        dest_name: &str,
81        target_project_dir: &str,
82        log_root: &Path,
83        provider_adapter: &agtrace_providers::ProviderAdapter,
84    ) -> Result<()> {
85        let source = self.samples_dir.join(sample_name);
86
87        // Canonicalize target_project_dir to match project_hash_from_root behavior
88        let canonical_project_dir = Path::new(target_project_dir)
89            .canonicalize()
90            .unwrap_or_else(|_| Path::new(target_project_dir).to_path_buf());
91        let canonical_str = canonical_project_dir.to_string_lossy();
92
93        // TODO(CRITICAL): LAYER VIOLATION - test code should NOT know provider directory encoding
94        //
95        // Current issue:
96        // - Hardcoded if/else branching based on provider implementation details
97        // - Duplicated logic in world.rs::get_session_file_path()
98        // - Testing layer depends on Claude's "-" encoding vs Gemini's hash subdirs
99        //
100        // Required fix:
101        // - Add `encode_project_path(project_root: &Path) -> PathBuf` to LogDiscovery trait
102        // - Each provider implements its own encoding strategy
103        // - Replace this if/else with: `provider_adapter.discovery.encode_project_path()`
104        //
105        // This abstraction belongs in agtrace-providers, NOT in test utilities.
106        let project_log_dir = if let Some(provider_subdir) = provider_adapter
107            .discovery
108            .resolve_log_root(&canonical_project_dir)
109        {
110            // Provider uses project-specific subdirectory (e.g., Gemini uses hash)
111            log_root.join(provider_subdir)
112        } else {
113            // Provider uses flat structure with encoded project names (e.g., Claude)
114            let encoded = target_project_dir
115                .replace(['/', '.'], "-")
116                .trim_start_matches('-')
117                .to_string();
118            let encoded_dir = format!("-{}", encoded);
119            log_root.join(encoded_dir)
120        };
121
122        fs::create_dir_all(&project_log_dir)?;
123
124        let dest = project_log_dir.join(dest_name);
125
126        // Read and modify content
127        let content = fs::read_to_string(&source)?;
128
129        // Replace cwd field with canonicalized path (Claude format)
130        let mut modified_content = content.replace(
131            r#""cwd":"/Users/test_user/agent-sample""#,
132            &format!(r#""cwd":"{}""#, canonical_str),
133        );
134
135        // Replace projectHash field (Gemini format)
136        // Calculate the correct project hash from the canonicalized path
137        let project_hash = agtrace_types::project_hash_from_root(&canonical_str);
138        modified_content = modified_content.replace(
139            r#""projectHash": "9126eddec7f67e038794657b4d517dd9cb5226468f30b5ee7296c27d65e84fde""#,
140            &format!(r#""projectHash": "{}""#, project_hash),
141        );
142
143        // Generate unique sessionId
144        let new_session_id = generate_session_id(target_project_dir, dest_name);
145
146        // Replace sessionId (Claude: 7f2abd2d-7cfc-4447-9ddd-3ca8d14e02e9)
147        modified_content =
148            modified_content.replace("7f2abd2d-7cfc-4447-9ddd-3ca8d14e02e9", &new_session_id);
149
150        // Replace sessionId (Gemini: f0a689a6-b0ac-407f-afcc-4fafa9e14e8a)
151        modified_content =
152            modified_content.replace("f0a689a6-b0ac-407f-afcc-4fafa9e14e8a", &new_session_id);
153
154        fs::write(dest, modified_content)?;
155        Ok(())
156    }
157}
158
159/// Generate a deterministic session ID based on project directory and filename.
160fn generate_session_id(project_dir: &str, filename: &str) -> String {
161    use sha2::{Digest, Sha256};
162
163    let mut hasher = Sha256::new();
164    hasher.update(project_dir.as_bytes());
165    hasher.update(filename.as_bytes());
166    let hash = hasher.finalize();
167
168    format!(
169        "test-session-{:016x}",
170        u64::from_be_bytes([
171            hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7]
172        ])
173    )
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_generate_session_id_deterministic() {
182        let id1 = generate_session_id("/Users/test/project-a", "session1.jsonl");
183        let id2 = generate_session_id("/Users/test/project-a", "session1.jsonl");
184        assert_eq!(id1, id2);
185    }
186
187    #[test]
188    fn test_generate_session_id_unique() {
189        let id1 = generate_session_id("/Users/test/project-a", "session1.jsonl");
190        let id2 = generate_session_id("/Users/test/project-b", "session1.jsonl");
191        assert_ne!(id1, id2);
192    }
193}