Skip to main content

rpytest_daemon/
context.rs

1//! Repository context management - handles inventory, collection, and state.
2
3use crate::collector::NativeCollector;
4use crate::error::Result;
5use crate::executor::{create_executor, create_pooled_executor, ExecutorConfig, TestExecutor};
6use crate::fixtures::FixtureManager;
7use crate::flakiness::FlakinessTracker;
8use crate::models::{ExecutionMode, RerunConfig, RunSummary, TestNode, TestOutcome};
9use crate::scheduler::TestScheduler;
10use crate::storage::DaemonStorage;
11use parking_lot::Mutex as PLMutex;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex};
17use std::time::{Duration, SystemTime};
18use tracing::{info, warn};
19
20/// Find a Python interpreter that has pytest installed.
21fn find_python_with_pytest(repo_path: &Path) -> PathBuf {
22    // 1. Check VIRTUAL_ENV environment variable
23    if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
24        let venv_python = PathBuf::from(&venv).join("bin").join("python");
25        if venv_python.exists() {
26            return venv_python;
27        }
28    }
29
30    // 2. Check for local .venv directory in repo
31    let local_venv = repo_path.join(".venv").join("bin").join("python");
32    if local_venv.exists() {
33        return local_venv;
34    }
35
36    // 3. Check for venv directory in repo
37    let venv_dir = repo_path.join("venv").join("bin").join("python");
38    if venv_dir.exists() {
39        return venv_dir;
40    }
41
42    // 4. Check PYTHON_PATH env var
43    if let Ok(python_path) = std::env::var("PYTHON_PATH") {
44        return PathBuf::from(python_path);
45    }
46
47    // 5. Fall back to python3
48    PathBuf::from("python3")
49}
50
51/// Represents a single test node.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct TestNodeInternal {
54    pub node_id: String,
55    pub file_path: String,
56    pub name: String,
57    pub class_name: Option<String>,
58    pub line_number: u32,
59    pub markers: Vec<String>,
60    pub skip: bool,
61    pub xfail: bool,
62}
63
64/// Execution state for the repository context.
65///
66/// Currently only supports fixed executor modes. Hybrid auto-switching to
67/// pooled mode was removed because it caused test isolation failures.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ExecutionState {
70    /// Running with a single fixed executor mode.
71    Fixed { mode: ExecutionMode },
72}
73
74impl ExecutionState {
75    /// Returns true if the state is using the pooled executor.
76    pub fn is_pooled(&self) -> bool {
77        matches!(
78            self,
79            ExecutionState::Fixed {
80                mode: ExecutionMode::Pooled
81            }
82        )
83    }
84
85    /// Returns the current execution mode for display/logging purposes.
86    pub fn current_mode(&self) -> ExecutionMode {
87        match self {
88            ExecutionState::Fixed { mode } => *mode,
89        }
90    }
91}
92
93/// Represents a repository execution context.
94#[derive(Debug)]
95pub struct RepoContext {
96    /// Unique context ID
97    pub context_id: String,
98    /// Repository root path
99    pub repo_path: PathBuf,
100    /// Python interpreter path
101    pub python_path: PathBuf,
102    /// Test inventory (node_id -> TestNode)
103    inventory: Arc<Mutex<HashMap<String, TestNode>>>,
104    /// Inventory hash for cache validation
105    pub inventory_hash: String,
106    /// Duration history (node_id -> list of durations)
107    duration_history: Arc<Mutex<HashMap<String, Vec<u64>>>>,
108    /// Outcome history (node_id -> list of outcome strings)
109    outcome_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
110    /// Scheduler for test ordering
111    scheduler: Arc<Mutex<TestScheduler>>,
112    /// Test executor (supports both embedded and subprocess modes)
113    executor: Arc<PLMutex<Box<dyn TestExecutor>>>,
114    /// Execution state machine
115    execution_state: ExecutionState,
116    /// Native test collector
117    native_collector: NativeCollector,
118    /// Flakiness tracker
119    flakiness_tracker: Arc<Mutex<FlakinessTracker>>,
120    /// Fixture manager (planned feature)
121    #[allow(dead_code)]
122    fixture_manager: Arc<Mutex<FixtureManager>>,
123    /// Re-run configuration (planned feature)
124    #[allow(dead_code)]
125    rerun_config: RerunConfig,
126    /// Storage backend
127    storage: Option<DaemonStorage>,
128    /// Use native collection
129    use_native: bool,
130    /// Collection time
131    pub last_collection_time: f64,
132    /// Total runs
133    total_runs: u32,
134}
135
136impl RepoContext {
137    /// Create a new context.
138    ///
139    /// # Arguments
140    /// * `context_id` - Unique identifier for this context
141    /// * `repo_path` - Path to the repository root
142    /// * `python_path` - Optional path to Python interpreter (auto-detected if None)
143    /// * `storage` - Optional storage backend for persistence
144    /// * `execution_mode` - Execution mode (Embedded, Subprocess, Pooled, or Auto)
145    pub async fn new(
146        context_id: &str,
147        repo_path: &Path,
148        python_path: Option<PathBuf>,
149        storage: Option<DaemonStorage>,
150        execution_mode: ExecutionMode,
151    ) -> Result<Self> {
152        let python_path = python_path.unwrap_or_else(|| find_python_with_pytest(repo_path));
153
154        let storage_path = repo_path.join(".rpytest");
155
156        let flakiness_tracker = FlakinessTracker::new(Some(storage_path.join("flakiness.json")));
157
158        // Create executor and determine initial execution state
159        let (executor, execution_state): (Box<dyn TestExecutor>, ExecutionState) =
160            match execution_mode {
161                ExecutionMode::Pooled => {
162                    let worker_count = num_cpus::get();
163                    info!(
164                        "Creating pooled executor with {} workers in {}",
165                        worker_count,
166                        repo_path.display()
167                    );
168                    let executor = create_pooled_executor(
169                        python_path.clone(),
170                        Some(worker_count),
171                        repo_path.to_path_buf(),
172                    )
173                    .await?;
174                    (
175                        executor,
176                        ExecutionState::Fixed {
177                            mode: ExecutionMode::Pooled,
178                        },
179                    )
180                }
181                ExecutionMode::Auto => {
182                    // Auto mode: use subprocess for reliable test isolation.
183                    // Pooled mode is available via --execution-mode pooled for projects
184                    // that don't share state between workers.
185                    info!(
186                        "Auto mode: using subprocess executor (use --execution-mode pooled for warm workers)"
187                    );
188                    let executor = create_executor(ExecutionMode::Subprocess, python_path.clone())?;
189                    (
190                        executor,
191                        ExecutionState::Fixed {
192                            mode: ExecutionMode::Subprocess,
193                        },
194                    )
195                }
196                other => {
197                    let executor = create_executor(other, python_path.clone())?;
198                    let mode = match executor.execution_mode() {
199                        "embedded" => ExecutionMode::Embedded,
200                        "pooled" => ExecutionMode::Pooled,
201                        _ => ExecutionMode::Subprocess,
202                    };
203                    (executor, ExecutionState::Fixed { mode })
204                }
205            };
206
207        info!(
208            "Created context {} with {} executor (state: {:?})",
209            context_id,
210            executor.execution_mode(),
211            execution_state
212        );
213
214        Ok(RepoContext {
215            context_id: context_id.to_string(),
216            repo_path: repo_path.to_path_buf(),
217            python_path,
218            inventory: Arc::new(Mutex::new(HashMap::new())),
219            inventory_hash: String::new(),
220            duration_history: Arc::new(Mutex::new(HashMap::new())),
221            outcome_history: Arc::new(Mutex::new(HashMap::new())),
222            scheduler: Arc::new(Mutex::new(TestScheduler::new())),
223            executor: Arc::new(PLMutex::new(executor)),
224            execution_state,
225            native_collector: NativeCollector::new(repo_path),
226            flakiness_tracker: Arc::new(Mutex::new(flakiness_tracker)),
227            fixture_manager: Arc::new(Mutex::new(FixtureManager::new())),
228            rerun_config: RerunConfig::default(),
229            storage,
230            use_native: true,
231            last_collection_time: 0.0,
232            total_runs: 0,
233        })
234    }
235
236    /// Collect tests using cached inventory or in-process collection.
237    pub fn collect(&mut self, force: bool) -> Result<(usize, u64)> {
238        let start_time = SystemTime::now();
239        let start_secs = start_time
240            .duration_since(SystemTime::UNIX_EPOCH)
241            .unwrap()
242            .as_secs_f64();
243
244        // Check if we can use cached inventory
245        if !force {
246            if let Some(ref storage) = self.storage {
247                let cached_inventory = storage.get_all_inventory()?;
248                if !cached_inventory.is_empty() {
249                    let mut inventory = self.inventory.lock().unwrap();
250                    for node in cached_inventory {
251                        inventory.insert(node.node_id.clone(), node);
252                    }
253                    self.inventory_hash = self.compute_hash();
254                    self.last_collection_time = start_secs;
255                    let duration_ms =
256                        start_time.elapsed().unwrap_or(Duration::ZERO).as_millis() as u64;
257                    return Ok((inventory.len(), duration_ms));
258                }
259            }
260        }
261
262        // Collect using native collector
263        if self.use_native {
264            let native_tests = self.native_collector.collect()?;
265
266            let mut inventory = self.inventory.lock().unwrap();
267            for test in native_tests {
268                inventory.insert(
269                    test.node_id.clone(),
270                    TestNode {
271                        node_id: test.node_id,
272                        file_path: test.file_path,
273                        name: test.name,
274                        class_name: test.class_name,
275                        line_number: test.line_number,
276                        markers: test.markers,
277                        skip: test.skip,
278                        xfail: test.xfail,
279                    },
280                );
281            }
282
283            // Save to storage using batch operation (much faster than individual saves)
284            if let Some(ref storage) = self.storage {
285                storage.clear_inventory()?;
286                let nodes: Vec<TestNode> = inventory.values().cloned().collect();
287                storage.save_test_nodes_batch(&nodes)?;
288            }
289        } else {
290            // Fall back to pytest collection (not implemented in pure Rust yet)
291            warn!("Pytest collection not yet implemented in pure Rust daemon");
292        }
293
294        self.inventory_hash = self.compute_hash();
295        self.last_collection_time = start_secs;
296
297        let duration_ms = start_time.elapsed().unwrap_or(Duration::ZERO).as_millis() as u64;
298
299        info!(
300            "Collected {} tests in {}ms",
301            self.inventory.lock().unwrap().len(),
302            duration_ms
303        );
304
305        Ok((self.inventory.lock().unwrap().len(), duration_ms))
306    }
307
308    /// Get all test node IDs.
309    pub fn get_node_ids(&self) -> Vec<String> {
310        self.inventory.lock().unwrap().keys().cloned().collect()
311    }
312
313    /// Get all test nodes.
314    pub fn get_inventory(&self) -> Vec<TestNode> {
315        self.inventory.lock().unwrap().values().cloned().collect()
316    }
317
318    /// Get test node by ID.
319    pub fn get_test_node(&self, node_id: &str) -> Option<TestNode> {
320        self.inventory.lock().unwrap().get(node_id).cloned()
321    }
322
323    /// Filter tests by keyword.
324    pub fn filter_by_keyword(&self, keyword: &str) -> Vec<TestNode> {
325        if keyword.is_empty() {
326            return self.get_inventory();
327        }
328
329        self.inventory
330            .lock()
331            .unwrap()
332            .values()
333            .filter(|node| {
334                node.node_id.contains(keyword)
335                    || node.name.contains(keyword)
336                    || node.markers.iter().any(|m| m.contains(keyword))
337            })
338            .cloned()
339            .collect()
340    }
341
342    /// Filter tests by marker.
343    pub fn filter_by_marker(&self, marker: &str) -> Vec<TestNode> {
344        if marker.is_empty() {
345            return self.get_inventory();
346        }
347
348        self.inventory
349            .lock()
350            .unwrap()
351            .values()
352            .filter(|node| node.markers.iter().any(|m| m.contains(marker)))
353            .cloned()
354            .collect()
355    }
356
357    /// Run tests and return results.
358    pub async fn run_tests(
359        &mut self,
360        node_ids: &[String],
361        workers: Option<u32>,
362        maxfail: Option<u32>,
363    ) -> Result<RunSummary> {
364        self.total_runs += 1;
365
366        // Configure executor
367        let mut config = ExecutorConfig::new();
368        config.workers = workers;
369        config.maxfail = maxfail;
370        {
371            let mut executor = self.executor.lock();
372            executor.configure(config);
373        }
374
375        // Separate pre-skipped tests from runnable tests
376        let (runnable_node_ids, pre_skipped_count): (Vec<String>, usize) = {
377            let inventory = self.inventory.lock().unwrap();
378            let mut runnable = Vec::with_capacity(node_ids.len());
379            let mut skipped_count = 0;
380
381            for node_id in node_ids {
382                if let Some(node) = inventory.get(node_id) {
383                    if node.skip {
384                        skipped_count += 1;
385                    } else {
386                        runnable.push(node_id.clone());
387                    }
388                } else {
389                    // Node not in inventory - run it anyway (might be a new test)
390                    runnable.push(node_id.clone());
391                }
392            }
393            (runnable, skipped_count)
394        };
395
396        // Update scheduler with latest durations
397        {
398            let durations: Vec<(String, u64)> = {
399                let history = self.duration_history.lock().unwrap();
400                history
401                    .iter()
402                    .filter_map(|(node_id, durations)| {
403                        durations.last().map(|d| (node_id.clone(), *d))
404                    })
405                    .collect()
406            };
407
408            let mut scheduler = self.scheduler.lock().unwrap();
409            for (node_id, duration) in durations {
410                scheduler.update_duration(&node_id, duration);
411            }
412        }
413
414        // Run tests (excluding pre-skipped ones)
415        let executor = self.executor.clone();
416        let start_time = SystemTime::now();
417        let results = {
418            let executor = executor.lock();
419            executor.run_tests(&runnable_node_ids).await
420        };
421
422        // Process results
423        let mut passed = 0;
424        let mut failed = 0;
425        let mut skipped = 0;
426        let mut errors = 0;
427
428        for result in &results {
429            // Update duration history
430            {
431                let mut durations = self.duration_history.lock().unwrap();
432                let entry = durations.entry(result.node_id.clone()).or_default();
433                entry.push(result.duration_ms);
434                if entry.len() > 10 {
435                    *entry = entry[entry.len() - 10..].to_vec();
436                }
437            }
438
439            // Update outcome history
440            {
441                let mut outcomes = self.outcome_history.lock().unwrap();
442                let entry = outcomes.entry(result.node_id.clone()).or_default();
443                entry.push(result.outcome.clone().into());
444            }
445
446            // Update flakiness tracker
447            {
448                let mut tracker = self.flakiness_tracker.lock().unwrap();
449                tracker.record_outcome(
450                    &result.node_id,
451                    result.outcome.clone(),
452                    result.message.as_deref(),
453                );
454            }
455
456            // Update scheduler
457            {
458                let mut scheduler = self.scheduler.lock().unwrap();
459                scheduler.update_duration(&result.node_id, result.duration_ms);
460            }
461
462            // Count outcomes
463            match result.outcome {
464                TestOutcome::Passed => passed += 1,
465                TestOutcome::Failed => failed += 1,
466                TestOutcome::Skipped => skipped += 1,
467                TestOutcome::Error => errors += 1,
468                TestOutcome::Xfail => {
469                    // Expected failure that failed - don't count as passed or failed
470                }
471                TestOutcome::Xpass => {
472                    // Expected failure that passed - don't count as passed to match pytest behavior
473                }
474            }
475        }
476
477        // Add pre-skipped tests (those with skip marker from collection) to skipped count
478        skipped += pre_skipped_count;
479
480        // Save state
481        self.save_state()?;
482
483        let duration_ms = start_time.elapsed().unwrap_or(Duration::ZERO).as_millis() as u64;
484
485        Ok(RunSummary {
486            total: results.len() + pre_skipped_count,
487            passed,
488            failed,
489            skipped,
490            errors,
491            duration_ms,
492        })
493    }
494
495    /// Save context state to storage.
496    fn save_state(&self) -> Result<()> {
497        if let Some(ref storage) = self.storage {
498            // Save flakiness data (uses buffered writes)
499            let mut tracker = self.flakiness_tracker.lock().unwrap();
500            tracker.flush_if_dirty()?;
501
502            // Save duration history in a single batch operation
503            let durations = self.duration_history.lock().unwrap();
504            let histories: Vec<(&str, &[u64])> = durations
505                .iter()
506                .map(|(id, d)| (id.as_str(), d.as_slice()))
507                .collect();
508            storage.save_duration_history_batch(&histories)?;
509        }
510        Ok(())
511    }
512
513    /// Compute inventory hash.
514    fn compute_hash(&self) -> String {
515        let inventory = self.inventory.lock().unwrap();
516        let mut ids: Vec<&String> = inventory.keys().collect();
517        ids.sort();
518
519        let mut hasher = Sha256::default();
520        for id in ids {
521            hasher.update(id.as_bytes());
522        }
523
524        hex::encode(hasher.finalize())
525    }
526
527    /// Get scheduler status.
528    pub fn get_scheduler_status(&self) -> serde_json::Value {
529        let scheduler = self.scheduler.lock().unwrap();
530        serde_json::json!({
531            "tracked_tests": scheduler.tracked_count(),
532            "default_duration_ms": scheduler.default_duration_ms,
533        })
534    }
535
536    /// Get flakiness report.
537    pub fn get_flakiness_report(&self) -> serde_json::Value {
538        let tracker = self.flakiness_tracker.lock().unwrap();
539        let flaky = tracker.get_flaky_tests();
540        let unstable = tracker.get_unstable_tests();
541
542        serde_json::json!({
543            "flaky_tests": flaky.iter().map(|r| self.serialize_flakiness_record(r)).collect::<Vec<_>>(),
544            "unstable_tests": unstable.iter().map(|r| self.serialize_flakiness_record(r)).collect::<Vec<_>>(),
545            "stable_count": tracker.stable_count(),
546            "total_tracked": tracker.total_tracked(),
547        })
548    }
549
550    fn serialize_flakiness_record(
551        &self,
552        record: &crate::models::FlakinessRecord,
553    ) -> serde_json::Value {
554        serde_json::json!({
555            "node_id": record.node_id,
556            "failure_rate": record.outcomes.iter().filter(|o| *o == "failed" || *o == "error").count() as f64 / record.outcomes.len() as f64,
557            "is_flaky": record.flaky_streak >= 2 && record.outcomes.iter().any(|o| *o == "passed"),
558            "flaky_streak": record.flaky_streak,
559            "consecutive_failures": record.consecutive_failures,
560            "consecutive_passes": record.consecutive_passes,
561            "total_runs": record.total_runs,
562            "recent_outcomes": record.outcomes.clone(),
563        })
564    }
565
566    /// Get the current execution state.
567    pub fn execution_state(&self) -> &ExecutionState {
568        &self.execution_state
569    }
570
571    /// Get the current execution mode.
572    pub fn execution_mode(&self) -> ExecutionMode {
573        self.execution_state.current_mode()
574    }
575}