Skip to main content

debtmap/testkit/
mock_env.rs

1//! Mock environment for testing debtmap analysis operations.
2//!
3//! This module provides [`DebtmapTestEnv`], an in-memory implementation of
4//! [`AnalysisEnv`] that enables fast, isolated tests
5//! without real I/O operations.
6//!
7//! # Architecture
8//!
9//! `DebtmapTestEnv` implements the same traits as production environments:
10//! - [`FileSystem`]: In-memory file storage
11//! - [`CoverageLoader`]: Mock coverage data
12//! - [`Cache`]: In-memory cache
13//!
14//! # Thread Safety
15//!
16//! `DebtmapTestEnv` is `Send + Sync + Clone`, making it suitable for:
17//! - Parallel test execution with rayon
18//! - Async tests with tokio
19//! - Shared test fixtures
20//!
21//! The internal state uses `Arc<RwLock<_>>` for safe concurrent access.
22
23use crate::config::DebtmapConfig;
24use crate::env::AnalysisEnv;
25use crate::errors::AnalysisError;
26use crate::io::traits::{Cache, CoverageData, CoverageLoader, FileCoverage, FileSystem};
27use crate::progress::implementations::{RecordingProgressSink, SilentProgressSink};
28use crate::progress::traits::{HasProgress, ProgressSink};
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use std::sync::{Arc, RwLock};
32
33/// In-memory test environment for debtmap analysis.
34///
35/// Provides a complete mock implementation of [`AnalysisEnv`] with:
36/// - In-memory file system with readable/writable files
37/// - Mock coverage data for testing coverage-based analysis
38/// - In-memory cache for testing caching behavior
39/// - Configurable settings via fluent builder API
40///
41/// # Example
42///
43/// ```rust,ignore
44/// use debtmap::testkit::DebtmapTestEnv;
45/// use debtmap::env::AnalysisEnv;
46///
47/// let env = DebtmapTestEnv::new()
48///     .with_file("src/main.rs", "fn main() { println!(\"Hello\"); }")
49///     .with_file("src/lib.rs", "pub fn add(a: i32, b: i32) -> i32 { a + b }")
50///     .with_coverage_percentage("src/main.rs", 80.0)
51///     .with_coverage_percentage("src/lib.rs", 100.0);
52///
53/// // Now use env with analysis functions
54/// let content = env.file_system().read_to_string("src/main.rs".as_ref()).unwrap();
55/// assert!(content.contains("fn main"));
56/// ```
57///
58/// # Performance
59///
60/// All operations are in-memory with no I/O overhead:
61/// - File reads: ~1μs (vs ~50ms with real files)
62/// - Coverage lookups: ~1μs
63/// - Cache operations: ~1μs
64#[derive(Clone)]
65pub struct DebtmapTestEnv {
66    files: Arc<RwLock<HashMap<PathBuf, String>>>,
67    coverage: Arc<RwLock<CoverageData>>,
68    cache: Arc<RwLock<HashMap<String, Vec<u8>>>>,
69    config: DebtmapConfig,
70    progress: Arc<dyn ProgressSink>,
71}
72
73impl DebtmapTestEnv {
74    /// Create a new empty test environment.
75    ///
76    /// The environment starts with:
77    /// - Empty file system
78    /// - No coverage data
79    /// - Empty cache
80    /// - Default configuration
81    pub fn new() -> Self {
82        Self {
83            files: Arc::new(RwLock::new(HashMap::new())),
84            coverage: Arc::new(RwLock::new(CoverageData::new())),
85            cache: Arc::new(RwLock::new(HashMap::new())),
86            config: DebtmapConfig::default(),
87            progress: Arc::new(SilentProgressSink),
88        }
89    }
90
91    /// Create a test environment with a recording progress sink.
92    ///
93    /// This returns both the environment and the recorder, allowing tests
94    /// to verify progress events.
95    ///
96    /// # Example
97    ///
98    /// ```rust,ignore
99    /// let (env, recorder) = DebtmapTestEnv::with_recording_progress();
100    /// // ... run effect that reports progress ...
101    /// assert_eq!(recorder.stages(), vec!["Analysis"]);
102    /// ```
103    pub fn with_recording_progress() -> (Self, Arc<RecordingProgressSink>) {
104        let recorder = Arc::new(RecordingProgressSink::new());
105        let env = Self {
106            files: Arc::new(RwLock::new(HashMap::new())),
107            coverage: Arc::new(RwLock::new(CoverageData::new())),
108            cache: Arc::new(RwLock::new(HashMap::new())),
109            config: DebtmapConfig::default(),
110            progress: recorder.clone(),
111        };
112        (env, recorder)
113    }
114
115    /// Set a custom progress sink.
116    ///
117    /// # Example
118    ///
119    /// ```rust,ignore
120    /// use std::sync::Arc;
121    /// use debtmap::progress::implementations::RecordingProgressSink;
122    ///
123    /// let recorder = Arc::new(RecordingProgressSink::new());
124    /// let env = DebtmapTestEnv::new()
125    ///     .with_progress(recorder.clone());
126    /// ```
127    pub fn with_progress(self, progress: Arc<dyn ProgressSink>) -> Self {
128        Self { progress, ..self }
129    }
130
131    /// Add a file to the mock file system.
132    ///
133    /// # Example
134    ///
135    /// ```rust,ignore
136    /// let env = DebtmapTestEnv::new()
137    ///     .with_file("test.rs", "fn foo() {}");
138    /// ```
139    pub fn with_file(self, path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
140        self.files
141            .write()
142            .expect("Lock poisoned")
143            .insert(path.into(), content.into());
144        self
145    }
146
147    /// Add multiple files at once.
148    ///
149    /// # Example
150    ///
151    /// ```rust,ignore
152    /// let env = DebtmapTestEnv::new()
153    ///     .with_files(vec![
154    ///         ("src/main.rs", "fn main() {}"),
155    ///         ("src/lib.rs", "pub fn lib() {}"),
156    ///         ("tests/test.rs", "#[test] fn test() {}"),
157    ///     ]);
158    /// ```
159    pub fn with_files<'a>(mut self, files: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
160        for (path, content) in files {
161            self = self.with_file(path, content);
162        }
163        self
164    }
165
166    /// Add coverage data for a file with specific line hits.
167    ///
168    /// # Example
169    ///
170    /// ```rust,ignore
171    /// use debtmap::io::traits::FileCoverage;
172    ///
173    /// let mut fc = FileCoverage::new();
174    /// fc.add_line(1, 5);  // Line 1 hit 5 times
175    /// fc.add_line(2, 0);  // Line 2 not hit
176    /// fc.add_line(3, 3);  // Line 3 hit 3 times
177    ///
178    /// let env = DebtmapTestEnv::new()
179    ///     .with_coverage("test.rs", fc);
180    /// ```
181    pub fn with_coverage(self, path: impl Into<PathBuf>, coverage: FileCoverage) -> Self {
182        self.coverage
183            .write()
184            .expect("Lock poisoned")
185            .add_file_coverage(path.into(), coverage);
186        self
187    }
188
189    /// Add coverage data as a simple percentage.
190    ///
191    /// Creates synthetic coverage data with the specified percentage.
192    /// Useful for quick test setups where exact line coverage isn't important.
193    ///
194    /// # Example
195    ///
196    /// ```rust,ignore
197    /// let env = DebtmapTestEnv::new()
198    ///     .with_coverage_percentage("src/main.rs", 75.0);  // 75% coverage
199    /// ```
200    pub fn with_coverage_percentage(self, path: impl Into<PathBuf>, percentage: f64) -> Self {
201        let mut fc = FileCoverage::new();
202        // Create 100 lines with hits matching the percentage
203        let hit_lines = (percentage as usize).min(100);
204        for i in 1..=100 {
205            fc.add_line(i, if i <= hit_lines { 1 } else { 0 });
206        }
207        self.with_coverage(path, fc)
208    }
209
210    /// Set the configuration for this environment.
211    ///
212    /// # Example
213    ///
214    /// ```rust,ignore
215    /// use debtmap::config::DebtmapConfig;
216    ///
217    /// let config = DebtmapConfig::default();
218    /// let env = DebtmapTestEnv::new()
219    ///     .with_config(config);
220    /// ```
221    pub fn with_config(mut self, config: DebtmapConfig) -> Self {
222        self.config = config;
223        self
224    }
225
226    /// Add a cache entry with raw bytes.
227    ///
228    /// # Example
229    ///
230    /// ```rust,ignore
231    /// let env = DebtmapTestEnv::new()
232    ///     .with_cache_entry("key", b"value");
233    /// ```
234    pub fn with_cache_entry(self, key: impl Into<String>, value: impl AsRef<[u8]>) -> Self {
235        self.cache
236            .write()
237            .expect("Lock poisoned")
238            .insert(key.into(), value.as_ref().to_vec());
239        self
240    }
241
242    /// Check if a file exists in the mock file system.
243    pub fn has_file(&self, path: impl AsRef<Path>) -> bool {
244        self.files
245            .read()
246            .expect("Lock poisoned")
247            .contains_key(path.as_ref())
248    }
249
250    /// Get all file paths in the mock file system.
251    pub fn file_paths(&self) -> Vec<PathBuf> {
252        self.files
253            .read()
254            .expect("Lock poisoned")
255            .keys()
256            .cloned()
257            .collect()
258    }
259
260    /// Clear all files from the mock file system.
261    pub fn clear_files(&self) {
262        self.files.write().expect("Lock poisoned").clear();
263    }
264
265    /// Clear all coverage data.
266    pub fn clear_coverage(&self) {
267        *self.coverage.write().expect("Lock poisoned") = CoverageData::new();
268    }
269
270    /// Clear all cache entries.
271    pub fn clear_cache(&self) {
272        self.cache.write().expect("Lock poisoned").clear();
273    }
274
275    /// Reset the environment to empty state (files, coverage, cache).
276    pub fn reset(&self) {
277        self.clear_files();
278        self.clear_coverage();
279        self.clear_cache();
280    }
281}
282
283impl Default for DebtmapTestEnv {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289impl std::fmt::Debug for DebtmapTestEnv {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        let file_count = self.files.read().map(|f| f.len()).unwrap_or(0);
292        f.debug_struct("DebtmapTestEnv")
293            .field("file_count", &file_count)
294            .field("config", &self.config)
295            .finish_non_exhaustive()
296    }
297}
298
299// Implement AnalysisEnv trait
300impl AnalysisEnv for DebtmapTestEnv {
301    fn file_system(&self) -> &dyn FileSystem {
302        self
303    }
304
305    fn coverage_loader(&self) -> &dyn CoverageLoader {
306        self
307    }
308
309    fn cache(&self) -> &dyn Cache {
310        self
311    }
312
313    fn config(&self) -> &DebtmapConfig {
314        &self.config
315    }
316
317    fn with_config(self, config: DebtmapConfig) -> Self {
318        Self { config, ..self }
319    }
320}
321
322// Implement FileSystem trait
323impl FileSystem for DebtmapTestEnv {
324    fn read_to_string(&self, path: &Path) -> Result<String, AnalysisError> {
325        self.files
326            .read()
327            .expect("Lock poisoned")
328            .get(path)
329            .cloned()
330            .ok_or_else(|| AnalysisError::io(format!("File not found: {}", path.display())))
331    }
332
333    fn write(&self, path: &Path, content: &str) -> Result<(), AnalysisError> {
334        self.files
335            .write()
336            .expect("Lock poisoned")
337            .insert(path.to_path_buf(), content.to_string());
338        Ok(())
339    }
340
341    fn exists(&self, path: &Path) -> bool {
342        self.files.read().expect("Lock poisoned").contains_key(path)
343    }
344
345    fn is_file(&self, path: &Path) -> bool {
346        self.exists(path)
347    }
348
349    fn is_dir(&self, path: &Path) -> bool {
350        // Check if any file has this path as a prefix
351        let files = self.files.read().expect("Lock poisoned");
352        files.keys().any(|file_path| {
353            file_path
354                .parent()
355                .map(|p| p.starts_with(path))
356                .unwrap_or(false)
357        })
358    }
359
360    fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, AnalysisError> {
361        self.read_to_string(path).map(|s| s.into_bytes())
362    }
363}
364
365// Implement CoverageLoader trait
366impl CoverageLoader for DebtmapTestEnv {
367    fn load_lcov(&self, _path: &Path) -> Result<CoverageData, AnalysisError> {
368        // Return the stored coverage data (ignoring path since it's mock)
369        Ok(self.coverage.read().expect("Lock poisoned").clone())
370    }
371
372    fn load_cobertura(&self, path: &Path) -> Result<CoverageData, AnalysisError> {
373        // Use same mock data for cobertura
374        self.load_lcov(path)
375    }
376}
377
378// Implement HasProgress trait
379impl HasProgress for DebtmapTestEnv {
380    fn progress(&self) -> &dyn ProgressSink {
381        &*self.progress
382    }
383}
384
385// Implement Cache trait
386impl Cache for DebtmapTestEnv {
387    fn get(&self, key: &str) -> Option<Vec<u8>> {
388        self.cache.read().expect("Lock poisoned").get(key).cloned()
389    }
390
391    fn set(&self, key: &str, value: &[u8]) -> Result<(), AnalysisError> {
392        self.cache
393            .write()
394            .expect("Lock poisoned")
395            .insert(key.to_string(), value.to_vec());
396        Ok(())
397    }
398
399    fn invalidate(&self, key: &str) -> Result<(), AnalysisError> {
400        self.cache.write().expect("Lock poisoned").remove(key);
401        Ok(())
402    }
403
404    fn clear(&self) -> Result<(), AnalysisError> {
405        self.cache.write().expect("Lock poisoned").clear();
406        Ok(())
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_new_env_is_empty() {
416        let env = DebtmapTestEnv::new();
417        assert!(!env.has_file("any.rs"));
418        assert!(env.file_paths().is_empty());
419    }
420
421    #[test]
422    fn test_with_file() {
423        let env = DebtmapTestEnv::new().with_file("test.rs", "fn main() {}");
424
425        assert!(env.has_file("test.rs"));
426        let content = env
427            .file_system()
428            .read_to_string(Path::new("test.rs"))
429            .unwrap();
430        assert_eq!(content, "fn main() {}");
431    }
432
433    #[test]
434    fn test_with_files() {
435        let env = DebtmapTestEnv::new().with_files(vec![
436            ("a.rs", "fn a() {}"),
437            ("b.rs", "fn b() {}"),
438            ("c.rs", "fn c() {}"),
439        ]);
440
441        assert_eq!(env.file_paths().len(), 3);
442        assert!(env.has_file("a.rs"));
443        assert!(env.has_file("b.rs"));
444        assert!(env.has_file("c.rs"));
445    }
446
447    #[test]
448    fn test_file_not_found() {
449        let env = DebtmapTestEnv::new();
450        let result = env.file_system().read_to_string(Path::new("missing.rs"));
451        assert!(result.is_err());
452    }
453
454    #[test]
455    fn test_write_and_read() {
456        let env = DebtmapTestEnv::new();
457        env.file_system()
458            .write(Path::new("new.rs"), "fn new() {}")
459            .unwrap();
460
461        let content = env
462            .file_system()
463            .read_to_string(Path::new("new.rs"))
464            .unwrap();
465        assert_eq!(content, "fn new() {}");
466    }
467
468    #[test]
469    fn test_coverage_percentage() {
470        let env = DebtmapTestEnv::new().with_coverage_percentage("test.rs", 75.0);
471
472        let coverage = env.coverage_loader().load_lcov(Path::new("")).unwrap();
473        let pct = coverage.get_file_coverage(Path::new("test.rs")).unwrap();
474        assert!((pct - 75.0).abs() < 1.0);
475    }
476
477    #[test]
478    fn test_cache_operations() {
479        let env = DebtmapTestEnv::new();
480
481        // Set and get
482        env.cache().set("key", b"value").unwrap();
483        assert_eq!(env.cache().get("key"), Some(b"value".to_vec()));
484
485        // Invalidate
486        env.cache().invalidate("key").unwrap();
487        assert!(env.cache().get("key").is_none());
488
489        // Set again and clear
490        env.cache().set("key1", b"v1").unwrap();
491        env.cache().set("key2", b"v2").unwrap();
492        env.cache().clear().unwrap();
493        assert!(env.cache().get("key1").is_none());
494        assert!(env.cache().get("key2").is_none());
495    }
496
497    #[test]
498    fn test_with_config() {
499        use crate::config::IgnoreConfig;
500
501        let config = DebtmapConfig {
502            ignore: Some(IgnoreConfig {
503                patterns: vec!["test".to_string()],
504            }),
505            ..Default::default()
506        };
507
508        let env = DebtmapTestEnv::new().with_config(config);
509        assert!(env.config().ignore.is_some());
510    }
511
512    #[test]
513    fn test_is_send_sync() {
514        fn assert_send_sync<T: Send + Sync>() {}
515        assert_send_sync::<DebtmapTestEnv>();
516    }
517
518    #[test]
519    fn test_is_clone() {
520        let env1 = DebtmapTestEnv::new().with_file("test.rs", "fn main() {}");
521        let env2 = env1.clone();
522
523        // Both should have the file (shared state via Arc)
524        assert!(env1.has_file("test.rs"));
525        assert!(env2.has_file("test.rs"));
526    }
527
528    #[test]
529    fn test_reset() {
530        let env = DebtmapTestEnv::new()
531            .with_file("test.rs", "fn main() {}")
532            .with_coverage_percentage("test.rs", 50.0)
533            .with_cache_entry("key", b"value");
534
535        env.reset();
536
537        assert!(!env.has_file("test.rs"));
538        assert!(env.cache().get("key").is_none());
539    }
540
541    #[test]
542    fn test_analysis_env_trait() {
543        let env = DebtmapTestEnv::new().with_file("test.rs", "fn main() {}");
544
545        // Test AnalysisEnv methods work through the trait
546        let _fs = env.file_system();
547        let _cl = env.coverage_loader();
548        let _cache = env.cache();
549        let _config = env.config();
550
551        // Test with_config
552        let env2 = env.with_config(DebtmapConfig::default());
553        assert!(env2.has_file("test.rs"));
554    }
555
556    #[test]
557    fn test_is_dir() {
558        let env = DebtmapTestEnv::new()
559            .with_file("src/main.rs", "fn main() {}")
560            .with_file("src/lib.rs", "pub fn lib() {}");
561
562        assert!(env.file_system().is_dir(Path::new("src")));
563        assert!(!env.file_system().is_dir(Path::new("other")));
564    }
565
566    #[test]
567    fn test_read_bytes() {
568        let env = DebtmapTestEnv::new().with_file("test.rs", "fn main() {}");
569
570        let bytes = env.file_system().read_bytes(Path::new("test.rs")).unwrap();
571        assert_eq!(bytes, b"fn main() {}");
572    }
573
574    #[test]
575    fn test_has_progress_silent() {
576        let env = DebtmapTestEnv::new();
577
578        // Silent progress sink should not panic
579        env.progress().start_stage("Test");
580        env.progress().report("Test", 0, 10);
581        env.progress().complete_stage("Test");
582    }
583
584    #[test]
585    fn test_with_recording_progress() {
586        let (env, recorder) = DebtmapTestEnv::with_recording_progress();
587
588        env.progress().start_stage("Analysis");
589        env.progress().report("Analysis", 5, 10);
590        env.progress().complete_stage("Analysis");
591
592        assert_eq!(recorder.stages(), vec!["Analysis"]);
593        assert_eq!(recorder.completed_stages(), vec!["Analysis"]);
594        assert_eq!(recorder.event_count(), 3);
595    }
596
597    #[test]
598    fn test_with_progress_custom() {
599        let recorder = Arc::new(RecordingProgressSink::new());
600        let env = DebtmapTestEnv::new().with_progress(recorder.clone());
601
602        env.progress().start_stage("Custom");
603        assert_eq!(recorder.stages(), vec!["Custom"]);
604    }
605}