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}