agtrace_testing/
world.rs

1//! TestWorld pattern for declarative integration test setup.
2//!
3//! Provides a fluent interface for:
4//! - Creating isolated test environments
5//! - Managing working directories
6//! - Setting up sample data
7//! - Executing CLI commands with proper context
8
9use anyhow::Result;
10use assert_cmd::Command;
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14use tempfile::TempDir;
15
16use crate::fixtures::SampleFiles;
17use crate::providers::TestProvider;
18
19/// Builder for constructing TestWorld with fine-grained control.
20///
21/// This builder allows testing edge cases like:
22/// - Fresh install (no .agtrace directory)
23/// - Missing or invalid configuration
24/// - Custom environment variables
25///
26/// # Example
27/// ```no_run
28/// use agtrace_testing::TestWorld;
29///
30/// let world = TestWorld::builder()
31///     .without_data_dir()
32///     .build();
33///
34/// assert!(!world.assert_database_exists());
35/// ```
36pub struct TestWorldBuilder {
37    skip_data_dir_creation: bool,
38    env_vars: HashMap<String, String>,
39}
40
41impl Default for TestWorldBuilder {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl TestWorldBuilder {
48    pub fn new() -> Self {
49        Self {
50            skip_data_dir_creation: false,
51            env_vars: HashMap::new(),
52        }
53    }
54
55    /// Skip automatic creation of .agtrace directory.
56    ///
57    /// Useful for testing fresh install scenarios where the data directory
58    /// does not exist yet.
59    pub fn without_data_dir(mut self) -> Self {
60        self.skip_data_dir_creation = true;
61        self
62    }
63
64    /// Set an environment variable for CLI execution.
65    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
66        self.env_vars.insert(key.into(), value.into());
67        self
68    }
69
70    /// Build the TestWorld with configured settings.
71    pub fn build(self) -> TestWorld {
72        let temp_dir = TempDir::new().expect("Failed to create temp dir");
73        let base_path = temp_dir.path().to_path_buf();
74        let data_dir = base_path.join(".agtrace");
75        let log_root = base_path.join(".claude");
76
77        // Create directories unless explicitly skipped
78        if !self.skip_data_dir_creation {
79            fs::create_dir_all(&data_dir).expect("Failed to create data dir");
80        }
81
82        // Log root is always created (represents external provider directories)
83        fs::create_dir_all(&log_root).expect("Failed to create log dir");
84
85        TestWorld {
86            temp_dir,
87            cwd: base_path,
88            data_dir,
89            log_root,
90            env_vars: self.env_vars,
91            samples: SampleFiles::new(),
92        }
93    }
94}
95
96/// Declarative test environment builder.
97///
98/// # Example
99/// ```no_run
100/// use agtrace_testing::TestWorld;
101///
102/// let world = TestWorld::new()
103///     .with_project("project-a")
104///     .enter_dir("project-a");
105///
106/// let result = world.run(&["session", "list"]).unwrap();
107/// assert!(result.success());
108/// ```
109pub struct TestWorld {
110    temp_dir: TempDir,
111    cwd: PathBuf,
112    data_dir: PathBuf,
113    log_root: PathBuf,
114    env_vars: HashMap<String, String>,
115    samples: SampleFiles,
116}
117
118impl Default for TestWorld {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl TestWorld {
125    /// Create a builder for constructing a TestWorld.
126    ///
127    /// # Example
128    /// ```no_run
129    /// use agtrace_testing::TestWorld;
130    ///
131    /// let world = TestWorld::builder()
132    ///     .without_data_dir()
133    ///     .build();
134    /// ```
135    pub fn builder() -> TestWorldBuilder {
136        TestWorldBuilder::new()
137    }
138
139    /// Create a new isolated test environment with default settings.
140    ///
141    /// This is a convenience method that creates a fully initialized environment.
142    /// For testing edge cases, use `TestWorld::builder()`.
143    pub fn new() -> Self {
144        Self::builder().build()
145    }
146
147    /// Get the data directory path (.agtrace).
148    pub fn data_dir(&self) -> &Path {
149        &self.data_dir
150    }
151
152    /// Get the log root directory path (.claude).
153    pub fn log_root(&self) -> &Path {
154        &self.log_root
155    }
156
157    /// Get the current working directory.
158    pub fn cwd(&self) -> &Path {
159        &self.cwd
160    }
161
162    /// Get the temp directory root.
163    pub fn temp_dir(&self) -> &Path {
164        self.temp_dir.path()
165    }
166
167    // --- Resource Manipulation Methods ---
168
169    /// Remove config.toml to simulate loss or fresh install.
170    ///
171    /// # Example
172    /// ```no_run
173    /// # use agtrace_testing::TestWorld;
174    /// let world = TestWorld::new();
175    /// world.remove_config().unwrap();
176    /// assert!(!world.assert_config_exists());
177    /// ```
178    pub fn remove_config(&self) -> Result<()> {
179        let config_path = self.data_dir.join("config.toml");
180        if config_path.exists() {
181            fs::remove_file(config_path)?;
182        }
183        Ok(())
184    }
185
186    /// Remove agtrace.db to simulate database loss.
187    ///
188    /// # Example
189    /// ```no_run
190    /// # use agtrace_testing::TestWorld;
191    /// let world = TestWorld::new();
192    /// world.remove_database().unwrap();
193    /// assert!(!world.assert_database_exists());
194    /// ```
195    pub fn remove_database(&self) -> Result<()> {
196        let db_path = self.data_dir.join("agtrace.db");
197        if db_path.exists() {
198            fs::remove_file(db_path)?;
199        }
200        Ok(())
201    }
202
203    /// Write arbitrary content to config.toml.
204    ///
205    /// Creates the data directory if it doesn't exist.
206    ///
207    /// # Example
208    /// ```no_run
209    /// # use agtrace_testing::TestWorld;
210    /// let world = TestWorld::builder().without_data_dir().build();
211    ///
212    /// world.write_raw_config(r#"
213    /// [providers.claude_code]
214    /// enabled = true
215    /// log_root = "/custom/path"
216    /// "#).unwrap();
217    ///
218    /// assert!(world.assert_config_exists());
219    /// ```
220    pub fn write_raw_config(&self, content: &str) -> Result<()> {
221        if !self.data_dir.exists() {
222            fs::create_dir_all(&self.data_dir)?;
223        }
224        let config_path = self.data_dir.join("config.toml");
225        fs::write(config_path, content)?;
226        Ok(())
227    }
228
229    /// Check if agtrace.db exists.
230    pub fn assert_database_exists(&self) -> bool {
231        self.data_dir.join("agtrace.db").exists()
232    }
233
234    /// Check if config.toml exists.
235    pub fn assert_config_exists(&self) -> bool {
236        self.data_dir.join("config.toml").exists()
237    }
238
239    // --- Directory Management ---
240
241    /// Change the current working directory (relative to temp root).
242    ///
243    /// This is crucial for testing CWD-dependent logic.
244    /// This method consumes `self` for use in builder pattern chains.
245    ///
246    /// For changing directory multiple times in a test, use `set_cwd()` instead.
247    pub fn enter_dir<P: AsRef<Path>>(mut self, path: P) -> Self {
248        let new_cwd = if path.as_ref().is_absolute() {
249            path.as_ref().to_path_buf()
250        } else {
251            self.temp_dir.path().join(path)
252        };
253
254        // Create the directory if it doesn't exist
255        std::fs::create_dir_all(&new_cwd).expect("Failed to create directory");
256        self.cwd = new_cwd;
257        self
258    }
259
260    /// Set the current working directory without consuming self.
261    ///
262    /// This is useful when you need to change directories multiple times
263    /// during a test.
264    ///
265    /// # Example
266    /// ```no_run
267    /// # use agtrace_testing::TestWorld;
268    /// let mut world = TestWorld::new()
269    ///     .with_project("project-a")
270    ///     .with_project("project-b");
271    ///
272    /// // Move to project-a
273    /// world.set_cwd("project-a");
274    /// let result = world.run(&["session", "list"]).unwrap();
275    ///
276    /// // Move to project-b
277    /// world.set_cwd("project-b");
278    /// let result = world.run(&["session", "list"]).unwrap();
279    /// ```
280    pub fn set_cwd<P: AsRef<Path>>(&mut self, path: P) {
281        let new_cwd = if path.as_ref().is_absolute() {
282            path.as_ref().to_path_buf()
283        } else {
284            self.temp_dir.path().join(path)
285        };
286
287        // Create the directory if it doesn't exist
288        std::fs::create_dir_all(&new_cwd).expect("Failed to create directory");
289        self.cwd = new_cwd;
290    }
291
292    /// Set an environment variable for CLI execution.
293    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
294        self.env_vars.insert(key.into(), value.into());
295        self
296    }
297
298    /// Create a project directory structure.
299    pub fn with_project(self, project_name: &str) -> Self {
300        let project_dir = self.temp_dir.path().join(project_name);
301        std::fs::create_dir_all(&project_dir).expect("Failed to create project dir");
302        self
303    }
304
305    /// Configure a CLI command with this test environment's settings.
306    ///
307    /// The caller must provide the base command (e.g., from `cargo_bin_cmd!("agtrace")`).
308    /// This method configures it with the appropriate data-dir, cwd, and env vars.
309    pub fn configure_command<'a>(&self, cmd: &'a mut Command) -> &'a mut Command {
310        cmd.arg("--data-dir")
311            .arg(self.data_dir())
312            .arg("--format")
313            .arg("plain");
314
315        // Set CWD for the command
316        cmd.current_dir(&self.cwd);
317
318        // Apply environment variables
319        for (key, value) in &self.env_vars {
320            cmd.env(key, value);
321        }
322
323        cmd
324    }
325
326    /// Create a CLI command configured for this test environment.
327    ///
328    /// Note: This requires the binary to be built and available in the cargo target directory.
329    /// For integration tests, prefer using `configure_command` with `cargo_bin_cmd!("agtrace")`.
330    #[doc(hidden)]
331    pub fn command_from_path(&self, bin_path: impl AsRef<std::ffi::OsStr>) -> Command {
332        let mut cmd = Command::new(bin_path);
333        self.configure_command(&mut cmd);
334        cmd
335    }
336
337    /// Copy a sample file to the log root.
338    pub fn copy_sample(&self, sample_name: &str, dest_name: &str) -> Result<()> {
339        let dest = self.log_root.join(dest_name);
340        self.samples.copy_to(sample_name, &dest)
341    }
342
343    /// Copy a sample file to a Claude-encoded project directory.
344    pub fn copy_sample_to_project(
345        &self,
346        sample_name: &str,
347        dest_name: &str,
348        project_dir: &str,
349    ) -> Result<()> {
350        self.samples
351            .copy_to_project(sample_name, dest_name, project_dir, &self.log_root)
352    }
353
354    /// Copy a sample file to a project with cwd and sessionId replacement.
355    ///
356    /// This is the recommended method for creating isolated test sessions.
357    pub fn copy_sample_to_project_with_cwd(
358        &self,
359        sample_name: &str,
360        dest_name: &str,
361        target_project_dir: &str,
362        provider: TestProvider,
363    ) -> Result<()> {
364        let adapter = provider.adapter();
365        self.samples.copy_to_project_with_cwd(
366            sample_name,
367            dest_name,
368            target_project_dir,
369            &self.log_root,
370            &adapter,
371        )
372    }
373
374    /// Execute a command using the project's binary and return the result.
375    ///
376    /// This is a convenience method that creates a command, configures it
377    /// with the test environment settings, and executes it.
378    ///
379    /// # Example
380    /// ```no_run
381    /// # use agtrace_testing::TestWorld;
382    /// let world = TestWorld::new();
383    /// let result = world.run(&["session", "list"]).unwrap();
384    /// assert!(result.success());
385    /// ```
386    ///
387    /// # Note
388    /// This method uses `Command::cargo_bin()` which requires the binary to be
389    /// built and the `CARGO_BIN_EXE_` environment variable to be set (which
390    /// cargo test does automatically).
391    #[allow(deprecated)]
392    pub fn run(&self, args: &[&str]) -> Result<CliResult> {
393        // Find the binary using cargo_bin
394        let mut cmd = Command::cargo_bin("agtrace")
395            .map_err(|e| anyhow::anyhow!("Failed to find agtrace binary: {}", e))?;
396
397        // Configure with test environment settings
398        self.configure_command(&mut cmd);
399
400        // Add arguments
401        cmd.args(args);
402
403        // Execute
404        let output = cmd.output()?;
405
406        Ok(CliResult {
407            status: output.status,
408            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
409            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
410        })
411    }
412
413    /// Execute a command in a specific directory temporarily.
414    ///
415    /// This helper temporarily changes the working directory, runs the command,
416    /// and restores the original directory. This is useful for testing commands
417    /// that depend on the current working directory without permanently changing
418    /// the `TestWorld` state.
419    ///
420    /// # Example
421    /// ```no_run
422    /// # use agtrace_testing::TestWorld;
423    /// let mut world = TestWorld::new()
424    ///     .with_project("project-a")
425    ///     .with_project("project-b");
426    ///
427    /// // Run in project-a
428    /// let result_a = world.run_in_dir(&["session", "list"], "project-a").unwrap();
429    ///
430    /// // Run in project-b (without manually changing cwd)
431    /// let result_b = world.run_in_dir(&["session", "list"], "project-b").unwrap();
432    ///
433    /// // Original cwd is preserved
434    /// ```
435    pub fn run_in_dir<P: AsRef<Path>>(&mut self, args: &[&str], dir: P) -> Result<CliResult> {
436        // Save original cwd
437        let original_cwd = self.cwd.clone();
438
439        // Temporarily change directory
440        self.set_cwd(dir);
441
442        // Run the command
443        let result = self.run(args);
444
445        // Restore original directory
446        self.cwd = original_cwd;
447
448        result
449    }
450
451    /// Enable a provider and configure it in the test environment.
452    ///
453    /// This method:
454    /// 1. Creates the provider's log directory
455    /// 2. Runs `provider set` command to update config.toml
456    /// 3. Enables the provider
457    ///
458    /// # Example
459    /// ```no_run
460    /// # use agtrace_testing::{TestWorld, providers::TestProvider};
461    /// let world = TestWorld::new();
462    /// world.enable_provider(TestProvider::Claude).unwrap();
463    /// world.enable_provider(TestProvider::Gemini).unwrap();
464    /// ```
465    ///
466    /// This tests the CLI's configuration routing logic.
467    pub fn enable_provider(&self, provider: TestProvider) -> Result<()> {
468        // Create provider-specific log directory
469        let log_root = self.temp_dir.path().join(provider.default_log_dir_name());
470        std::fs::create_dir_all(&log_root)?;
471
472        // Configure provider via CLI (tests the config.toml update logic)
473        let log_root_str = log_root.to_string_lossy();
474        let result = self.run(&[
475            "provider",
476            "set",
477            provider.name(),
478            "--enable",
479            "--log-root",
480            &log_root_str,
481        ])?;
482
483        if !result.success() {
484            anyhow::bail!(
485                "Failed to enable provider {}: {}",
486                provider.name(),
487                result.stderr()
488            );
489        }
490
491        Ok(())
492    }
493
494    /// Get the full path to a session file.
495    ///
496    /// This helper resolves the provider-specific directory encoding and returns
497    /// the absolute path to the session file.
498    ///
499    /// TODO(CRITICAL): This is a LAYER VIOLATION - test code should NOT know provider implementation details
500    ///
501    /// Current issue:
502    /// - Testing layer depends on provider-specific directory encoding logic
503    /// - Same if/else branching duplicated in fixtures.rs
504    /// - Hardcoded knowledge of Claude's "-" encoding vs Gemini's hash-based subdirs
505    ///
506    /// Required fix:
507    /// - Add `encode_project_path(project_root: &Path) -> PathBuf` to LogDiscovery trait
508    /// - Each provider implements its own encoding (Claude: "-Users-foo-bar", Gemini: hash, Codex: flat)
509    /// - Remove this if/else branching and call `adapter.discovery.encode_project_path()`
510    /// - Consolidate with fixtures.rs logic
511    ///
512    /// This abstraction belongs in agtrace-providers, NOT in test utilities.
513    pub fn get_session_file_path(&self, provider: TestProvider, filename: &str) -> Result<PathBuf> {
514        let log_root = self.temp_dir.path().join(provider.default_log_dir_name());
515        let project_dir = self.cwd.to_string_lossy();
516        let adapter = provider.adapter();
517
518        // Canonicalize target_project_dir to match project_hash_from_root behavior
519        let canonical_project_dir = self.cwd.canonicalize().unwrap_or_else(|_| self.cwd.clone());
520
521        // WORST: Provider-specific branching in test code - MUST move to LogDiscovery trait
522        let project_log_dir = if let Some(provider_subdir) =
523            adapter.discovery.resolve_log_root(&canonical_project_dir)
524        {
525            // Provider uses project-specific subdirectory (e.g., Gemini uses hash)
526            log_root.join(provider_subdir)
527        } else {
528            // Provider uses flat structure with encoded project names (e.g., Claude)
529            let encoded = project_dir
530                .replace(['/', '.'], "-")
531                .trim_start_matches('-')
532                .to_string();
533            let encoded_dir = format!("-{}", encoded);
534            log_root.join(encoded_dir)
535        };
536
537        Ok(project_log_dir.join(filename))
538    }
539
540    /// Add a session log for the specified provider.
541    ///
542    /// This method:
543    /// 1. Determines the provider's log directory
544    /// 2. Places a sample session file in the correct location
545    /// 3. Handles provider-specific directory encoding via the provider adapter
546    ///
547    /// # Example
548    /// ```no_run
549    /// # use agtrace_testing::{TestWorld, providers::TestProvider};
550    /// let mut world = TestWorld::new()
551    ///     .with_project("my-project")
552    ///     .enter_dir("my-project");
553    ///
554    /// world.enable_provider(TestProvider::Claude).unwrap();
555    ///
556    /// // Add a Claude session for the current project
557    /// world.add_session(TestProvider::Claude, "session1.jsonl").unwrap();
558    /// ```
559    ///
560    /// This tests the CLI's ability to find logs in the correct provider directory.
561    pub fn add_session(&self, provider: TestProvider, dest_filename: &str) -> Result<()> {
562        let log_root = self.temp_dir.path().join(provider.default_log_dir_name());
563        let project_dir = self.cwd.to_string_lossy();
564        let adapter = provider.adapter();
565
566        // Use the existing fixture infrastructure with provider-specific log root and adapter
567        self.samples.copy_to_project_with_cwd(
568            provider.sample_filename(),
569            dest_filename,
570            &project_dir,
571            &log_root,
572            &adapter,
573        )
574    }
575
576    /// Set file modification time for testing time-based logic.
577    ///
578    /// This is useful for testing features that depend on file modification times,
579    /// such as watch mode's "most recently updated" session detection.
580    ///
581    /// # Example
582    /// ```no_run
583    /// # use agtrace_testing::{TestWorld, providers::TestProvider};
584    /// # use std::time::{SystemTime, Duration};
585    /// let mut world = TestWorld::new();
586    /// world.enable_provider(TestProvider::Claude).unwrap();
587    /// world.add_session(TestProvider::Claude, "session1.jsonl").unwrap();
588    ///
589    /// // Set older modification time
590    /// let old_time = SystemTime::now() - Duration::from_secs(3600);
591    /// world.set_file_mtime(TestProvider::Claude, "session1.jsonl", old_time).unwrap();
592    /// ```
593    pub fn set_file_mtime(
594        &self,
595        provider: TestProvider,
596        filename: &str,
597        mtime: std::time::SystemTime,
598    ) -> Result<()> {
599        let file_path = self.get_session_file_path(provider, filename)?;
600
601        if !file_path.exists() {
602            anyhow::bail!("File does not exist: {}", file_path.display());
603        }
604
605        filetime::set_file_mtime(&file_path, filetime::FileTime::from_system_time(mtime))?;
606
607        Ok(())
608    }
609}
610
611/// Result of a CLI command execution.
612#[derive(Debug)]
613pub struct CliResult {
614    pub status: std::process::ExitStatus,
615    pub stdout: String,
616    pub stderr: String,
617}
618
619impl CliResult {
620    /// Check if the command succeeded.
621    pub fn success(&self) -> bool {
622        self.status.success()
623    }
624
625    /// Parse stdout as JSON.
626    pub fn json(&self) -> Result<serde_json::Value> {
627        Ok(serde_json::from_str(&self.stdout)?)
628    }
629
630    /// Get stdout as a string.
631    pub fn stdout(&self) -> &str {
632        &self.stdout
633    }
634
635    /// Get stderr as a string.
636    pub fn stderr(&self) -> &str {
637        &self.stderr
638    }
639}