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::{debug, 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/// Represents a repository execution context.
65#[derive(Debug)]
66pub struct RepoContext {
67    /// Unique context ID
68    pub context_id: String,
69    /// Repository root path
70    pub repo_path: PathBuf,
71    /// Python interpreter path
72    pub python_path: PathBuf,
73    /// Test inventory (node_id -> TestNode)
74    inventory: Arc<Mutex<HashMap<String, TestNode>>>,
75    /// Inventory hash for cache validation
76    pub inventory_hash: String,
77    /// Duration history (node_id -> list of durations)
78    duration_history: Arc<Mutex<HashMap<String, Vec<u64>>>>,
79    /// Outcome history (node_id -> list of outcome strings)
80    outcome_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
81    /// Scheduler for test ordering
82    scheduler: Arc<Mutex<TestScheduler>>,
83    /// Test executor (supports both embedded and subprocess modes)
84    executor: Arc<PLMutex<Box<dyn TestExecutor>>>,
85    /// Execution mode being used
86    pub execution_mode: ExecutionMode,
87    /// Native test collector
88    native_collector: NativeCollector,
89    /// Flakiness tracker
90    flakiness_tracker: Arc<Mutex<FlakinessTracker>>,
91    /// Fixture manager (planned feature)
92    #[allow(dead_code)]
93    fixture_manager: Arc<Mutex<FixtureManager>>,
94    /// Re-run configuration (planned feature)
95    #[allow(dead_code)]
96    rerun_config: RerunConfig,
97    /// Storage backend
98    storage: Option<DaemonStorage>,
99    /// Use native collection
100    use_native: bool,
101    /// Collection time
102    pub last_collection_time: f64,
103    /// Total runs
104    total_runs: u32,
105    /// Whether we're in hybrid auto mode (embedded first, then pooled)
106    hybrid_auto_mode: bool,
107    /// Pending pooled executor being spawned in background
108    pending_pooled: Arc<tokio::sync::Mutex<Option<Box<dyn TestExecutor>>>>,
109    /// Whether pooled executor is ready
110    pooled_ready: Arc<std::sync::atomic::AtomicBool>,
111}
112
113impl RepoContext {
114    /// Create a new context.
115    ///
116    /// # Arguments
117    /// * `context_id` - Unique identifier for this context
118    /// * `repo_path` - Path to the repository root
119    /// * `python_path` - Optional path to Python interpreter (auto-detected if None)
120    /// * `storage` - Optional storage backend for persistence
121    /// * `execution_mode` - Execution mode (Embedded, Subprocess, Pooled, or Auto)
122    pub async fn new(
123        context_id: &str,
124        repo_path: &Path,
125        python_path: Option<PathBuf>,
126        storage: Option<DaemonStorage>,
127        execution_mode: ExecutionMode,
128    ) -> Result<Self> {
129        let python_path = python_path.unwrap_or_else(|| find_python_with_pytest(repo_path));
130
131        let storage_path = repo_path.join(".rpytest");
132
133        let flakiness_tracker = FlakinessTracker::new(Some(storage_path.join("flakiness.json")));
134
135        // Create executor based on execution mode
136        // For Auto mode, use hybrid strategy: embedded first (fast cold start), then pooled (fast warm runs)
137        let (executor, actual_mode, hybrid_auto): (Box<dyn TestExecutor>, ExecutionMode, bool) = match execution_mode {
138            ExecutionMode::Pooled => {
139                // Pooled mode: create async worker pool with repo_path as working directory
140                let worker_count = num_cpus::get();
141                info!("Creating pooled executor with {} workers in {}", worker_count, repo_path.display());
142                let executor = create_pooled_executor(python_path.clone(), Some(worker_count), repo_path.to_path_buf()).await?;
143                (executor, ExecutionMode::Pooled, false)
144            }
145            ExecutionMode::Auto => {
146                // Hybrid auto mode: start with fastest available cold-start executor
147                // Will switch to pooled after first run for fast warm runs
148                #[cfg(feature = "embedded-python")]
149                {
150                    if crate::embedded::EmbeddedExecutor::is_available() {
151                        match crate::embedded::EmbeddedExecutor::new(Some(python_path.clone())) {
152                            Ok(executor) => {
153                                info!("Hybrid auto mode: starting with embedded executor (will switch to pooled after first run)");
154                                (Box::new(executor) as Box<dyn TestExecutor>, ExecutionMode::Embedded, true)
155                            }
156                            Err(e) => {
157                                info!("Embedded unavailable ({}), using subprocess with hybrid auto", e);
158                                let executor = create_executor(ExecutionMode::Subprocess, python_path.clone())?;
159                                // Still enable hybrid mode - will switch to pooled after first run
160                                (executor, ExecutionMode::Subprocess, true)
161                            }
162                        }
163                    } else {
164                        info!("Embedded Python not available, using subprocess with hybrid auto");
165                        let executor = create_executor(ExecutionMode::Subprocess, python_path.clone())?;
166                        // Still enable hybrid mode - will switch to pooled after first run
167                        (executor, ExecutionMode::Subprocess, true)
168                    }
169                }
170                #[cfg(not(feature = "embedded-python"))]
171                {
172                    info!("Embedded Python feature not enabled, using subprocess with hybrid auto");
173                    let executor = create_executor(ExecutionMode::Subprocess, python_path.clone())?;
174                    // Still enable hybrid mode - will switch to pooled after first run
175                    (executor, ExecutionMode::Subprocess, true)
176                }
177            }
178            other => {
179                // Other modes: use sync creation
180                let executor = create_executor(other, python_path.clone())?;
181                let mode = match executor.execution_mode() {
182                    "embedded" => ExecutionMode::Embedded,
183                    "pooled" => ExecutionMode::Pooled,
184                    _ => ExecutionMode::Subprocess,
185                };
186                (executor, mode, false)
187            }
188        };
189
190        info!(
191            "Created context {} with {} executor{}",
192            context_id,
193            executor.execution_mode(),
194            if hybrid_auto { " (hybrid auto)" } else { "" }
195        );
196
197        Ok(RepoContext {
198            context_id: context_id.to_string(),
199            repo_path: repo_path.to_path_buf(),
200            python_path,
201            inventory: Arc::new(Mutex::new(HashMap::new())),
202            inventory_hash: String::new(),
203            duration_history: Arc::new(Mutex::new(HashMap::new())),
204            outcome_history: Arc::new(Mutex::new(HashMap::new())),
205            scheduler: Arc::new(Mutex::new(TestScheduler::new())),
206            executor: Arc::new(PLMutex::new(executor)),
207            execution_mode: actual_mode,
208            native_collector: NativeCollector::new(repo_path),
209            flakiness_tracker: Arc::new(Mutex::new(flakiness_tracker)),
210            fixture_manager: Arc::new(Mutex::new(FixtureManager::new())),
211            rerun_config: RerunConfig::default(),
212            storage,
213            use_native: true,
214            last_collection_time: 0.0,
215            total_runs: 0,
216            hybrid_auto_mode: hybrid_auto,
217            pending_pooled: Arc::new(tokio::sync::Mutex::new(None)),
218            pooled_ready: Arc::new(std::sync::atomic::AtomicBool::new(false)),
219        })
220    }
221
222    /// Collect tests using cached inventory or in-process collection.
223    pub fn collect(&mut self, force: bool) -> Result<(usize, u64)> {
224        let start_time = SystemTime::now();
225        let start_secs = start_time
226            .duration_since(SystemTime::UNIX_EPOCH)
227            .unwrap()
228            .as_secs_f64();
229
230        // Check if we can use cached inventory
231        if !force {
232            if let Some(ref storage) = self.storage {
233                let cached_inventory = storage.get_all_inventory()?;
234                if !cached_inventory.is_empty() {
235                    let mut inventory = self.inventory.lock().unwrap();
236                    for node in cached_inventory {
237                        inventory.insert(node.node_id.clone(), node);
238                    }
239                    self.inventory_hash = self.compute_hash();
240                    self.last_collection_time = start_secs;
241                    let duration_ms =
242                        start_time.elapsed().unwrap_or(Duration::ZERO).as_millis() as u64;
243                    return Ok((inventory.len(), duration_ms));
244                }
245            }
246        }
247
248        // Collect using native collector
249        if self.use_native {
250            let native_tests = self.native_collector.collect()?;
251
252            let mut inventory = self.inventory.lock().unwrap();
253            for test in native_tests {
254                inventory.insert(
255                    test.node_id.clone(),
256                    TestNode {
257                        node_id: test.node_id,
258                        file_path: test.file_path,
259                        name: test.name,
260                        class_name: test.class_name,
261                        line_number: test.line_number,
262                        markers: test.markers,
263                        skip: test.skip,
264                        xfail: test.xfail,
265                    },
266                );
267            }
268
269            // Save to storage using batch operation (much faster than individual saves)
270            if let Some(ref storage) = self.storage {
271                storage.clear_inventory()?;
272                let nodes: Vec<TestNode> = inventory.values().cloned().collect();
273                storage.save_test_nodes_batch(&nodes)?;
274            }
275        } else {
276            // Fall back to pytest collection (not implemented in pure Rust yet)
277            warn!("Pytest collection not yet implemented in pure Rust daemon");
278        }
279
280        self.inventory_hash = self.compute_hash();
281        self.last_collection_time = start_secs;
282
283        let duration_ms = start_time.elapsed().unwrap_or(Duration::ZERO).as_millis() as u64;
284
285        info!(
286            "Collected {} tests in {}ms",
287            self.inventory.lock().unwrap().len(),
288            duration_ms
289        );
290
291        Ok((self.inventory.lock().unwrap().len(), duration_ms))
292    }
293
294    /// Get all test node IDs.
295    pub fn get_node_ids(&self) -> Vec<String> {
296        self.inventory.lock().unwrap().keys().cloned().collect()
297    }
298
299    /// Get all test nodes.
300    pub fn get_inventory(&self) -> Vec<TestNode> {
301        self.inventory.lock().unwrap().values().cloned().collect()
302    }
303
304    /// Get test node by ID.
305    pub fn get_test_node(&self, node_id: &str) -> Option<TestNode> {
306        self.inventory.lock().unwrap().get(node_id).cloned()
307    }
308
309    /// Filter tests by keyword.
310    pub fn filter_by_keyword(&self, keyword: &str) -> Vec<TestNode> {
311        if keyword.is_empty() {
312            return self.get_inventory();
313        }
314
315        self.inventory
316            .lock()
317            .unwrap()
318            .values()
319            .filter(|node| {
320                node.node_id.contains(keyword)
321                    || node.name.contains(keyword)
322                    || node.markers.iter().any(|m| m.contains(keyword))
323            })
324            .cloned()
325            .collect()
326    }
327
328    /// Filter tests by marker.
329    pub fn filter_by_marker(&self, marker: &str) -> Vec<TestNode> {
330        if marker.is_empty() {
331            return self.get_inventory();
332        }
333
334        self.inventory
335            .lock()
336            .unwrap()
337            .values()
338            .filter(|node| node.markers.iter().any(|m| m.contains(marker)))
339            .cloned()
340            .collect()
341    }
342
343    /// Run tests and return results.
344    pub async fn run_tests(
345        &mut self,
346        node_ids: &[String],
347        workers: Option<u32>,
348        maxfail: Option<u32>,
349    ) -> Result<RunSummary> {
350        self.total_runs += 1;
351
352        // Hybrid auto mode: check if pooled executor is ready and switch to it
353        if self.hybrid_auto_mode {
354            let is_ready = self.pooled_ready.load(std::sync::atomic::Ordering::SeqCst);
355            info!("Hybrid auto: run {}, pooled_ready={}, current_mode={}", self.total_runs, is_ready, self.execution_mode);
356
357            if is_ready {
358                let mut pending = self.pending_pooled.lock().await;
359                if let Some(pooled_executor) = pending.take() {
360                    info!("Hybrid auto: switching to pooled executor for faster warm runs");
361                    let mut executor = self.executor.lock();
362                    *executor = pooled_executor;
363                    self.execution_mode = ExecutionMode::Pooled;
364                    self.hybrid_auto_mode = false; // Don't check again after switching
365                } else {
366                    info!("Hybrid auto: pooled_ready was true but executor was None");
367                }
368            }
369        }
370
371        // Configure executor
372        let mut config = ExecutorConfig::new();
373        config.workers = workers;
374        config.maxfail = maxfail;
375        {
376            let mut executor = self.executor.lock();
377            executor.configure(config);
378        }
379
380        // Separate pre-skipped tests from runnable tests
381        // Tests with skip=true from collection markers should be counted as skipped without running
382        let (runnable_node_ids, pre_skipped_count): (Vec<String>, usize) = {
383            let inventory = self.inventory.lock().unwrap();
384            let mut runnable = Vec::with_capacity(node_ids.len());
385            let mut skipped_count = 0;
386
387            for node_id in node_ids {
388                if let Some(node) = inventory.get(node_id) {
389                    if node.skip {
390                        skipped_count += 1;
391                    } else {
392                        runnable.push(node_id.clone());
393                    }
394                } else {
395                    // Node not in inventory - run it anyway (might be a new test)
396                    runnable.push(node_id.clone());
397                }
398            }
399            (runnable, skipped_count)
400        };
401
402        // Update scheduler with latest durations
403        {
404            let durations: Vec<(String, u64)> = {
405                let history = self.duration_history.lock().unwrap();
406                history
407                    .iter()
408                    .filter_map(|(node_id, durations)| {
409                        durations.last().map(|d| (node_id.clone(), *d))
410                    })
411                    .collect()
412            };
413
414            let mut scheduler = self.scheduler.lock().unwrap();
415            for (node_id, duration) in durations {
416                scheduler.update_duration(&node_id, duration);
417            }
418        }
419
420        // Run tests (excluding pre-skipped ones)
421        // Clone the executor Arc to avoid holding the lock across await
422        let executor = self.executor.clone();
423        let start_time = SystemTime::now(); // Start timing BEFORE test execution
424        let results = {
425            let executor = executor.lock();
426            executor.run_tests(&runnable_node_ids).await
427        };
428
429        // Hybrid auto mode: after first run, spawn pooled workers in background
430        if self.hybrid_auto_mode && self.total_runs == 1 && !self.pooled_ready.load(std::sync::atomic::Ordering::SeqCst) {
431            let pending_pooled = self.pending_pooled.clone();
432            let pooled_ready = self.pooled_ready.clone();
433            let python_path = self.python_path.clone();
434            let repo_path = self.repo_path.clone();
435            let worker_count = num_cpus::get();
436
437            info!("Hybrid auto: spawning {} pooled workers in background for next run", worker_count);
438            tokio::spawn(async move {
439                info!("Hybrid auto: background task started, creating pooled executor...");
440                match create_pooled_executor(python_path, Some(worker_count), repo_path).await {
441                    Ok(executor) => {
442                        info!("Hybrid auto: pooled executor created, storing...");
443                        let mut pending = pending_pooled.lock().await;
444                        *pending = Some(executor);
445                        pooled_ready.store(true, std::sync::atomic::Ordering::SeqCst);
446                        info!("Hybrid auto: pooled executor ready (pooled_ready=true)");
447                    }
448                    Err(e) => {
449                        warn!("Hybrid auto: failed to create pooled executor: {}", e);
450                    }
451                }
452            });
453        }
454
455        // Process results
456        let mut passed = 0;
457        let mut failed = 0;
458        let mut skipped = 0;
459        let mut errors = 0;
460
461        for result in &results {
462            // Update duration history
463            {
464                let mut durations = self.duration_history.lock().unwrap();
465                let entry = durations.entry(result.node_id.clone()).or_default();
466                entry.push(result.duration_ms);
467                if entry.len() > 10 {
468                    *entry = entry[entry.len() - 10..].to_vec();
469                }
470            }
471
472            // Update outcome history
473            {
474                let mut outcomes = self.outcome_history.lock().unwrap();
475                let entry = outcomes.entry(result.node_id.clone()).or_default();
476                entry.push(result.outcome.clone().into());
477            }
478
479            // Update flakiness tracker
480            {
481                let mut tracker = self.flakiness_tracker.lock().unwrap();
482                tracker.record_outcome(
483                    &result.node_id,
484                    result.outcome.clone(),
485                    result.message.as_deref(),
486                );
487            }
488
489            // Update scheduler
490            {
491                let mut scheduler = self.scheduler.lock().unwrap();
492                scheduler.update_duration(&result.node_id, result.duration_ms);
493            }
494
495            // Count outcomes
496            match result.outcome {
497                TestOutcome::Passed => passed += 1,
498                TestOutcome::Failed => failed += 1,
499                TestOutcome::Skipped => skipped += 1,
500                TestOutcome::Error => errors += 1,
501                TestOutcome::Xfail => {
502                    // Expected failure that failed - don't count as passed or failed
503                    // These are "successful failures"
504                }
505                TestOutcome::Xpass => {
506                    // Expected failure that passed - don't count as passed to match pytest behavior
507                    // pytest counts xpassed separately, not as "passed"
508                }
509            }
510        }
511
512        // Add pre-skipped tests (those with skip marker from collection) to skipped count
513        skipped += pre_skipped_count;
514
515        // Save state
516        self.save_state()?;
517
518        let duration_ms = start_time.elapsed().unwrap_or(Duration::ZERO).as_millis() as u64;
519
520        Ok(RunSummary {
521            total: results.len() + pre_skipped_count,
522            passed,
523            failed,
524            skipped,
525            errors,
526            duration_ms,
527        })
528    }
529
530    /// Save context state to storage.
531    fn save_state(&self) -> Result<()> {
532        if let Some(ref storage) = self.storage {
533            // Save flakiness data (uses buffered writes)
534            let mut tracker = self.flakiness_tracker.lock().unwrap();
535            tracker.flush_if_dirty()?;
536
537            // Save duration history in a single batch operation
538            let durations = self.duration_history.lock().unwrap();
539            let histories: Vec<(&str, &[u64])> = durations
540                .iter()
541                .map(|(id, d)| (id.as_str(), d.as_slice()))
542                .collect();
543            storage.save_duration_history_batch(&histories)?;
544        }
545        Ok(())
546    }
547
548    /// Compute inventory hash.
549    fn compute_hash(&self) -> String {
550        let inventory = self.inventory.lock().unwrap();
551        let mut ids: Vec<&String> = inventory.keys().collect();
552        ids.sort();
553
554        let mut hasher = Sha256::default();
555        for id in ids {
556            hasher.update(id.as_bytes());
557        }
558
559        hex::encode(hasher.finalize())
560    }
561
562    /// Get scheduler status.
563    pub fn get_scheduler_status(&self) -> serde_json::Value {
564        let scheduler = self.scheduler.lock().unwrap();
565        serde_json::json!({
566            "tracked_tests": scheduler.tracked_count(),
567            "default_duration_ms": scheduler.default_duration_ms,
568        })
569    }
570
571    /// Get flakiness report.
572    pub fn get_flakiness_report(&self) -> serde_json::Value {
573        let tracker = self.flakiness_tracker.lock().unwrap();
574        let flaky = tracker.get_flaky_tests();
575        let unstable = tracker.get_unstable_tests();
576
577        serde_json::json!({
578            "flaky_tests": flaky.iter().map(|r| self.serialize_flakiness_record(r)).collect::<Vec<_>>(),
579            "unstable_tests": unstable.iter().map(|r| self.serialize_flakiness_record(r)).collect::<Vec<_>>(),
580            "stable_count": tracker.stable_count(),
581            "total_tracked": tracker.total_tracked(),
582        })
583    }
584
585    fn serialize_flakiness_record(
586        &self,
587        record: &crate::models::FlakinessRecord,
588    ) -> serde_json::Value {
589        serde_json::json!({
590            "node_id": record.node_id,
591            "failure_rate": record.outcomes.iter().filter(|o| *o == "failed" || *o == "error").count() as f64 / record.outcomes.len() as f64,
592            "is_flaky": record.flaky_streak >= 2 && record.outcomes.iter().any(|o| *o == "passed"),
593            "flaky_streak": record.flaky_streak,
594            "consecutive_failures": record.consecutive_failures,
595            "consecutive_passes": record.consecutive_passes,
596            "total_runs": record.total_runs,
597            "recent_outcomes": record.outcomes.clone(),
598        })
599    }
600}