1use 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
20fn find_python_with_pytest(repo_path: &Path) -> PathBuf {
22 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 let local_venv = repo_path.join(".venv").join("bin").join("python");
32 if local_venv.exists() {
33 return local_venv;
34 }
35
36 let venv_dir = repo_path.join("venv").join("bin").join("python");
38 if venv_dir.exists() {
39 return venv_dir;
40 }
41
42 if let Ok(python_path) = std::env::var("PYTHON_PATH") {
44 return PathBuf::from(python_path);
45 }
46
47 PathBuf::from("python3")
49}
50
51#[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#[derive(Debug)]
66pub struct RepoContext {
67 pub context_id: String,
69 pub repo_path: PathBuf,
71 pub python_path: PathBuf,
73 inventory: Arc<Mutex<HashMap<String, TestNode>>>,
75 pub inventory_hash: String,
77 duration_history: Arc<Mutex<HashMap<String, Vec<u64>>>>,
79 outcome_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
81 scheduler: Arc<Mutex<TestScheduler>>,
83 executor: Arc<PLMutex<Box<dyn TestExecutor>>>,
85 pub execution_mode: ExecutionMode,
87 native_collector: NativeCollector,
89 flakiness_tracker: Arc<Mutex<FlakinessTracker>>,
91 #[allow(dead_code)]
93 fixture_manager: Arc<Mutex<FixtureManager>>,
94 #[allow(dead_code)]
96 rerun_config: RerunConfig,
97 storage: Option<DaemonStorage>,
99 use_native: bool,
101 pub last_collection_time: f64,
103 total_runs: u32,
105 hybrid_auto_mode: bool,
107 pending_pooled: Arc<tokio::sync::Mutex<Option<Box<dyn TestExecutor>>>>,
109 pooled_ready: Arc<std::sync::atomic::AtomicBool>,
111}
112
113impl RepoContext {
114 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 let (executor, actual_mode, hybrid_auto): (Box<dyn TestExecutor>, ExecutionMode, bool) = match execution_mode {
138 ExecutionMode::Pooled => {
139 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 #[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 (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 (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 (executor, ExecutionMode::Subprocess, true)
176 }
177 }
178 other => {
179 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 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 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 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 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 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 pub fn get_node_ids(&self) -> Vec<String> {
296 self.inventory.lock().unwrap().keys().cloned().collect()
297 }
298
299 pub fn get_inventory(&self) -> Vec<TestNode> {
301 self.inventory.lock().unwrap().values().cloned().collect()
302 }
303
304 pub fn get_test_node(&self, node_id: &str) -> Option<TestNode> {
306 self.inventory.lock().unwrap().get(node_id).cloned()
307 }
308
309 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 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 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 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; } else {
366 info!("Hybrid auto: pooled_ready was true but executor was None");
367 }
368 }
369 }
370
371 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 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 runnable.push(node_id.clone());
397 }
398 }
399 (runnable, skipped_count)
400 };
401
402 {
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 let executor = self.executor.clone();
423 let start_time = SystemTime::now(); let results = {
425 let executor = executor.lock();
426 executor.run_tests(&runnable_node_ids).await
427 };
428
429 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 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 {
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 {
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 {
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 {
491 let mut scheduler = self.scheduler.lock().unwrap();
492 scheduler.update_duration(&result.node_id, result.duration_ms);
493 }
494
495 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 }
505 TestOutcome::Xpass => {
506 }
509 }
510 }
511
512 skipped += pre_skipped_count;
514
515 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 fn save_state(&self) -> Result<()> {
532 if let Some(ref storage) = self.storage {
533 let mut tracker = self.flakiness_tracker.lock().unwrap();
535 tracker.flush_if_dirty()?;
536
537 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 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 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 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}