1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use streaming_iterator::StreamingIterator;
6use tree_sitter::{Query, QueryCursor};
7
8use exspec_core::observe::{
9 BarrelReExport, FileMapping, ImportMapping, MappingStrategy, ObserveExtractor,
10 ProductionFunction,
11};
12
13use super::PythonExtractor;
14
15const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
16static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
17
18const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
19static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
20
21const RE_EXPORT_QUERY: &str = include_str!("../queries/re_export.scm");
22static RE_EXPORT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
23
24const EXPORTED_SYMBOL_QUERY: &str = include_str!("../queries/exported_symbol.scm");
25static EXPORTED_SYMBOL_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
26
27const BARE_IMPORT_ATTRIBUTE_QUERY: &str = include_str!("../queries/bare_import_attribute.scm");
28static BARE_IMPORT_ATTRIBUTE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
29
30const ASSERTION_QUERY: &str = include_str!("../queries/assertion.scm");
31static ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
32
33const ASSIGNMENT_MAPPING_QUERY: &str = include_str!("../queries/assignment_mapping.scm");
34static ASSIGNMENT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
35
36fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
37 lock.get_or_init(|| {
38 Query::new(&tree_sitter_python::LANGUAGE.into(), source).expect("invalid query")
39 })
40}
41
42pub fn test_stem(path: &str) -> Option<&str> {
51 let file_name = Path::new(path).file_name()?.to_str()?;
52 let stem = file_name.strip_suffix(".py")?;
54 if let Some(rest) = stem.strip_prefix("test_") {
56 return Some(rest);
57 }
58 if let Some(rest) = stem.strip_suffix("_test") {
60 return Some(rest);
61 }
62 if stem == "tests" {
64 let sep_pos = path.rfind('/')?;
65 let before_sep = &path[..sep_pos];
66 let parent_start = before_sep.rfind('/').map(|i| i + 1).unwrap_or(0);
67 let parent_name = &path[parent_start..sep_pos];
68 if parent_name.is_empty() {
69 return None;
70 }
71 return Some(parent_name);
72 }
73 None
74}
75
76pub fn production_stem(path: &str) -> Option<&str> {
82 let file_name = Path::new(path).file_name()?.to_str()?;
83 let stem = file_name.strip_suffix(".py")?;
84 if stem == "__init__" {
86 return None;
87 }
88 if stem == "tests" {
90 return None;
91 }
92 if stem.starts_with("test_") || stem.ends_with("_test") {
94 return None;
95 }
96 let stem = stem.strip_prefix('_').unwrap_or(stem);
97 let stem = stem.strip_suffix("__").unwrap_or(stem);
98 Some(stem)
99}
100
101pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
103 let in_test_dir = file_path
108 .split('/')
109 .any(|seg| seg == "tests" || seg == "test");
110
111 if in_test_dir {
112 return true;
113 }
114
115 let stem_only = Path::new(file_path)
119 .file_stem()
120 .and_then(|f| f.to_str())
121 .unwrap_or("");
122
123 if stem_only == "__version__" {
125 return true;
126 }
127
128 {
130 let normalized = stem_only.trim_matches('_');
131 if normalized == "types" || normalized.ends_with("_types") {
132 return true;
133 }
134 }
135
136 if stem_only == "mock" || stem_only.starts_with("mock_") {
138 return true;
139 }
140
141 if is_known_production {
142 return false;
143 }
144
145 let file_name = Path::new(file_path)
146 .file_name()
147 .and_then(|f| f.to_str())
148 .unwrap_or("");
149
150 if matches!(
152 file_name,
153 "conftest.py" | "constants.py" | "setup.py" | "__init__.py"
154 ) {
155 return true;
156 }
157
158 let parent_is_pycache = Path::new(file_path)
160 .parent()
161 .and_then(|p| p.file_name())
162 .and_then(|f| f.to_str())
163 .map(|s| s == "__pycache__")
164 .unwrap_or(false);
165
166 if parent_is_pycache {
167 return true;
168 }
169
170 false
171}
172
173fn extract_bare_import_attributes(
182 source_bytes: &[u8],
183 tree: &tree_sitter::Tree,
184 module_name: &str,
185) -> Vec<String> {
186 let query = cached_query(
187 &BARE_IMPORT_ATTRIBUTE_QUERY_CACHE,
188 BARE_IMPORT_ATTRIBUTE_QUERY,
189 );
190 let module_name_idx = query.capture_index_for_name("module_name").unwrap();
191 let attribute_name_idx = query.capture_index_for_name("attribute_name").unwrap();
192
193 let mut cursor = QueryCursor::new();
194 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
195
196 let mut attrs: Vec<String> = Vec::new();
197 while let Some(m) = matches.next() {
198 let mut mod_text = "";
199 let mut attr_text = "";
200 for cap in m.captures {
201 if cap.index == module_name_idx {
202 mod_text = cap.node.utf8_text(source_bytes).unwrap_or("");
203 } else if cap.index == attribute_name_idx {
204 attr_text = cap.node.utf8_text(source_bytes).unwrap_or("");
205 }
206 }
207 if mod_text == module_name && !attr_text.is_empty() {
208 attrs.push(attr_text.to_string());
209 }
210 }
211 attrs.sort();
212 attrs.dedup();
213 attrs
214}
215
216impl ObserveExtractor for PythonExtractor {
221 fn extract_production_functions(
222 &self,
223 source: &str,
224 file_path: &str,
225 ) -> Vec<ProductionFunction> {
226 let mut parser = Self::parser();
227 let tree = match parser.parse(source, None) {
228 Some(t) => t,
229 None => return Vec::new(),
230 };
231 let source_bytes = source.as_bytes();
232 let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
233
234 let name_idx = query.capture_index_for_name("name");
236 let class_name_idx = query.capture_index_for_name("class_name");
237 let method_name_idx = query.capture_index_for_name("method_name");
238 let decorated_name_idx = query.capture_index_for_name("decorated_name");
239 let decorated_class_name_idx = query.capture_index_for_name("decorated_class_name");
240 let decorated_method_name_idx = query.capture_index_for_name("decorated_method_name");
241
242 let fn_name_indices: [Option<u32>; 4] = [
244 name_idx,
245 method_name_idx,
246 decorated_name_idx,
247 decorated_method_name_idx,
248 ];
249 let class_name_indices: [Option<u32>; 2] = [class_name_idx, decorated_class_name_idx];
251
252 let mut cursor = QueryCursor::new();
253 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
254 let mut result = Vec::new();
255
256 while let Some(m) = matches.next() {
257 let mut fn_name: Option<String> = None;
259 let mut class_name: Option<String> = None;
260 let mut line: usize = 1;
261
262 for cap in m.captures {
263 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
264 let node_line = cap.node.start_position().row + 1;
265
266 if fn_name_indices.contains(&Some(cap.index)) {
267 fn_name = Some(text);
268 line = node_line;
269 } else if class_name_indices.contains(&Some(cap.index)) {
270 class_name = Some(text);
271 }
272 }
273
274 if let Some(name) = fn_name {
275 result.push(ProductionFunction {
276 name,
277 file: file_path.to_string(),
278 line,
279 class_name,
280 is_exported: true,
281 });
282 }
283 }
284
285 let mut seen = HashSet::new();
287 result.retain(|f| seen.insert((f.name.clone(), f.class_name.clone())));
288
289 result
290 }
291
292 fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
293 let mut parser = Self::parser();
294 let tree = match parser.parse(source, None) {
295 Some(t) => t,
296 None => return Vec::new(),
297 };
298 let source_bytes = source.as_bytes();
299 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
300
301 let module_name_idx = query.capture_index_for_name("module_name");
302 let symbol_name_idx = query.capture_index_for_name("symbol_name");
303
304 let mut cursor = QueryCursor::new();
305 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
306
307 let mut raw: Vec<(String, String, usize)> = Vec::new();
309
310 while let Some(m) = matches.next() {
311 let mut module_text: Option<String> = None;
312 let mut symbol_text: Option<String> = None;
313 let mut symbol_line: usize = 1;
314
315 for cap in m.captures {
316 if module_name_idx == Some(cap.index) {
317 module_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
318 } else if symbol_name_idx == Some(cap.index) {
319 symbol_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
320 symbol_line = cap.node.start_position().row + 1;
321 }
322 }
323
324 let (module_text, symbol_text) = match (module_text, symbol_text) {
325 (Some(m), Some(s)) => (m, s),
326 _ => continue,
327 };
328
329 let specifier_base = python_module_to_relative_specifier(&module_text);
334
335 if specifier_base.starts_with("./") || specifier_base.starts_with("../") {
337 let specifier = if specifier_base == "./"
340 && !module_text.contains('/')
341 && module_text.chars().all(|c| c == '.')
342 {
343 format!("./{symbol_text}")
344 } else {
345 specifier_base
346 };
347 raw.push((specifier, symbol_text, symbol_line));
348 }
349 }
350
351 let mut specifier_symbols: HashMap<String, Vec<(String, usize)>> = HashMap::new();
353 for (spec, sym, line) in &raw {
354 specifier_symbols
355 .entry(spec.clone())
356 .or_default()
357 .push((sym.clone(), *line));
358 }
359
360 let mut result = Vec::new();
362 for (specifier, sym_lines) in &specifier_symbols {
363 let all_symbols: Vec<String> = sym_lines.iter().map(|(s, _)| s.clone()).collect();
364 for (sym, line) in sym_lines {
365 result.push(ImportMapping {
366 symbol_name: sym.clone(),
367 module_specifier: specifier.clone(),
368 file: file_path.to_string(),
369 line: *line,
370 symbols: all_symbols.clone(),
371 });
372 }
373 }
374
375 result
376 }
377
378 fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
379 let mut parser = Self::parser();
380 let tree = match parser.parse(source, None) {
381 Some(t) => t,
382 None => return Vec::new(),
383 };
384 let source_bytes = source.as_bytes();
385 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
386
387 let module_name_idx = query.capture_index_for_name("module_name");
388 let symbol_name_idx = query.capture_index_for_name("symbol_name");
389 let import_name_idx = query.capture_index_for_name("import_name");
390
391 let mut cursor = QueryCursor::new();
392 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
393
394 let mut specifier_symbols: HashMap<String, Vec<String>> = HashMap::new();
395
396 while let Some(m) = matches.next() {
397 let mut module_text: Option<String> = None;
398 let mut symbol_text: Option<String> = None;
399 let mut import_name_parts: Vec<String> = Vec::new();
400
401 for cap in m.captures {
402 if module_name_idx == Some(cap.index) {
403 module_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
404 } else if symbol_name_idx == Some(cap.index) {
405 symbol_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
406 } else if import_name_idx == Some(cap.index) {
407 let dotted_text = cap
410 .node
411 .parent()
412 .and_then(|p| p.utf8_text(source_bytes).ok())
413 .unwrap_or_else(|| cap.node.utf8_text(source_bytes).unwrap_or(""))
414 .to_string();
415 import_name_parts.push(dotted_text);
416 }
417 }
418
419 if !import_name_parts.is_empty() {
420 import_name_parts.dedup();
423 let specifier = python_module_to_absolute_specifier(&import_name_parts[0]);
424 if !specifier.starts_with("./")
425 && !specifier.starts_with("../")
426 && !specifier.is_empty()
427 {
428 let attrs =
429 extract_bare_import_attributes(source_bytes, &tree, &import_name_parts[0]);
430 specifier_symbols.entry(specifier).or_insert_with(|| attrs);
431 }
432 continue;
433 }
434
435 let (module_text, symbol_text) = match (module_text, symbol_text) {
436 (Some(m), Some(s)) => (m, s),
437 _ => continue,
438 };
439
440 let specifier = python_module_to_absolute_specifier(&module_text);
442
443 if specifier.starts_with("./") || specifier.starts_with("../") || specifier.is_empty() {
446 continue;
447 }
448
449 specifier_symbols
450 .entry(specifier)
451 .or_default()
452 .push(symbol_text);
453 }
454
455 specifier_symbols.into_iter().collect()
456 }
457
458 fn extract_barrel_re_exports(&self, source: &str, _file_path: &str) -> Vec<BarrelReExport> {
459 let mut parser = Self::parser();
460 let tree = match parser.parse(source, None) {
461 Some(t) => t,
462 None => return Vec::new(),
463 };
464 let source_bytes = source.as_bytes();
465 let query = cached_query(&RE_EXPORT_QUERY_CACHE, RE_EXPORT_QUERY);
466
467 let from_specifier_idx = query
468 .capture_index_for_name("from_specifier")
469 .expect("@from_specifier capture not found in re_export.scm");
470 let symbol_name_idx = query.capture_index_for_name("symbol_name");
471 let wildcard_idx = query.capture_index_for_name("wildcard");
472
473 let mut cursor = QueryCursor::new();
474 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
475
476 struct ReExportEntry {
478 symbols: Vec<String>,
479 wildcard: bool,
480 }
481 let mut grouped: HashMap<String, ReExportEntry> = HashMap::new();
482
483 while let Some(m) = matches.next() {
484 let mut from_spec: Option<String> = None;
485 let mut sym: Option<String> = None;
486 let mut is_wildcard = false;
487
488 for cap in m.captures {
489 if cap.index == from_specifier_idx {
490 let raw = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
491 from_spec = Some(python_module_to_relative_specifier(&raw));
492 } else if wildcard_idx == Some(cap.index) {
493 is_wildcard = true;
494 } else if symbol_name_idx == Some(cap.index) {
495 sym = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
496 }
497 }
498
499 if let Some(spec) = from_spec {
500 if spec.starts_with("./") || spec.starts_with("../") {
502 let entry = grouped.entry(spec).or_insert(ReExportEntry {
503 symbols: Vec::new(),
504 wildcard: false,
505 });
506 if is_wildcard {
507 entry.wildcard = true;
508 }
509 if let Some(symbol) = sym {
510 if !entry.symbols.contains(&symbol) {
511 entry.symbols.push(symbol);
512 }
513 }
514 }
515 }
516 }
517
518 grouped
519 .into_iter()
520 .map(|(from_specifier, entry)| BarrelReExport {
521 symbols: entry.symbols,
522 from_specifier,
523 wildcard: entry.wildcard,
524 namespace_wildcard: false,
525 })
526 .collect()
527 }
528
529 fn source_extensions(&self) -> &[&str] {
530 &["py"]
531 }
532
533 fn index_file_names(&self) -> &[&str] {
534 &["__init__.py"]
535 }
536
537 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
538 production_stem(path)
539 }
540
541 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
542 test_stem(path)
543 }
544
545 fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
546 is_non_sut_helper(file_path, is_known_production)
547 }
548
549 fn file_exports_any_symbol(&self, file_path: &Path, symbols: &[String]) -> bool {
550 let source = match std::fs::read_to_string(file_path) {
551 Ok(s) => s,
552 Err(_) => return true, };
554
555 let mut parser = Self::parser();
556 let tree = match parser.parse(&source, None) {
557 Some(t) => t,
558 None => return true,
559 };
560 let source_bytes = source.as_bytes();
561 let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
562
563 let symbol_idx = query.capture_index_for_name("symbol");
564 let all_decl_idx = query.capture_index_for_name("all_decl");
565 let var_name_idx = query.capture_index_for_name("var_name");
566
567 let mut cursor = QueryCursor::new();
568 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
569
570 let mut all_symbols: Vec<String> = Vec::new();
571 let mut found_all = false;
572
573 while let Some(m) = matches.next() {
574 for cap in m.captures {
575 if var_name_idx == Some(cap.index) || all_decl_idx == Some(cap.index) {
577 found_all = true;
578 } else if symbol_idx == Some(cap.index) {
579 let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
580 let stripped = raw.trim_matches(|c| c == '\'' || c == '"');
581 all_symbols.push(stripped.to_string());
582 }
583 }
584 }
585
586 if !found_all {
587 return true;
589 }
590
591 symbols.iter().any(|s| all_symbols.contains(s))
593 }
594}
595
596fn python_module_to_relative_specifier(module: &str) -> String {
612 let dot_count = module.chars().take_while(|&c| c == '.').count();
614 if dot_count == 0 {
615 return module.to_string();
617 }
618
619 let rest = &module[dot_count..];
620
621 if dot_count == 1 {
622 if rest.is_empty() {
623 "./".to_string()
626 } else {
627 format!("./{rest}")
628 }
629 } else {
630 let prefix = "../".repeat(dot_count - 1);
632 if rest.is_empty() {
633 prefix
635 } else {
636 format!("{prefix}{rest}")
637 }
638 }
639}
640
641fn python_module_to_absolute_specifier(module: &str) -> String {
645 if module.starts_with('.') {
646 return python_module_to_relative_specifier(module);
648 }
649 module.replace('.', "/")
650}
651
652pub fn find_manage_py_root(scan_root: &Path) -> Option<PathBuf> {
662 if scan_root.join("manage.py").exists() {
664 return None;
665 }
666 for entry in scan_root.read_dir().ok()?.flatten() {
668 let path = entry.path();
669 if path.is_dir() && path.join("manage.py").exists() {
670 return Some(path);
671 }
672 }
673 for entry in scan_root.read_dir().ok()?.flatten() {
675 let path = entry.path();
676 if path.is_dir() {
677 for inner in path.read_dir().into_iter().flatten().flatten() {
678 let inner_path = inner.path();
679 if inner_path.is_dir() && inner_path.join("manage.py").exists() {
680 return Some(inner_path);
681 }
682 }
683 }
684 }
685 None
686}
687
688pub fn extract_assertion_referenced_imports(source: &str) -> HashSet<String> {
705 let mut parser = PythonExtractor::parser();
706 let tree = match parser.parse(source, None) {
707 Some(t) => t,
708 None => return HashSet::new(),
709 };
710 let source_bytes = source.as_bytes();
711
712 let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
714 let assertion_cap_idx = match assertion_query.capture_index_for_name("assertion") {
715 Some(idx) => idx,
716 None => return HashSet::new(),
717 };
718
719 let mut assertion_ranges: Vec<(usize, usize)> = Vec::new();
720 {
721 let mut cursor = QueryCursor::new();
722 let mut matches = cursor.matches(assertion_query, tree.root_node(), source_bytes);
723 while let Some(m) = matches.next() {
724 for cap in m.captures {
725 if cap.index == assertion_cap_idx {
726 let r = cap.node.byte_range();
727 assertion_ranges.push((r.start, r.end));
728 }
729 }
730 }
731 }
732
733 if assertion_ranges.is_empty() {
734 return HashSet::new();
735 }
736
737 let mut assertion_identifiers: HashSet<String> = HashSet::new();
739 {
740 let root = tree.root_node();
741 let mut stack = vec![root];
742 while let Some(node) = stack.pop() {
743 let nr = node.byte_range();
744 let overlaps = assertion_ranges
746 .iter()
747 .any(|&(s, e)| nr.start < e && nr.end > s);
748 if !overlaps {
749 continue;
750 }
751 if node.kind() == "identifier" {
752 if assertion_ranges
754 .iter()
755 .any(|&(s, e)| nr.start >= s && nr.end <= e)
756 {
757 if let Ok(text) = node.utf8_text(source_bytes) {
758 if !text.is_empty() {
759 assertion_identifiers.insert(text.to_string());
760 }
761 }
762 }
763 }
764 for i in 0..node.child_count() {
765 if let Some(child) = node.child(i) {
766 stack.push(child);
767 }
768 }
769 }
770 }
771
772 let assign_query = cached_query(&ASSIGNMENT_MAPPING_QUERY_CACHE, ASSIGNMENT_MAPPING_QUERY);
775 let var_idx = assign_query.capture_index_for_name("var");
776 let class_idx = assign_query.capture_index_for_name("class");
777 let source_idx = assign_query.capture_index_for_name("source");
778
779 let mut assignment_map: HashMap<String, Vec<String>> = HashMap::new();
781
782 if let (Some(var_cap), Some(class_cap), Some(source_cap)) = (var_idx, class_idx, source_idx) {
783 let mut cursor = QueryCursor::new();
784 let mut matches = cursor.matches(assign_query, tree.root_node(), source_bytes);
785 while let Some(m) = matches.next() {
786 let mut var_text = String::new();
787 let mut target_text = String::new();
788 for cap in m.captures {
789 if cap.index == var_cap {
790 var_text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
791 } else if cap.index == class_cap || cap.index == source_cap {
792 let t = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
793 if !t.is_empty() {
794 target_text = t;
795 }
796 }
797 }
798 if !var_text.is_empty() && !target_text.is_empty() && var_text != target_text {
799 assignment_map
800 .entry(var_text)
801 .or_default()
802 .push(target_text);
803 }
804 }
805 }
806
807 let mut resolved: HashSet<String> = assertion_identifiers.clone();
809 for _ in 0..2 {
810 let mut additions: HashSet<String> = HashSet::new();
811 for sym in &resolved {
812 if let Some(targets) = assignment_map.get(sym) {
813 for t in targets {
814 additions.insert(t.clone());
815 }
816 }
817 }
818 let before = resolved.len();
819 resolved.extend(additions);
820 if resolved.len() == before {
821 break;
822 }
823 }
824
825 resolved
826}
827
828fn track_new_matches(
832 all_matched: &HashSet<usize>,
833 before: &HashSet<usize>,
834 symbols: &[String],
835 idx_to_symbols: &mut HashMap<usize, HashSet<String>>,
836) {
837 for &new_idx in all_matched.difference(before) {
838 let entry = idx_to_symbols.entry(new_idx).or_default();
839 for s in symbols {
840 entry.insert(s.clone());
841 }
842 }
843}
844
845impl PythonExtractor {
846 pub fn map_test_files_with_imports(
848 &self,
849 production_files: &[String],
850 test_sources: &HashMap<String, String>,
851 scan_root: &Path,
852 l1_exclusive: bool,
853 ) -> Vec<FileMapping> {
854 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
855
856 let canonical_root_for_filter = scan_root.canonicalize().ok();
864 let filtered_production_files: Vec<String> = production_files
865 .iter()
866 .filter(|p| {
867 let check_path = if let Some(ref root) = canonical_root_for_filter {
868 if let Ok(canonical_p) = Path::new(p).canonicalize() {
869 if let Ok(rel) = canonical_p.strip_prefix(root) {
870 rel.to_string_lossy().into_owned()
871 } else {
872 p.to_string()
873 }
874 } else {
875 p.to_string()
876 }
877 } else {
878 p.to_string()
879 };
880 !is_non_sut_helper(&check_path, false)
881 })
882 .cloned()
883 .collect();
884
885 let mut mappings =
887 exspec_core::observe::map_test_files(self, &filtered_production_files, &test_file_list);
888
889 let canonical_root = match scan_root.canonicalize() {
891 Ok(r) => r,
892 Err(_) => return mappings,
893 };
894 let manage_py_root = find_manage_py_root(scan_root).and_then(|p| p.canonicalize().ok());
895 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
896 for (idx, prod) in filtered_production_files.iter().enumerate() {
897 if let Ok(canonical) = Path::new(prod).canonicalize() {
898 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
899 }
900 }
901
902 let layer1_tests_per_prod: Vec<HashSet<String>> = mappings
904 .iter()
905 .map(|m| m.test_files.iter().cloned().collect())
906 .collect();
907
908 {
912 let mut stem_to_prod_indices: HashMap<String, Vec<usize>> = HashMap::new();
914 for (idx, prod) in filtered_production_files.iter().enumerate() {
915 if let Some(pstem) = self.production_stem(prod) {
916 stem_to_prod_indices
917 .entry(pstem.to_owned())
918 .or_default()
919 .push(idx);
920 }
921 }
922
923 let l1_core_matched: HashSet<&str> = layer1_tests_per_prod
925 .iter()
926 .flat_map(|s| s.iter().map(|t| t.as_str()))
927 .collect();
928
929 for test_file in &test_file_list {
930 if l1_core_matched.contains(test_file.as_str()) {
932 continue;
933 }
934 if let Some(tstem) = self.test_stem(test_file) {
935 if let Some(prod_indices) = stem_to_prod_indices.get(tstem) {
936 if prod_indices.len() > 1 {
937 continue; }
939 for &idx in prod_indices {
940 if !mappings[idx].test_files.contains(test_file) {
941 mappings[idx].test_files.push(test_file.clone());
942 }
943 }
944 }
945 }
946 }
947 }
948
949 let layer1_extended_tests_per_prod: Vec<HashSet<String>> = mappings
951 .iter()
952 .map(|m| m.test_files.iter().cloned().collect())
953 .collect();
954
955 let l1_matched_tests: HashSet<String> = mappings
957 .iter()
958 .flat_map(|m| m.test_files.iter().cloned())
959 .collect();
960
961 let mut manage_py_only_prods: HashSet<usize> = HashSet::new();
965 for (test_file, source) in test_sources {
966 if l1_exclusive && l1_matched_tests.contains(test_file.as_str()) {
967 continue;
968 }
969 let imports = <Self as ObserveExtractor>::extract_imports(self, source, test_file);
970 let from_file = Path::new(test_file);
971 let mut all_matched = HashSet::<usize>::new();
973 let mut idx_to_symbols: HashMap<usize, HashSet<String>> = HashMap::new();
975 let mut direct_import_indices: HashSet<usize> = HashSet::new();
978
979 for import in &imports {
980 let is_bare_relative = (import.module_specifier == "./"
984 || import.module_specifier.ends_with('/'))
985 && import
986 .module_specifier
987 .trim_end_matches('/')
988 .chars()
989 .all(|c| c == '.');
990
991 let specifier = if is_bare_relative {
992 let prefix =
993 &import.module_specifier[..import.module_specifier.len().saturating_sub(1)];
994 for sym in &import.symbols {
995 let sym_specifier = format!("{prefix}/{sym}");
996 if let Some(resolved) = exspec_core::observe::resolve_import_path(
997 self,
998 &sym_specifier,
999 from_file,
1000 &canonical_root,
1001 ) {
1002 if self.is_barrel_file(&resolved)
1004 && l1_matched_tests.contains(test_file.as_str())
1005 {
1006 continue;
1007 }
1008 let sym_slice = &[sym.clone()];
1009 let before = all_matched.clone();
1010 exspec_core::observe::collect_import_matches(
1011 self,
1012 &resolved,
1013 sym_slice,
1014 &canonical_to_idx,
1015 &mut all_matched,
1016 &canonical_root,
1017 );
1018 track_new_matches(
1019 &all_matched,
1020 &before,
1021 sym_slice,
1022 &mut idx_to_symbols,
1023 );
1024 if !self.is_barrel_file(&resolved) {
1029 for &idx in all_matched.difference(&before) {
1030 direct_import_indices.insert(idx);
1031 }
1032 }
1033 }
1034 }
1035 continue;
1036 } else {
1037 import.module_specifier.clone()
1038 };
1039
1040 if let Some(resolved) = exspec_core::observe::resolve_import_path(
1041 self,
1042 &specifier,
1043 from_file,
1044 &canonical_root,
1045 ) {
1046 if self.is_barrel_file(&resolved)
1048 && l1_matched_tests.contains(test_file.as_str())
1049 {
1050 continue;
1051 }
1052 let before = all_matched.clone();
1053 exspec_core::observe::collect_import_matches(
1054 self,
1055 &resolved,
1056 &import.symbols,
1057 &canonical_to_idx,
1058 &mut all_matched,
1059 &canonical_root,
1060 );
1061 track_new_matches(&all_matched, &before, &import.symbols, &mut idx_to_symbols);
1062 let is_direct = !self.is_barrel_file(&resolved);
1064 if is_direct {
1065 for &idx in all_matched.difference(&before) {
1066 direct_import_indices.insert(idx);
1067 }
1068 }
1069 }
1070 }
1071
1072 let abs_specifiers = self.extract_all_import_specifiers(source);
1074 for (specifier, symbols) in &abs_specifiers {
1075 let base = canonical_root.join(specifier);
1076 let standard_resolved = exspec_core::observe::resolve_absolute_base_to_file(
1077 self,
1078 &base,
1079 &canonical_root,
1080 )
1081 .or_else(|| {
1082 let src_base = canonical_root.join("src").join(specifier);
1083 exspec_core::observe::resolve_absolute_base_to_file(
1084 self,
1085 &src_base,
1086 &canonical_root,
1087 )
1088 });
1089 let via_manage_py = standard_resolved.is_none() && manage_py_root.is_some();
1090 let resolved = standard_resolved.or_else(|| {
1091 if let Some(ref mpr) = manage_py_root {
1092 let django_base = mpr.join(specifier);
1093 exspec_core::observe::resolve_absolute_base_to_file(
1094 self,
1095 &django_base,
1096 &canonical_root,
1097 )
1098 } else {
1099 None
1100 }
1101 });
1102 if let Some(resolved) = resolved {
1103 if self.is_barrel_file(&resolved)
1105 && l1_matched_tests.contains(test_file.as_str())
1106 {
1107 continue;
1108 }
1109 let is_direct = !self.is_barrel_file(&resolved);
1112 let before = all_matched.clone();
1113 exspec_core::observe::collect_import_matches(
1114 self,
1115 &resolved,
1116 symbols,
1117 &canonical_to_idx,
1118 &mut all_matched,
1119 &canonical_root,
1120 );
1121 track_new_matches(&all_matched, &before, symbols, &mut idx_to_symbols);
1122 if is_direct {
1124 for &idx in all_matched.difference(&before) {
1125 direct_import_indices.insert(idx);
1126 }
1127 }
1128 if via_manage_py && is_direct {
1130 for &idx in all_matched.difference(&before) {
1131 manage_py_only_prods.insert(idx);
1132 }
1133 }
1134 }
1135 }
1136
1137 let asserted_imports = extract_assertion_referenced_imports(source);
1139 let final_indices: HashSet<usize> = if asserted_imports.is_empty() {
1140 all_matched.clone()
1142 } else {
1143 let asserted_matched: HashSet<usize> = all_matched
1145 .iter()
1146 .copied()
1147 .filter(|idx| {
1148 idx_to_symbols
1149 .get(idx)
1150 .map(|syms| syms.iter().any(|s| asserted_imports.contains(s)))
1151 .unwrap_or(false)
1152 })
1153 .collect();
1154 if asserted_matched.is_empty() {
1155 all_matched.clone()
1157 } else {
1158 let mut final_set = asserted_matched;
1160 final_set.extend(direct_import_indices.intersection(&all_matched).copied());
1161 final_set
1162 }
1163 };
1164
1165 for idx in final_indices {
1166 if !mappings[idx].test_files.contains(test_file) {
1167 mappings[idx].test_files.push(test_file.clone());
1168 }
1169 }
1170 }
1171
1172 for (i, mapping) in mappings.iter_mut().enumerate() {
1177 let has_layer1 = !layer1_extended_tests_per_prod[i].is_empty();
1178 if manage_py_only_prods.contains(&i) {
1179 mapping.strategy = MappingStrategy::ImportTracing;
1181 } else if !has_layer1 && !mapping.test_files.is_empty() {
1182 mapping.strategy = MappingStrategy::ImportTracing;
1183 }
1184 }
1185
1186 mappings
1187 }
1188}
1189
1190#[cfg(test)]
1195mod tests {
1196 use super::*;
1197 use std::path::PathBuf;
1198
1199 #[test]
1203 fn py_stem_01_test_prefix() {
1204 let extractor = PythonExtractor::new();
1208 let result = extractor.test_stem("tests/test_user.py");
1209 assert_eq!(result, Some("user"));
1210 }
1211
1212 #[test]
1216 fn py_stem_02_test_suffix() {
1217 let extractor = PythonExtractor::new();
1221 let result = extractor.test_stem("tests/user_test.py");
1222 assert_eq!(result, Some("user"));
1223 }
1224
1225 #[test]
1229 fn py_stem_03_test_prefix_multi_segment() {
1230 let extractor = PythonExtractor::new();
1234 let result = extractor.test_stem("tests/test_user_service.py");
1235 assert_eq!(result, Some("user_service"));
1236 }
1237
1238 #[test]
1242 fn py_stem_04_production_stem_regular() {
1243 let extractor = PythonExtractor::new();
1247 let result = extractor.production_stem("src/user.py");
1248 assert_eq!(result, Some("user"));
1249 }
1250
1251 #[test]
1255 fn py_stem_05_production_stem_init() {
1256 let extractor = PythonExtractor::new();
1260 let result = extractor.production_stem("src/__init__.py");
1261 assert_eq!(result, None);
1262 }
1263
1264 #[test]
1268 fn py_stem_06_production_stem_test_file() {
1269 let extractor = PythonExtractor::new();
1273 let result = extractor.production_stem("tests/test_user.py");
1274 assert_eq!(result, None);
1275 }
1276
1277 #[test]
1281 fn py_helper_01_conftest() {
1282 let extractor = PythonExtractor::new();
1286 assert!(extractor.is_non_sut_helper("tests/conftest.py", false));
1287 }
1288
1289 #[test]
1293 fn py_helper_02_constants() {
1294 let extractor = PythonExtractor::new();
1298 assert!(extractor.is_non_sut_helper("src/constants.py", false));
1299 }
1300
1301 #[test]
1305 fn py_helper_03_init() {
1306 let extractor = PythonExtractor::new();
1310 assert!(extractor.is_non_sut_helper("src/__init__.py", false));
1311 }
1312
1313 #[test]
1317 fn py_helper_04_utils_under_tests_dir() {
1318 let extractor = PythonExtractor::new();
1322 assert!(extractor.is_non_sut_helper("tests/utils.py", false));
1323 }
1324
1325 #[test]
1329 fn py_helper_05_models_is_not_helper() {
1330 let extractor = PythonExtractor::new();
1334 assert!(!extractor.is_non_sut_helper("src/models.py", false));
1335 }
1336
1337 #[test]
1341 fn py_helper_06_tests_common_helper_despite_known_production() {
1342 let extractor = PythonExtractor::new();
1346 assert!(extractor.is_non_sut_helper("tests/common.py", true));
1347 }
1348
1349 #[test]
1353 fn py_helper_07_tests_subdirectory_helper() {
1354 let extractor = PythonExtractor::new();
1358 assert!(extractor.is_non_sut_helper("tests/testserver/server.py", true));
1359 }
1360
1361 #[test]
1365 fn py_helper_08_tests_compat_helper() {
1366 let extractor = PythonExtractor::new();
1370 assert!(extractor.is_non_sut_helper("tests/compat.py", false));
1371 }
1372
1373 #[test]
1377 fn py_helper_09_deep_nested_test_dir_helper() {
1378 let extractor = PythonExtractor::new();
1382 assert!(extractor.is_non_sut_helper("tests/fixtures/data.py", false));
1383 }
1384
1385 #[test]
1389 fn py_helper_10_tests_in_filename_not_helper() {
1390 let extractor = PythonExtractor::new();
1394 assert!(!extractor.is_non_sut_helper("src/tests.py", false));
1395 }
1396
1397 #[test]
1401 fn py_helper_11_test_singular_dir_helper() {
1402 let extractor = PythonExtractor::new();
1406 assert!(extractor.is_non_sut_helper("test/helpers.py", true));
1407 }
1408
1409 #[test]
1413 fn py_barrel_01_init_is_barrel() {
1414 let extractor = PythonExtractor::new();
1418 assert!(extractor.is_barrel_file("src/mypackage/__init__.py"));
1419 }
1420
1421 #[test]
1425 fn py_func_01_top_level_function() {
1426 let source = r#"
1428def create_user():
1429 pass
1430"#;
1431 let extractor = PythonExtractor::new();
1433 let result = extractor.extract_production_functions(source, "src/users.py");
1434
1435 let func = result.iter().find(|f| f.name == "create_user");
1437 assert!(func.is_some(), "create_user not found in {:?}", result);
1438 let func = func.unwrap();
1439 assert_eq!(func.class_name, None);
1440 }
1441
1442 #[test]
1446 fn py_func_02_class_method() {
1447 let source = r#"
1449class User:
1450 def save(self):
1451 pass
1452"#;
1453 let extractor = PythonExtractor::new();
1455 let result = extractor.extract_production_functions(source, "src/models.py");
1456
1457 let method = result.iter().find(|f| f.name == "save");
1459 assert!(method.is_some(), "save not found in {:?}", result);
1460 let method = method.unwrap();
1461 assert_eq!(method.class_name, Some("User".to_string()));
1462 }
1463
1464 #[test]
1468 fn py_func_03_decorated_function() {
1469 let source = r#"
1471import functools
1472
1473def my_decorator(func):
1474 @functools.wraps(func)
1475 def wrapper(*args, **kwargs):
1476 return func(*args, **kwargs)
1477 return wrapper
1478
1479@my_decorator
1480def endpoint():
1481 pass
1482"#;
1483 let extractor = PythonExtractor::new();
1485 let result = extractor.extract_production_functions(source, "src/views.py");
1486
1487 let func = result.iter().find(|f| f.name == "endpoint");
1489 assert!(func.is_some(), "endpoint not found in {:?}", result);
1490 }
1491
1492 #[test]
1496 fn py_imp_01_relative_import_from_dot() {
1497 let source = "from .models import User\n";
1499
1500 let extractor = PythonExtractor::new();
1502 let result = extractor.extract_imports(source, "tests/test_user.py");
1503
1504 let imp = result.iter().find(|i| i.module_specifier == "./models");
1506 assert!(
1507 imp.is_some(),
1508 "import from ./models not found in {:?}",
1509 result
1510 );
1511 let imp = imp.unwrap();
1512 assert!(
1513 imp.symbols.contains(&"User".to_string()),
1514 "User not in symbols: {:?}",
1515 imp.symbols
1516 );
1517 }
1518
1519 #[test]
1523 fn py_imp_02_relative_import_two_dots() {
1524 let source = "from ..utils import helper\n";
1526
1527 let extractor = PythonExtractor::new();
1529 let result = extractor.extract_imports(source, "tests/unit/test_something.py");
1530
1531 let imp = result.iter().find(|i| i.module_specifier == "../utils");
1533 assert!(
1534 imp.is_some(),
1535 "import from ../utils not found in {:?}",
1536 result
1537 );
1538 let imp = imp.unwrap();
1539 assert!(
1540 imp.symbols.contains(&"helper".to_string()),
1541 "helper not in symbols: {:?}",
1542 imp.symbols
1543 );
1544 }
1545
1546 #[test]
1550 fn py_imp_03_absolute_import_dotted() {
1551 let source = "from myapp.models import User\n";
1553
1554 let extractor = PythonExtractor::new();
1556 let result = extractor.extract_all_import_specifiers(source);
1557
1558 let entry = result.iter().find(|(spec, _)| spec == "myapp/models");
1560 assert!(entry.is_some(), "myapp/models not found in {:?}", result);
1561 let (_, symbols) = entry.unwrap();
1562 assert!(
1563 symbols.contains(&"User".to_string()),
1564 "User not in symbols: {:?}",
1565 symbols
1566 );
1567 }
1568
1569 #[test]
1573 fn py_imp_04_plain_import_skipped() {
1574 let source = "import os\n";
1576
1577 let extractor = PythonExtractor::new();
1579 let result = extractor.extract_all_import_specifiers(source);
1580
1581 let os_entry = result.iter().find(|(spec, _)| spec == "os");
1583 assert!(
1584 os_entry.is_some(),
1585 "plain 'import os' should be included as bare import, got {:?}",
1586 result
1587 );
1588 let (_, symbols) = os_entry.unwrap();
1589 assert!(
1590 symbols.is_empty(),
1591 "expected empty symbols for bare import, got {:?}",
1592 symbols
1593 );
1594 }
1595
1596 #[test]
1600 fn py_imp_05_from_dot_import_name() {
1601 let source = "from . import views\n";
1603
1604 let extractor = PythonExtractor::new();
1606 let result = extractor.extract_imports(source, "tests/test_app.py");
1607
1608 let imp = result.iter().find(|i| i.module_specifier == "./views");
1610 assert!(imp.is_some(), "./views not found in {:?}", result);
1611 let imp = imp.unwrap();
1612 assert!(
1613 imp.symbols.contains(&"views".to_string()),
1614 "views not in symbols: {:?}",
1615 imp.symbols
1616 );
1617 }
1618
1619 #[test]
1623 fn py_import_01_bare_import_simple() {
1624 let source = "import httpx\n";
1626
1627 let extractor = PythonExtractor::new();
1629 let result = extractor.extract_all_import_specifiers(source);
1630
1631 let entry = result.iter().find(|(spec, _)| spec == "httpx");
1633 assert!(
1634 entry.is_some(),
1635 "httpx not found in {:?}; bare import should be included",
1636 result
1637 );
1638 let (_, symbols) = entry.unwrap();
1639 assert!(
1640 symbols.is_empty(),
1641 "expected empty symbols for bare import, got {:?}",
1642 symbols
1643 );
1644 }
1645
1646 #[test]
1650 fn py_import_01b_bare_import_attribute_access_narrowing() {
1651 let source = "import httpx\nhttpx.Client()\nhttpx.get('/api')\n";
1653
1654 let extractor = PythonExtractor::new();
1656 let result = extractor.extract_all_import_specifiers(source);
1657
1658 let entry = result.iter().find(|(spec, _)| spec == "httpx");
1660 assert!(entry.is_some(), "httpx not found in {:?}", result);
1661 let (_, symbols) = entry.unwrap();
1662 assert!(
1663 symbols.contains(&"Client".to_string()),
1664 "expected Client in symbols, got {:?}",
1665 symbols
1666 );
1667 assert!(
1668 symbols.contains(&"get".to_string()),
1669 "expected get in symbols, got {:?}",
1670 symbols
1671 );
1672 }
1673
1674 #[test]
1681 fn py_import_02a_dotted_bare_import_attribute_fallback() {
1682 let source = "import os.path\nos.path.join('/a', 'b')\n";
1684
1685 let extractor = PythonExtractor::new();
1687 let result = extractor.extract_all_import_specifiers(source);
1688
1689 let entry = result.iter().find(|(spec, _)| spec == "os/path");
1692 assert!(entry.is_some(), "os/path not found in {:?}", result);
1693 let (_, symbols) = entry.unwrap();
1694 assert!(
1695 symbols.is_empty(),
1696 "expected empty symbols for dotted bare import (intentional fallback), got {:?}",
1697 symbols
1698 );
1699 }
1700
1701 #[test]
1705 fn py_import_02_bare_import_dotted() {
1706 let source = "import os.path\n";
1708
1709 let extractor = PythonExtractor::new();
1711 let result = extractor.extract_all_import_specifiers(source);
1712
1713 let entry = result.iter().find(|(spec, _)| spec == "os/path");
1715 assert!(
1716 entry.is_some(),
1717 "os/path not found in {:?}; dotted bare import should be converted",
1718 result
1719 );
1720 let (_, symbols) = entry.unwrap();
1721 assert!(
1722 symbols.is_empty(),
1723 "expected empty symbols for dotted bare import, got {:?}",
1724 symbols
1725 );
1726 }
1727
1728 #[test]
1733 fn py_import_03_from_import_regression() {
1734 let source = "from httpx import Client\n";
1736
1737 let extractor = PythonExtractor::new();
1739 let result = extractor.extract_all_import_specifiers(source);
1740
1741 let entry = result.iter().find(|(spec, _)| spec == "httpx");
1743 assert!(entry.is_some(), "httpx not found in {:?}", result);
1744 let (_, symbols) = entry.unwrap();
1745 assert!(
1746 symbols.contains(&"Client".to_string()),
1747 "Client not in symbols: {:?}",
1748 symbols
1749 );
1750 }
1751
1752 #[test]
1757 fn py_barrel_02_re_export_named() {
1758 let source = "from .module import Foo\n";
1760
1761 let extractor = PythonExtractor::new();
1763 let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1764
1765 let entry = result.iter().find(|e| e.from_specifier == "./module");
1767 assert!(entry.is_some(), "./module not found in {:?}", result);
1768 let entry = entry.unwrap();
1769 assert!(
1770 entry.symbols.contains(&"Foo".to_string()),
1771 "Foo not in symbols: {:?}",
1772 entry.symbols
1773 );
1774 }
1775
1776 #[test]
1780 fn py_barrel_03_all_exports_symbol_present() {
1781 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1784 .parent()
1785 .unwrap()
1786 .parent()
1787 .unwrap()
1788 .join("tests/fixtures/python/observe/barrel/__init__.py");
1789
1790 let extractor = PythonExtractor::new();
1792 let symbols = vec!["Foo".to_string()];
1793 let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1794
1795 assert!(
1797 result,
1798 "expected file_exports_any_symbol to return true for Foo"
1799 );
1800 }
1801
1802 #[test]
1806 fn py_barrel_04_all_exports_symbol_absent() {
1807 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1809 .parent()
1810 .unwrap()
1811 .parent()
1812 .unwrap()
1813 .join("tests/fixtures/python/observe/barrel/__init__.py");
1814
1815 let extractor = PythonExtractor::new();
1817 let symbols = vec!["Bar".to_string()];
1818 let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1819
1820 assert!(
1822 !result,
1823 "expected file_exports_any_symbol to return false for Bar"
1824 );
1825 }
1826
1827 #[test]
1831 fn py_barrel_05_re_export_wildcard() {
1832 let source = "from .module import *\n";
1834
1835 let extractor = PythonExtractor::new();
1837 let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1838
1839 let entry = result.iter().find(|e| e.from_specifier == "./module");
1841 assert!(entry.is_some(), "./module not found in {:?}", result);
1842 let entry = entry.unwrap();
1843 assert!(entry.wildcard, "expected wildcard=true, got {:?}", entry);
1844 assert!(
1845 entry.symbols.is_empty(),
1846 "expected empty symbols for wildcard, got {:?}",
1847 entry.symbols
1848 );
1849 }
1850
1851 #[test]
1855 fn py_barrel_06_re_export_named_multi_symbol() {
1856 let source = "from .module import Foo, Bar\n";
1858
1859 let extractor = PythonExtractor::new();
1861 let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1862
1863 let entry = result.iter().find(|e| e.from_specifier == "./module");
1865 assert!(entry.is_some(), "./module not found in {:?}", result);
1866 let entry = entry.unwrap();
1867 assert!(
1868 !entry.wildcard,
1869 "expected wildcard=false for named re-export, got {:?}",
1870 entry
1871 );
1872 assert!(
1873 entry.symbols.contains(&"Foo".to_string()),
1874 "Foo not in symbols: {:?}",
1875 entry.symbols
1876 );
1877 assert!(
1878 entry.symbols.contains(&"Bar".to_string()),
1879 "Bar not in symbols: {:?}",
1880 entry.symbols
1881 );
1882 }
1883
1884 #[test]
1890 fn py_barrel_07_e2e_wildcard_barrel_mapped() {
1891 use tempfile::TempDir;
1892
1893 let dir = TempDir::new().unwrap();
1894 let pkg = dir.path().join("pkg");
1895 std::fs::create_dir_all(&pkg).unwrap();
1896
1897 std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
1899 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
1901 let tests_dir = dir.path().join("tests");
1903 std::fs::create_dir_all(&tests_dir).unwrap();
1904 std::fs::write(
1905 tests_dir.join("test_foo.py"),
1906 "from pkg import Foo\n\ndef test_foo():\n assert Foo()\n",
1907 )
1908 .unwrap();
1909
1910 let extractor = PythonExtractor::new();
1911 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1912 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1913 let test_source = std::fs::read_to_string(&test_path).unwrap();
1914
1915 let production_files = vec![module_path.clone()];
1916 let test_sources: HashMap<String, String> =
1917 [(test_path.clone(), test_source)].into_iter().collect();
1918
1919 let result = extractor.map_test_files_with_imports(
1921 &production_files,
1922 &test_sources,
1923 dir.path(),
1924 false,
1925 );
1926
1927 let mapping = result.iter().find(|m| m.production_file == module_path);
1929 assert!(
1930 mapping.is_some(),
1931 "module.py not found in mappings: {:?}",
1932 result
1933 );
1934 let mapping = mapping.unwrap();
1935 assert!(
1936 mapping.test_files.contains(&test_path),
1937 "test_foo.py not matched to module.py: {:?}",
1938 mapping.test_files
1939 );
1940 }
1941
1942 #[test]
1948 fn py_barrel_08_e2e_named_barrel_mapped() {
1949 use tempfile::TempDir;
1950
1951 let dir = TempDir::new().unwrap();
1952 let pkg = dir.path().join("pkg");
1953 std::fs::create_dir_all(&pkg).unwrap();
1954
1955 std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
1957 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
1959 let tests_dir = dir.path().join("tests");
1961 std::fs::create_dir_all(&tests_dir).unwrap();
1962 std::fs::write(
1963 tests_dir.join("test_foo.py"),
1964 "from pkg import Foo\n\ndef test_foo():\n assert Foo()\n",
1965 )
1966 .unwrap();
1967
1968 let extractor = PythonExtractor::new();
1969 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1970 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1971 let test_source = std::fs::read_to_string(&test_path).unwrap();
1972
1973 let production_files = vec![module_path.clone()];
1974 let test_sources: HashMap<String, String> =
1975 [(test_path.clone(), test_source)].into_iter().collect();
1976
1977 let result = extractor.map_test_files_with_imports(
1979 &production_files,
1980 &test_sources,
1981 dir.path(),
1982 false,
1983 );
1984
1985 let mapping = result.iter().find(|m| m.production_file == module_path);
1987 assert!(
1988 mapping.is_some(),
1989 "module.py not found in mappings: {:?}",
1990 result
1991 );
1992 let mapping = mapping.unwrap();
1993 assert!(
1994 mapping.test_files.contains(&test_path),
1995 "test_foo.py not matched to module.py: {:?}",
1996 mapping.test_files
1997 );
1998 }
1999
2000 #[test]
2006 fn py_barrel_09_e2e_wildcard_barrel_non_exported_not_mapped() {
2007 use tempfile::TempDir;
2008
2009 let dir = TempDir::new().unwrap();
2010 let pkg = dir.path().join("pkg");
2011 std::fs::create_dir_all(&pkg).unwrap();
2012
2013 std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
2015 std::fs::write(
2017 pkg.join("module.py"),
2018 "__all__ = [\"Foo\"]\n\nclass Foo:\n pass\n\nclass NonExistent:\n pass\n",
2019 )
2020 .unwrap();
2021 let tests_dir = dir.path().join("tests");
2023 std::fs::create_dir_all(&tests_dir).unwrap();
2024 std::fs::write(
2025 tests_dir.join("test_nonexistent.py"),
2026 "from pkg import NonExistent\n\ndef test_ne():\n assert NonExistent()\n",
2027 )
2028 .unwrap();
2029
2030 let extractor = PythonExtractor::new();
2031 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
2032 let test_path = tests_dir
2033 .join("test_nonexistent.py")
2034 .to_string_lossy()
2035 .into_owned();
2036 let test_source = std::fs::read_to_string(&test_path).unwrap();
2037
2038 let production_files = vec![module_path.clone()];
2039 let test_sources: HashMap<String, String> =
2040 [(test_path.clone(), test_source)].into_iter().collect();
2041
2042 let result = extractor.map_test_files_with_imports(
2044 &production_files,
2045 &test_sources,
2046 dir.path(),
2047 false,
2048 );
2049
2050 let mapping = result.iter().find(|m| m.production_file == module_path);
2053 if let Some(mapping) = mapping {
2054 assert!(
2055 !mapping.test_files.contains(&test_path),
2056 "test_nonexistent.py should NOT be matched to module.py: {:?}",
2057 mapping.test_files
2058 );
2059 }
2060 }
2062
2063 #[test]
2067 fn py_e2e_01_layer1_stem_match() {
2068 let extractor = PythonExtractor::new();
2070 let production_files = vec!["e2e_pkg/models.py".to_string()];
2071 let test_sources: HashMap<String, String> =
2072 [("e2e_pkg/test_models.py".to_string(), "".to_string())]
2073 .into_iter()
2074 .collect();
2075
2076 let scan_root = PathBuf::from(".");
2078 let result = extractor.map_test_files_with_imports(
2079 &production_files,
2080 &test_sources,
2081 &scan_root,
2082 false,
2083 );
2084
2085 let mapping = result
2087 .iter()
2088 .find(|m| m.production_file == "e2e_pkg/models.py");
2089 assert!(
2090 mapping.is_some(),
2091 "models.py not found in mappings: {:?}",
2092 result
2093 );
2094 let mapping = mapping.unwrap();
2095 assert!(
2096 mapping
2097 .test_files
2098 .contains(&"e2e_pkg/test_models.py".to_string()),
2099 "test_models.py not in test_files: {:?}",
2100 mapping.test_files
2101 );
2102 assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2103 }
2104
2105 #[test]
2109 fn py_e2e_02_layer2_import_tracing() {
2110 let extractor = PythonExtractor::new();
2112
2113 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2114 .parent()
2115 .unwrap()
2116 .parent()
2117 .unwrap()
2118 .join("tests/fixtures/python/observe/e2e_pkg");
2119
2120 let views_path = fixture_root.join("views.py").to_string_lossy().into_owned();
2121 let test_views_path = fixture_root
2122 .join("tests/test_views.py")
2123 .to_string_lossy()
2124 .into_owned();
2125
2126 let test_source =
2127 std::fs::read_to_string(fixture_root.join("tests/test_views.py")).unwrap_or_default();
2128
2129 let production_files = vec![views_path.clone()];
2130 let test_sources: HashMap<String, String> = [(test_views_path.clone(), test_source)]
2131 .into_iter()
2132 .collect();
2133
2134 let result = extractor.map_test_files_with_imports(
2136 &production_files,
2137 &test_sources,
2138 &fixture_root,
2139 false,
2140 );
2141
2142 let mapping = result.iter().find(|m| m.production_file == views_path);
2144 assert!(
2145 mapping.is_some(),
2146 "views.py not found in mappings: {:?}",
2147 result
2148 );
2149 let mapping = mapping.unwrap();
2150 assert!(
2151 mapping.test_files.contains(&test_views_path),
2152 "test_views.py not matched to views.py: {:?}",
2153 mapping.test_files
2154 );
2155 }
2156
2157 #[test]
2161 fn py_e2e_03_conftest_excluded_as_helper() {
2162 let extractor = PythonExtractor::new();
2164 let production_files = vec!["e2e_pkg/models.py".to_string()];
2165 let test_sources: HashMap<String, String> = [
2166 ("e2e_pkg/tests/test_models.py".to_string(), "".to_string()),
2167 (
2168 "e2e_pkg/tests/conftest.py".to_string(),
2169 "import pytest\n".to_string(),
2170 ),
2171 ]
2172 .into_iter()
2173 .collect();
2174
2175 let scan_root = PathBuf::from(".");
2177 let result = extractor.map_test_files_with_imports(
2178 &production_files,
2179 &test_sources,
2180 &scan_root,
2181 false,
2182 );
2183
2184 for mapping in &result {
2186 assert!(
2187 !mapping.test_files.iter().any(|f| f.contains("conftest.py")),
2188 "conftest.py should not appear in mappings: {:?}",
2189 mapping
2190 );
2191 }
2192 }
2193
2194 struct ImportTestResult {
2199 mappings: Vec<FileMapping>,
2200 prod_path: String,
2201 test_path: String,
2202 _tmp: tempfile::TempDir,
2203 }
2204
2205 fn run_import_test(
2209 prod_rel: &str,
2210 prod_content: &str,
2211 test_rel: &str,
2212 test_content: &str,
2213 extra_files: &[(&str, &str)],
2214 ) -> ImportTestResult {
2215 let tmp = tempfile::tempdir().unwrap();
2216
2217 for (rel, content) in extra_files {
2219 let path = tmp.path().join(rel);
2220 if let Some(parent) = path.parent() {
2221 std::fs::create_dir_all(parent).unwrap();
2222 }
2223 std::fs::write(&path, content).unwrap();
2224 }
2225
2226 let prod_abs = tmp.path().join(prod_rel);
2228 if let Some(parent) = prod_abs.parent() {
2229 std::fs::create_dir_all(parent).unwrap();
2230 }
2231 std::fs::write(&prod_abs, prod_content).unwrap();
2232
2233 let test_abs = tmp.path().join(test_rel);
2235 if let Some(parent) = test_abs.parent() {
2236 std::fs::create_dir_all(parent).unwrap();
2237 }
2238 std::fs::write(&test_abs, test_content).unwrap();
2239
2240 let extractor = PythonExtractor::new();
2241 let prod_path = prod_abs.to_string_lossy().into_owned();
2242 let test_path = test_abs.to_string_lossy().into_owned();
2243 let production_files = vec![prod_path.clone()];
2244 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
2245 .into_iter()
2246 .collect();
2247
2248 let mappings = extractor.map_test_files_with_imports(
2249 &production_files,
2250 &test_sources,
2251 tmp.path(),
2252 false,
2253 );
2254
2255 ImportTestResult {
2256 mappings,
2257 prod_path,
2258 test_path,
2259 _tmp: tmp,
2260 }
2261 }
2262
2263 #[test]
2267 fn py_abs_01_absolute_import_nested_module() {
2268 let r = run_import_test(
2271 "models/cars.py",
2272 "class Car:\n pass\n",
2273 "tests/unit/test_car.py",
2274 "from models.cars import Car\n\ndef test_car():\n pass\n",
2275 &[],
2276 );
2277
2278 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2280 assert!(
2281 mapping.is_some(),
2282 "models/cars.py not found in mappings: {:?}",
2283 r.mappings
2284 );
2285 let mapping = mapping.unwrap();
2286 assert!(
2287 mapping.test_files.contains(&r.test_path),
2288 "test_car.py not in test_files for models/cars.py: {:?}",
2289 mapping.test_files
2290 );
2291 assert_eq!(
2292 mapping.strategy,
2293 MappingStrategy::ImportTracing,
2294 "expected ImportTracing strategy, got {:?}",
2295 mapping.strategy
2296 );
2297 }
2298
2299 #[test]
2303 fn py_abs_02_absolute_import_utils_module() {
2304 let r = run_import_test(
2307 "utils/publish_state.py",
2308 "class PublishState:\n pass\n",
2309 "tests/test_pub.py",
2310 "from utils.publish_state import PublishState\n\ndef test_pub():\n pass\n",
2311 &[],
2312 );
2313
2314 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2316 assert!(
2317 mapping.is_some(),
2318 "utils/publish_state.py not found in mappings: {:?}",
2319 r.mappings
2320 );
2321 let mapping = mapping.unwrap();
2322 assert!(
2323 mapping.test_files.contains(&r.test_path),
2324 "test_pub.py not in test_files for utils/publish_state.py: {:?}",
2325 mapping.test_files
2326 );
2327 assert_eq!(
2328 mapping.strategy,
2329 MappingStrategy::ImportTracing,
2330 "expected ImportTracing strategy, got {:?}",
2331 mapping.strategy
2332 );
2333 }
2334
2335 #[test]
2339 fn py_abs_03_relative_import_still_resolves() {
2340 let r = run_import_test(
2344 "pkg/models.py",
2345 "class X:\n pass\n",
2346 "pkg/test_something.py",
2347 "from .models import X\n\ndef test_x():\n pass\n",
2348 &[],
2349 );
2350
2351 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2353 assert!(
2354 mapping.is_some(),
2355 "pkg/models.py not found in mappings: {:?}",
2356 r.mappings
2357 );
2358 let mapping = mapping.unwrap();
2359 assert!(
2360 mapping.test_files.contains(&r.test_path),
2361 "test_something.py not in test_files for pkg/models.py: {:?}",
2362 mapping.test_files
2363 );
2364 }
2365
2366 #[test]
2370 fn py_stem_07_production_stem_single_underscore_prefix() {
2371 let extractor = PythonExtractor::new();
2375 let result = extractor.production_stem("httpx/_decoders.py");
2376 assert_eq!(result, Some("decoders"));
2377 }
2378
2379 #[test]
2383 fn py_stem_08_production_stem_double_underscore_strips_one() {
2384 let extractor = PythonExtractor::new();
2388 let result = extractor.production_stem("httpx/__version__.py");
2389 assert_eq!(result, Some("_version"));
2390 }
2391
2392 #[test]
2396 fn py_stem_09_production_stem_no_prefix_regression() {
2397 let extractor = PythonExtractor::new();
2401 let result = extractor.production_stem("httpx/decoders.py");
2402 assert_eq!(result, Some("decoders"));
2403 }
2404
2405 #[test]
2409 fn py_stem_10_production_stem_triple_underscore() {
2410 let extractor = PythonExtractor::new();
2414 let result = extractor.production_stem("pkg/___triple.py");
2415 assert_eq!(result, Some("__triple"));
2416 }
2417
2418 #[test]
2422 fn py_stem_11_production_stem_prefix_and_suffix_chained() {
2423 let extractor = PythonExtractor::new();
2427 let result = extractor.production_stem("pkg/___foo__.py");
2428 assert_eq!(result, Some("__foo"));
2429 }
2430
2431 #[test]
2435 fn py_stem_12_production_stem_dunder_prefix_and_suffix() {
2436 let extractor = PythonExtractor::new();
2440 let result = extractor.production_stem("pkg/__foo__.py");
2441 assert_eq!(result, Some("_foo"));
2442 }
2443
2444 #[test]
2448 fn py_stem_13_tests_file_with_parent_dir() {
2449 let result = test_stem("app/tests.py");
2453 assert_eq!(result, Some("app"));
2454 }
2455
2456 #[test]
2460 fn py_stem_14_tests_file_with_nested_parent_dir() {
2461 let result = test_stem("tests/aggregation/tests.py");
2465 assert_eq!(result, Some("aggregation"));
2466 }
2467
2468 #[test]
2472 fn py_stem_15_tests_file_no_parent_dir() {
2473 let result = test_stem("tests.py");
2477 assert_eq!(result, None);
2478 }
2479
2480 #[test]
2484 fn py_stem_16_production_stem_excludes_tests_file() {
2485 let result = production_stem("app/tests.py");
2489 assert_eq!(result, None);
2490 }
2491
2492 #[test]
2496 fn py_srclayout_01_src_layout_absolute_import_resolved() {
2497 let r = run_import_test(
2500 "src/mypackage/sessions.py",
2501 "class Session:\n pass\n",
2502 "tests/test_sessions.py",
2503 "from mypackage.sessions import Session\n\ndef test_session():\n pass\n",
2504 &[("src/mypackage/__init__.py", "")],
2505 );
2506
2507 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2512 assert!(
2513 mapping.is_some(),
2514 "src/mypackage/sessions.py not found in mappings: {:?}",
2515 r.mappings
2516 );
2517 let mapping = mapping.unwrap();
2518 assert!(
2519 mapping.test_files.contains(&r.test_path),
2520 "test_sessions.py not in test_files for sessions.py (src/ layout): {:?}",
2521 mapping.test_files
2522 );
2523 assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2524 }
2525
2526 #[test]
2530 fn py_srclayout_02_non_src_layout_regression() {
2531 let r = run_import_test(
2534 "mypackage/sessions.py",
2535 "class Session:\n pass\n",
2536 "tests/test_sessions.py",
2537 "from mypackage.sessions import Session\n\ndef test_session():\n pass\n",
2538 &[],
2539 );
2540
2541 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2546 assert!(
2547 mapping.is_some(),
2548 "mypackage/sessions.py not found in mappings: {:?}",
2549 r.mappings
2550 );
2551 let mapping = mapping.unwrap();
2552 assert!(
2553 mapping.test_files.contains(&r.test_path),
2554 "test_sessions.py not in test_files for sessions.py (non-src layout): {:?}",
2555 mapping.test_files
2556 );
2557 assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2558 }
2559
2560 #[test]
2564 fn py_abs_04_nonexistent_absolute_import_skipped() {
2565 let r = run_import_test(
2569 "models/real.py",
2570 "class Real:\n pass\n",
2571 "tests/test_missing.py",
2572 "from nonexistent.module import X\n\ndef test_x():\n pass\n",
2573 &[],
2574 );
2575
2576 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2578 if let Some(mapping) = mapping {
2579 assert!(
2580 !mapping.test_files.contains(&r.test_path),
2581 "test_missing.py should NOT be mapped to models/real.py: {:?}",
2582 mapping.test_files
2583 );
2584 }
2585 }
2587
2588 #[test]
2592 fn py_abs_05_mixed_absolute_and_relative_imports() {
2593 let tmp = tempfile::tempdir().unwrap();
2597 let models_dir = tmp.path().join("models");
2598 let tests_dir = tmp.path().join("tests");
2599 std::fs::create_dir_all(&models_dir).unwrap();
2600 std::fs::create_dir_all(&tests_dir).unwrap();
2601
2602 let cars_py = models_dir.join("cars.py");
2603 std::fs::write(&cars_py, "class Car:\n pass\n").unwrap();
2604
2605 let helpers_py = tests_dir.join("helpers.py");
2606 std::fs::write(&helpers_py, "def setup():\n pass\n").unwrap();
2607
2608 let test_py = tests_dir.join("test_mixed.py");
2609 let test_source =
2610 "from models.cars import Car\nfrom .helpers import setup\n\ndef test_mixed():\n pass\n";
2611 std::fs::write(&test_py, test_source).unwrap();
2612
2613 let extractor = PythonExtractor::new();
2614 let cars_prod = cars_py.to_string_lossy().into_owned();
2615 let helpers_prod = helpers_py.to_string_lossy().into_owned();
2616 let test_path = test_py.to_string_lossy().into_owned();
2617
2618 let production_files = vec![cars_prod.clone(), helpers_prod.clone()];
2619 let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
2620 .into_iter()
2621 .collect();
2622
2623 let result = extractor.map_test_files_with_imports(
2625 &production_files,
2626 &test_sources,
2627 tmp.path(),
2628 false,
2629 );
2630
2631 let cars_mapping = result.iter().find(|m| m.production_file == cars_prod);
2633 assert!(
2634 cars_mapping.is_some(),
2635 "models/cars.py not found in mappings: {:?}",
2636 result
2637 );
2638 let cars_m = cars_mapping.unwrap();
2639 assert!(
2640 cars_m.test_files.contains(&test_path),
2641 "test_mixed.py not mapped to models/cars.py via absolute import: {:?}",
2642 cars_m.test_files
2643 );
2644
2645 let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
2647 assert!(
2648 helpers_mapping.is_none(),
2649 "tests/helpers.py should be excluded as test helper (Phase 20), but found in mappings: {:?}",
2650 helpers_mapping
2651 );
2652 }
2653
2654 #[test]
2658 fn py_rel_01_bare_two_dot_relative_import() {
2659 let r = run_import_test(
2662 "pkg/utils.py",
2663 "def helper():\n pass\n",
2664 "pkg/sub/test_thing.py",
2665 "from .. import utils\n\ndef test_thing():\n pass\n",
2666 &[],
2667 );
2668
2669 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2671 assert!(
2672 mapping.is_some(),
2673 "pkg/utils.py not found in mappings: {:?}",
2674 r.mappings
2675 );
2676 let mapping = mapping.unwrap();
2677 assert!(
2678 mapping.test_files.contains(&r.test_path),
2679 "test_thing.py not in test_files for pkg/utils.py via bare two-dot import: {:?}",
2680 mapping.test_files
2681 );
2682 }
2683
2684 #[test]
2688 fn py_l2_django_01_tests_file_mapped_via_import_tracing() {
2689 let r = run_import_test(
2692 "src/models.py",
2693 "class Model:\n pass\n",
2694 "app/tests.py",
2695 "from src.models import Model\n\n\ndef test_model():\n pass\n",
2696 &[],
2697 );
2698
2699 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2701 assert!(
2702 mapping.is_some(),
2703 "src/models.py not found in mappings: {:?}",
2704 r.mappings
2705 );
2706 let mapping = mapping.unwrap();
2707 assert!(
2708 mapping.test_files.contains(&r.test_path),
2709 "app/tests.py not in test_files for src/models.py: {:?}",
2710 mapping.test_files
2711 );
2712 assert_eq!(
2713 mapping.strategy,
2714 MappingStrategy::ImportTracing,
2715 "expected ImportTracing strategy, got {:?}",
2716 mapping.strategy
2717 );
2718 }
2719
2720 #[test]
2725 fn py_l2_django_managepy_root_tc01_subdirectory_layout() {
2726 let tmp = tempfile::tempdir().unwrap();
2729
2730 let manage_py_path = tmp.path().join("project").join("manage.py");
2731 std::fs::create_dir_all(manage_py_path.parent().unwrap()).unwrap();
2732 std::fs::write(&manage_py_path, "#!/usr/bin/env python\n").unwrap();
2733
2734 let prod_rel = "project/app/models.py";
2735 let prod_abs = tmp.path().join(prod_rel);
2736 std::fs::create_dir_all(prod_abs.parent().unwrap()).unwrap();
2737 std::fs::write(&prod_abs, "class MyModel:\n pass\n").unwrap();
2738
2739 let test_rel = "tests/test_models.py";
2740 let test_abs = tmp.path().join(test_rel);
2741 std::fs::create_dir_all(test_abs.parent().unwrap()).unwrap();
2742 let test_content = "from app.models import MyModel\n\ndef test_mymodel():\n pass\n";
2743 std::fs::write(&test_abs, test_content).unwrap();
2744
2745 let extractor = PythonExtractor::new();
2746 let prod_path = prod_abs.to_string_lossy().into_owned();
2747 let test_path = test_abs.to_string_lossy().into_owned();
2748 let production_files = vec![prod_path.clone()];
2749 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
2750 .into_iter()
2751 .collect();
2752
2753 let mappings = extractor.map_test_files_with_imports(
2755 &production_files,
2756 &test_sources,
2757 tmp.path(),
2758 false,
2759 );
2760
2761 let mapping = mappings.iter().find(|m| m.production_file == prod_path);
2763 assert!(
2764 mapping.is_some(),
2765 "project/app/models.py not found in mappings: {:?}",
2766 mappings
2767 );
2768 let mapping = mapping.unwrap();
2769 assert!(
2770 mapping.test_files.contains(&test_path),
2771 "tests/test_models.py not in test_files for project/app/models.py: {:?}",
2772 mapping.test_files
2773 );
2774 assert_eq!(
2775 mapping.strategy,
2776 MappingStrategy::ImportTracing,
2777 "expected ImportTracing strategy, got {:?}",
2778 mapping.strategy
2779 );
2780 }
2781
2782 #[test]
2786 fn py_l2_django_managepy_root_tc03_at_scan_root_returns_none() {
2787 let tmp = tempfile::tempdir().unwrap();
2789 std::fs::write(tmp.path().join("manage.py"), "#!/usr/bin/env python\n").unwrap();
2790
2791 let result = find_manage_py_root(tmp.path());
2793
2794 assert!(
2796 result.is_none(),
2797 "expected None when manage.py is at scan_root, got {:?}",
2798 result
2799 );
2800 }
2801}
2802
2803const ROUTE_DECORATOR_QUERY: &str = include_str!("../queries/route_decorator.scm");
2808static ROUTE_DECORATOR_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
2809
2810const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
2811
2812#[derive(Debug, Clone, PartialEq)]
2814pub struct Route {
2815 pub http_method: String,
2816 pub path: String,
2817 pub handler_name: String,
2818 pub file: String,
2819}
2820
2821fn collect_router_prefixes(
2824 source_bytes: &[u8],
2825 tree: &tree_sitter::Tree,
2826) -> HashMap<String, String> {
2827 let mut prefixes = HashMap::new();
2828
2829 let root = tree.root_node();
2831 let mut stack = vec![root];
2832
2833 while let Some(node) = stack.pop() {
2834 if node.kind() == "assignment" {
2835 let left = node.child_by_field_name("left");
2836 let right = node.child_by_field_name("right");
2837
2838 if let (Some(left_node), Some(right_node)) = (left, right) {
2839 if left_node.kind() == "identifier" && right_node.kind() == "call" {
2840 let var_name = left_node.utf8_text(source_bytes).unwrap_or("").to_string();
2841
2842 let fn_node = right_node.child_by_field_name("function");
2844 let call_name = fn_node
2845 .and_then(|f| f.utf8_text(source_bytes).ok())
2846 .unwrap_or("");
2847 let is_api_router = call_name == "APIRouter";
2848 let is_blueprint = call_name == "Blueprint";
2849
2850 if is_api_router || is_blueprint {
2851 let prefix_kw = if is_blueprint { "url_prefix" } else { "prefix" };
2854 let args_node = right_node.child_by_field_name("arguments");
2855 if let Some(args) = args_node {
2856 let mut args_cursor = args.walk();
2857 for arg in args.named_children(&mut args_cursor) {
2858 if arg.kind() == "keyword_argument" {
2859 let kw_name = arg
2860 .child_by_field_name("name")
2861 .and_then(|n| n.utf8_text(source_bytes).ok())
2862 .unwrap_or("");
2863 if kw_name == prefix_kw {
2864 if let Some(val) = arg.child_by_field_name("value") {
2865 if val.kind() == "string" {
2866 let raw = val.utf8_text(source_bytes).unwrap_or("");
2867 let prefix = strip_string_quotes(raw);
2868 prefixes.insert(var_name.clone(), prefix);
2869 }
2870 }
2871 }
2872 }
2873 }
2874 }
2875 prefixes.entry(var_name).or_default();
2877 }
2878 }
2879 }
2880 }
2881
2882 let mut w = node.walk();
2884 let children: Vec<_> = node.named_children(&mut w).collect();
2885 for child in children.into_iter().rev() {
2886 stack.push(child);
2887 }
2888 }
2889
2890 prefixes
2891}
2892
2893fn strip_string_quotes(raw: &str) -> String {
2899 let raw = raw.trim_start_matches(|c: char| "rRbBfFuU".contains(c));
2902 for q in &[r#"""""#, "'''"] {
2904 if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2905 return inner.to_string();
2906 }
2907 }
2908 for q in &["\"", "'"] {
2910 if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2911 return inner.to_string();
2912 }
2913 }
2914 raw.to_string()
2915}
2916
2917fn extract_methods_kwarg<'a>(args_node: tree_sitter::Node<'a>, source_bytes: &[u8]) -> Vec<String> {
2924 let mut cursor = args_node.walk();
2925 for arg in args_node.named_children(&mut cursor) {
2926 if arg.kind() != "keyword_argument" {
2927 continue;
2928 }
2929 let kw_name = arg
2930 .child_by_field_name("name")
2931 .and_then(|n| n.utf8_text(source_bytes).ok())
2932 .unwrap_or("");
2933 if kw_name != "methods" {
2934 continue;
2935 }
2936 let val = match arg.child_by_field_name("value") {
2938 Some(v) => v,
2939 None => return vec![],
2940 };
2941 if val.kind() != "list" {
2942 return vec![];
2943 }
2944 let mut methods = Vec::new();
2945 let mut list_cursor = val.walk();
2946 for item in val.named_children(&mut list_cursor) {
2947 if item.kind() == "string" {
2948 let raw = item.utf8_text(source_bytes).unwrap_or("");
2949 let method = strip_string_quotes(raw).to_uppercase();
2950 if !method.is_empty() {
2951 methods.push(method);
2952 }
2953 }
2954 }
2955 return methods;
2956 }
2957 vec![]
2958}
2959
2960pub fn extract_routes(source: &str, file_path: &str) -> Vec<Route> {
2962 if source.is_empty() {
2963 return Vec::new();
2964 }
2965
2966 let mut parser = PythonExtractor::parser();
2967 let tree = match parser.parse(source, None) {
2968 Some(t) => t,
2969 None => return Vec::new(),
2970 };
2971 let source_bytes = source.as_bytes();
2972
2973 let router_prefixes = collect_router_prefixes(source_bytes, &tree);
2975
2976 let query = cached_query(&ROUTE_DECORATOR_QUERY_CACHE, ROUTE_DECORATOR_QUERY);
2978
2979 let obj_idx = query.capture_index_for_name("route.object");
2980 let method_idx = query.capture_index_for_name("route.method");
2981 let path_idx = query.capture_index_for_name("route.path");
2982 let handler_idx = query.capture_index_for_name("route.handler");
2983
2984 let mut cursor = QueryCursor::new();
2985 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
2986
2987 let mut routes = Vec::new();
2988 let mut seen = HashSet::new();
2989
2990 static COLON_PARAM_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
2993 let colon_param_re =
2994 COLON_PARAM_RE.get_or_init(|| regex::Regex::new(r":(\w+)").expect("invalid regex"));
2995
2996 while let Some(m) = matches.next() {
2997 let mut obj: Option<String> = None;
2998 let mut method: Option<String> = None;
2999 let mut path_raw: Option<String> = None;
3000 let mut path_is_string = false;
3001 let mut path_node: Option<tree_sitter::Node> = None;
3002 let mut handler: Option<String> = None;
3003
3004 for cap in m.captures {
3005 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
3006 if obj_idx == Some(cap.index) {
3007 obj = Some(text);
3008 } else if method_idx == Some(cap.index) {
3009 method = Some(text);
3010 } else if path_idx == Some(cap.index) {
3011 path_is_string = cap.node.kind() == "string";
3013 path_raw = Some(text);
3014 path_node = Some(cap.node);
3015 } else if handler_idx == Some(cap.index) {
3016 handler = Some(text);
3017 }
3018 }
3019
3020 let (obj, method, handler) = match (obj, method, handler) {
3021 (Some(o), Some(m), Some(h)) => (o, m, h),
3022 _ => continue,
3023 };
3024
3025 if method == "route" {
3027 let sub_path = match path_raw {
3029 Some(ref raw) if path_is_string => strip_string_quotes(raw),
3030 Some(_) => "<dynamic>".to_string(),
3031 None => "<dynamic>".to_string(),
3032 };
3033
3034 let prefix = router_prefixes.get(&obj).map(|s| s.as_str()).unwrap_or("");
3036 let raw_full_path = if prefix.is_empty() {
3037 sub_path
3038 } else {
3039 format!("{prefix}{sub_path}")
3040 };
3041
3042 let colon_normalized = normalize_django_path(&raw_full_path);
3046 let full_path = colon_param_re
3047 .replace_all(&colon_normalized, "{$1}")
3048 .into_owned();
3049
3050 let http_methods: Vec<String> =
3053 if let Some(arg_list) = path_node.and_then(|n| n.parent()) {
3054 let methods_vec = extract_methods_kwarg(arg_list, source_bytes);
3055 if methods_vec.is_empty() {
3056 vec!["GET".to_string()]
3057 } else {
3058 methods_vec
3059 }
3060 } else {
3061 vec!["GET".to_string()]
3062 };
3063
3064 for http_method in http_methods {
3065 let key = (http_method.clone(), full_path.clone(), handler.clone());
3066 if seen.insert(key) {
3067 routes.push(Route {
3068 http_method,
3069 path: full_path.clone(),
3070 handler_name: handler.clone(),
3071 file: file_path.to_string(),
3072 });
3073 }
3074 }
3075 continue;
3076 }
3077
3078 if !HTTP_METHODS.contains(&method.as_str()) {
3080 continue;
3081 }
3082
3083 let sub_path = match path_raw {
3085 Some(ref raw) if path_is_string => strip_string_quotes(raw),
3086 Some(_) => "<dynamic>".to_string(),
3087 None => "<dynamic>".to_string(),
3088 };
3089
3090 let prefix = router_prefixes.get(&obj).map(|s| s.as_str()).unwrap_or("");
3092 let full_path = if prefix.is_empty() {
3093 sub_path
3094 } else {
3095 format!("{prefix}{sub_path}")
3096 };
3097
3098 let key = (method.clone(), full_path.clone(), handler.clone());
3100 if !seen.insert(key) {
3101 continue;
3102 }
3103
3104 routes.push(Route {
3105 http_method: method.to_uppercase(),
3106 path: full_path,
3107 handler_name: handler,
3108 file: file_path.to_string(),
3109 });
3110 }
3111
3112 routes
3113}
3114
3115#[cfg(test)]
3120mod route_tests {
3121 use super::*;
3122
3123 #[test]
3125 fn fa_rt_01_basic_app_get_route() {
3126 let source = r#"
3128from fastapi import FastAPI
3129app = FastAPI()
3130
3131@app.get("/users")
3132def read_users():
3133 return []
3134"#;
3135
3136 let routes = extract_routes(source, "main.py");
3138
3139 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3141 assert_eq!(routes[0].http_method, "GET");
3142 assert_eq!(routes[0].path, "/users");
3143 assert_eq!(routes[0].handler_name, "read_users");
3144 }
3145
3146 #[test]
3148 fn fa_rt_02_multiple_http_methods() {
3149 let source = r#"
3151from fastapi import FastAPI
3152app = FastAPI()
3153
3154@app.get("/items")
3155def list_items():
3156 return []
3157
3158@app.post("/items")
3159def create_item():
3160 return {}
3161
3162@app.put("/items/{item_id}")
3163def update_item(item_id: int):
3164 return {}
3165
3166@app.delete("/items/{item_id}")
3167def delete_item(item_id: int):
3168 return {}
3169"#;
3170
3171 let routes = extract_routes(source, "main.py");
3173
3174 assert_eq!(routes.len(), 4, "expected 4 routes, got {:?}", routes);
3176 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
3177 assert!(methods.contains(&"GET"), "missing GET");
3178 assert!(methods.contains(&"POST"), "missing POST");
3179 assert!(methods.contains(&"PUT"), "missing PUT");
3180 assert!(methods.contains(&"DELETE"), "missing DELETE");
3181 }
3182
3183 #[test]
3185 fn fa_rt_03_path_parameter() {
3186 let source = r#"
3188from fastapi import FastAPI
3189app = FastAPI()
3190
3191@app.get("/items/{item_id}")
3192def read_item(item_id: int):
3193 return {}
3194"#;
3195
3196 let routes = extract_routes(source, "main.py");
3198
3199 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3201 assert_eq!(routes[0].path, "/items/{item_id}");
3202 }
3203
3204 #[test]
3206 fn fa_rt_04_router_get_with_prefix() {
3207 let source = r#"
3209from fastapi import APIRouter
3210
3211router = APIRouter(prefix="/items")
3212
3213@router.get("/{item_id}")
3214def read_item(item_id: int):
3215 return {}
3216"#;
3217
3218 let routes = extract_routes(source, "routes.py");
3220
3221 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3223 assert_eq!(
3224 routes[0].path, "/items/{item_id}",
3225 "expected prefix-resolved path"
3226 );
3227 }
3228
3229 #[test]
3231 fn fa_rt_05_router_get_without_prefix() {
3232 let source = r#"
3234from fastapi import APIRouter
3235
3236router = APIRouter()
3237
3238@router.get("/health")
3239def health_check():
3240 return {"status": "ok"}
3241"#;
3242
3243 let routes = extract_routes(source, "routes.py");
3245
3246 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3248 assert_eq!(routes[0].path, "/health");
3249 }
3250
3251 #[test]
3253 fn fa_rt_06_non_route_decorator_ignored() {
3254 let source = r#"
3256import pytest
3257
3258@pytest.fixture
3259def client():
3260 return None
3261
3262class MyClass:
3263 @staticmethod
3264 def helper():
3265 pass
3266"#;
3267
3268 let routes = extract_routes(source, "main.py");
3270
3271 assert!(
3273 routes.is_empty(),
3274 "expected no routes for non-route decorators, got {:?}",
3275 routes
3276 );
3277 }
3278
3279 #[test]
3281 fn fa_rt_07_dynamic_path_non_literal() {
3282 let source = r#"
3284from fastapi import FastAPI
3285app = FastAPI()
3286
3287ROUTE_PATH = "/dynamic"
3288
3289@app.get(ROUTE_PATH)
3290def dynamic_route():
3291 return {}
3292"#;
3293
3294 let routes = extract_routes(source, "main.py");
3296
3297 assert_eq!(
3299 routes.len(),
3300 1,
3301 "expected 1 route for dynamic path, got {:?}",
3302 routes
3303 );
3304 assert_eq!(
3305 routes[0].path, "<dynamic>",
3306 "expected <dynamic> for non-literal path argument"
3307 );
3308 }
3309
3310 #[test]
3312 fn fa_rt_08_empty_source() {
3313 let source = "";
3315
3316 let routes = extract_routes(source, "main.py");
3318
3319 assert!(routes.is_empty(), "expected empty Vec for empty source");
3321 }
3322
3323 #[test]
3325 fn fa_rt_09_async_def_handler() {
3326 let source = r#"
3328from fastapi import FastAPI
3329app = FastAPI()
3330
3331@app.get("/")
3332async def root():
3333 return {"message": "hello"}
3334"#;
3335
3336 let routes = extract_routes(source, "main.py");
3338
3339 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3341 assert_eq!(
3342 routes[0].handler_name, "root",
3343 "async def should produce handler_name = 'root'"
3344 );
3345 }
3346
3347 #[test]
3349 fn fa_rt_10_multiple_decorators_on_same_function() {
3350 let source = r#"
3352from fastapi import FastAPI
3353app = FastAPI()
3354
3355def require_auth(func):
3356 return func
3357
3358@app.get("/")
3359@require_auth
3360def root():
3361 return {}
3362"#;
3363
3364 let routes = extract_routes(source, "main.py");
3366
3367 assert_eq!(
3369 routes.len(),
3370 1,
3371 "expected exactly 1 route (non-route decorators ignored), got {:?}",
3372 routes
3373 );
3374 assert_eq!(routes[0].http_method, "GET");
3375 assert_eq!(routes[0].path, "/");
3376 assert_eq!(routes[0].handler_name, "root");
3377 }
3378
3379 #[test]
3381 fn fl_rt_01_blueprint_route_with_post_method() {
3382 let source = r#"
3384from flask import Blueprint
3385
3386bp = Blueprint('auth', __name__)
3387
3388@bp.route('/verify', methods=['POST'])
3389def verify_func():
3390 return {}
3391"#;
3392
3393 let routes = extract_routes(source, "auth.py");
3395
3396 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3398 assert_eq!(routes[0].http_method, "POST");
3399 assert_eq!(routes[0].path, "/verify");
3400 assert_eq!(routes[0].handler_name, "verify_func");
3401 }
3402
3403 #[test]
3405 fn fl_rt_02_blueprint_route_no_methods_defaults_to_get() {
3406 let source = r#"
3408from flask import Blueprint
3409
3410bp = Blueprint('health', __name__)
3411
3412@bp.route('/health')
3413def health_func():
3414 return {}
3415"#;
3416
3417 let routes = extract_routes(source, "health.py");
3419
3420 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3422 assert_eq!(routes[0].http_method, "GET");
3423 assert_eq!(routes[0].path, "/health");
3424 assert_eq!(routes[0].handler_name, "health_func");
3425 }
3426
3427 #[test]
3429 fn fl_rt_03_blueprint_url_prefix_applied() {
3430 let source = r#"
3432from flask import Blueprint
3433
3434bp = Blueprint('auth', __name__, url_prefix='/api/auth')
3435
3436@bp.route('/verify')
3437def verify_func():
3438 return {}
3439"#;
3440
3441 let routes = extract_routes(source, "auth.py");
3443
3444 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3446 assert_eq!(routes[0].http_method, "GET");
3447 assert_eq!(routes[0].path, "/api/auth/verify");
3448 assert_eq!(routes[0].handler_name, "verify_func");
3449 }
3450
3451 #[test]
3453 fn fl_rt_04_blueprint_route_multiple_methods() {
3454 let source = r#"
3456from flask import Blueprint
3457
3458bp = Blueprint('data', __name__)
3459
3460@bp.route('/data', methods=['GET', 'POST'])
3461def data_func():
3462 return {}
3463"#;
3464
3465 let routes = extract_routes(source, "data.py");
3467
3468 assert_eq!(routes.len(), 2, "expected 2 routes, got {:?}", routes);
3470 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
3471 assert!(methods.contains(&"GET"), "missing GET route");
3472 assert!(methods.contains(&"POST"), "missing POST route");
3473 for r in &routes {
3474 assert_eq!(r.path, "/data");
3475 assert_eq!(r.handler_name, "data_func");
3476 }
3477 }
3478
3479 #[test]
3481 fn fl_rt_05_route_with_int_type_param_normalized_to_brace_style() {
3482 let source = r#"
3484from flask import Flask
3485
3486app = Flask(__name__)
3487
3488@app.route('/users/<int:id>')
3489def get_user(id):
3490 return {}
3491"#;
3492
3493 let routes = extract_routes(source, "main.py");
3495
3496 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3498 assert_eq!(
3499 routes[0].path, "/users/{id}",
3500 "Flask <int:id> should be normalized to {{id}}"
3501 );
3502 }
3503
3504 #[test]
3506 fn fl_rt_06_route_with_path_type_param_normalized_to_brace_style() {
3507 let source = r#"
3509from flask import Flask
3510
3511app = Flask(__name__)
3512
3513@app.route('/files/<path:filepath>')
3514def serve_file(filepath):
3515 return {}
3516"#;
3517
3518 let routes = extract_routes(source, "main.py");
3520
3521 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3523 assert_eq!(
3524 routes[0].path, "/files/{filepath}",
3525 "Flask <path:filepath> should be normalized to {{filepath}}"
3526 );
3527 }
3528
3529 #[test]
3531 fn fl_rt_07_route_with_untyped_param_normalized_to_brace_style() {
3532 let source = r#"
3534from flask import Flask
3535
3536app = Flask(__name__)
3537
3538@app.route('/api/<anything>')
3539def catch_all(anything):
3540 return {}
3541"#;
3542
3543 let routes = extract_routes(source, "main.py");
3545
3546 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3548 assert_eq!(
3549 routes[0].path, "/api/{anything}",
3550 "Flask <anything> (untyped) should be normalized to {{anything}}"
3551 );
3552 }
3553}
3554
3555const DJANGO_URL_PATTERN_QUERY: &str = include_str!("../queries/django_url_pattern.scm");
3560static DJANGO_URL_PATTERN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
3561
3562static DJANGO_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
3563static DJANGO_RE_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
3564
3565const HTTP_METHOD_ANY: &str = "ANY";
3566
3567pub fn normalize_django_path(path: &str) -> String {
3571 let re = DJANGO_PATH_RE
3572 .get_or_init(|| regex::Regex::new(r"<(?:\w+:)?(\w+)>").expect("invalid regex"));
3573 re.replace_all(path, ":$1").into_owned()
3574}
3575
3576pub fn normalize_re_path(path: &str) -> String {
3579 let s = path.strip_prefix('^').unwrap_or(path);
3581 let s = s.strip_suffix('$').unwrap_or(s);
3583 let re = DJANGO_RE_PATH_RE
3589 .get_or_init(|| regex::Regex::new(r"\(\?P<(\w+)>[^)]*\)").expect("invalid regex"));
3590 re.replace_all(s, ":$1").into_owned()
3591}
3592
3593pub fn extract_django_routes(source: &str, file_path: &str) -> Vec<Route> {
3595 if source.is_empty() {
3596 return Vec::new();
3597 }
3598
3599 let mut parser = PythonExtractor::parser();
3600 let tree = match parser.parse(source, None) {
3601 Some(t) => t,
3602 None => return Vec::new(),
3603 };
3604 let source_bytes = source.as_bytes();
3605
3606 let query = cached_query(&DJANGO_URL_PATTERN_QUERY_CACHE, DJANGO_URL_PATTERN_QUERY);
3607
3608 let func_idx = query.capture_index_for_name("django.func");
3609 let path_idx = query.capture_index_for_name("django.path");
3610 let handler_idx = query.capture_index_for_name("django.handler");
3611
3612 let mut cursor = QueryCursor::new();
3613 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
3614
3615 let mut routes = Vec::new();
3616 let mut seen = HashSet::new();
3617
3618 while let Some(m) = matches.next() {
3619 let mut func: Option<String> = None;
3620 let mut path_raw: Option<String> = None;
3621 let mut handler: Option<String> = None;
3622
3623 for cap in m.captures {
3624 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
3625 if func_idx == Some(cap.index) {
3626 func = Some(text);
3627 } else if path_idx == Some(cap.index) {
3628 path_raw = Some(text);
3629 } else if handler_idx == Some(cap.index) {
3630 handler = Some(text);
3631 }
3632 }
3633
3634 let (func, path_raw, handler) = match (func, path_raw, handler) {
3635 (Some(f), Some(p), Some(h)) => (f, p, h),
3636 _ => continue,
3637 };
3638
3639 let raw_path = strip_string_quotes(&path_raw);
3640 let normalized = match func.as_str() {
3641 "re_path" => normalize_re_path(&raw_path),
3642 _ => normalize_django_path(&raw_path),
3643 };
3644
3645 let key = (
3647 HTTP_METHOD_ANY.to_string(),
3648 normalized.clone(),
3649 handler.clone(),
3650 );
3651 if !seen.insert(key) {
3652 continue;
3653 }
3654
3655 routes.push(Route {
3656 http_method: HTTP_METHOD_ANY.to_string(),
3657 path: normalized,
3658 handler_name: handler,
3659 file: file_path.to_string(),
3660 });
3661 }
3662
3663 routes
3664}
3665
3666#[cfg(test)]
3671mod django_route_tests {
3672 use super::*;
3673
3674 #[test]
3680 fn dj_np_01_typed_parameter() {
3681 let result = normalize_django_path("users/<int:pk>/");
3685 assert_eq!(result, "users/:pk/");
3686 }
3687
3688 #[test]
3690 fn dj_np_02_untyped_parameter() {
3691 let result = normalize_django_path("users/<pk>/");
3695 assert_eq!(result, "users/:pk/");
3696 }
3697
3698 #[test]
3700 fn dj_np_03_multiple_parameters() {
3701 let result = normalize_django_path("posts/<slug:slug>/comments/<int:id>/");
3705 assert_eq!(result, "posts/:slug/comments/:id/");
3706 }
3707
3708 #[test]
3710 fn dj_np_04_no_parameters() {
3711 let result = normalize_django_path("users/");
3715 assert_eq!(result, "users/");
3716 }
3717
3718 #[test]
3724 fn dj_nr_01_single_named_group() {
3725 let result = normalize_re_path("^articles/(?P<year>[0-9]{4})/$");
3729 assert_eq!(result, "articles/:year/");
3730 }
3731
3732 #[test]
3734 fn dj_nr_02_multiple_named_groups() {
3735 let result = normalize_re_path("^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$");
3739 assert_eq!(result, ":year/:month/");
3740 }
3741
3742 #[test]
3744 fn dj_nr_03_no_named_groups() {
3745 let result = normalize_re_path("^users/$");
3749 assert_eq!(result, "users/");
3750 }
3751
3752 #[test]
3754 fn dj_nr_04_character_class_caret_preserved() {
3755 let result = normalize_re_path("^items/[^/]+/$");
3759 assert_eq!(result, "items/[^/]+/");
3760 }
3761
3762 #[test]
3768 fn dj_rt_01_basic_path_attribute_handler() {
3769 let source = r#"
3771from django.urls import path
3772from . import views
3773
3774urlpatterns = [
3775 path("users/", views.user_list),
3776]
3777"#;
3778 let routes = extract_django_routes(source, "urls.py");
3780
3781 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3783 assert_eq!(routes[0].http_method, "ANY");
3784 assert_eq!(routes[0].path, "users/");
3785 assert_eq!(routes[0].handler_name, "user_list");
3786 }
3787
3788 #[test]
3790 fn dj_rt_02_path_direct_import_handler() {
3791 let source = r#"
3793from django.urls import path
3794from .views import user_list
3795
3796urlpatterns = [
3797 path("users/", user_list),
3798]
3799"#;
3800 let routes = extract_django_routes(source, "urls.py");
3802
3803 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3805 assert_eq!(routes[0].http_method, "ANY");
3806 assert_eq!(routes[0].path, "users/");
3807 assert_eq!(routes[0].handler_name, "user_list");
3808 }
3809
3810 #[test]
3812 fn dj_rt_03_path_typed_parameter() {
3813 let source = r#"
3815from django.urls import path
3816from . import views
3817
3818urlpatterns = [
3819 path("users/<int:pk>/", views.user_detail),
3820]
3821"#;
3822 let routes = extract_django_routes(source, "urls.py");
3824
3825 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3827 assert_eq!(routes[0].path, "users/:pk/");
3828 }
3829
3830 #[test]
3832 fn dj_rt_04_path_untyped_parameter() {
3833 let source = r#"
3835from django.urls import path
3836from . import views
3837
3838urlpatterns = [
3839 path("users/<pk>/", views.user_detail),
3840]
3841"#;
3842 let routes = extract_django_routes(source, "urls.py");
3844
3845 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3847 assert_eq!(routes[0].path, "users/:pk/");
3848 }
3849
3850 #[test]
3852 fn dj_rt_05_re_path_named_group() {
3853 let source = r#"
3855from django.urls import re_path
3856from . import views
3857
3858urlpatterns = [
3859 re_path(r"^articles/(?P<year>[0-9]{4})/$", views.year_archive),
3860]
3861"#;
3862 let routes = extract_django_routes(source, "urls.py");
3864
3865 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3867 assert_eq!(routes[0].path, "articles/:year/");
3868 }
3869
3870 #[test]
3872 fn dj_rt_06_multiple_routes() {
3873 let source = r#"
3875from django.urls import path
3876from . import views
3877
3878urlpatterns = [
3879 path("users/", views.user_list),
3880 path("users/<int:pk>/", views.user_detail),
3881 path("about/", views.about),
3882]
3883"#;
3884 let routes = extract_django_routes(source, "urls.py");
3886
3887 assert_eq!(routes.len(), 3, "expected 3 routes, got {:?}", routes);
3889 for r in &routes {
3890 assert_eq!(r.http_method, "ANY", "expected method ANY for {:?}", r);
3891 }
3892 }
3893
3894 #[test]
3896 fn dj_rt_07_path_with_name_kwarg() {
3897 let source = r#"
3899from django.urls import path
3900from . import views
3901
3902urlpatterns = [
3903 path("login/", views.login_view, name="login"),
3904]
3905"#;
3906 let routes = extract_django_routes(source, "urls.py");
3908
3909 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3911 assert_eq!(routes[0].handler_name, "login_view");
3912 }
3913
3914 #[test]
3916 fn dj_rt_08_empty_source() {
3917 let routes = extract_django_routes("", "urls.py");
3920
3921 assert!(routes.is_empty(), "expected empty Vec for empty source");
3923 }
3924
3925 #[test]
3927 fn dj_rt_09_no_path_calls() {
3928 let source = r#"
3930from django.db import models
3931
3932class User(models.Model):
3933 name = models.CharField(max_length=100)
3934"#;
3935 let routes = extract_django_routes(source, "models.py");
3937
3938 assert!(
3940 routes.is_empty(),
3941 "expected empty Vec for non-URL source, got {:?}",
3942 routes
3943 );
3944 }
3945
3946 #[test]
3948 fn dj_rt_10_deduplication() {
3949 let source = r#"
3951from django.urls import path
3952from . import views
3953
3954urlpatterns = [
3955 path("users/", views.user_list),
3956 path("users/", views.user_list),
3957]
3958"#;
3959 let routes = extract_django_routes(source, "urls.py");
3961
3962 assert_eq!(
3964 routes.len(),
3965 1,
3966 "expected 1 route after dedup, got {:?}",
3967 routes
3968 );
3969 }
3970
3971 #[test]
3973 fn dj_rt_11_include_is_ignored() {
3974 let source = r#"
3976from django.urls import path, include
3977
3978urlpatterns = [
3979 path("api/", include("myapp.urls")),
3980]
3981"#;
3982 let routes = extract_django_routes(source, "urls.py");
3984
3985 assert!(
3987 routes.is_empty(),
3988 "expected empty Vec for include()-only urlpatterns, got {:?}",
3989 routes
3990 );
3991 }
3992
3993 #[test]
3995 fn dj_rt_12_multiple_path_parameters() {
3996 let source = r#"
3998from django.urls import path
3999from . import views
4000
4001urlpatterns = [
4002 path("posts/<slug:slug>/comments/<int:id>/", views.comment_detail),
4003]
4004"#;
4005 let routes = extract_django_routes(source, "urls.py");
4007
4008 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4010 assert_eq!(routes[0].path, "posts/:slug/comments/:id/");
4011 }
4012
4013 #[test]
4015 fn dj_rt_13_re_path_multiple_named_groups() {
4016 let source = r#"
4018from django.urls import re_path
4019from . import views
4020
4021urlpatterns = [
4022 re_path(r"^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$", views.archive),
4023]
4024"#;
4025 let routes = extract_django_routes(source, "urls.py");
4027
4028 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4030 assert_eq!(routes[0].path, ":year/:month/");
4031 }
4032
4033 #[test]
4039 fn dj_rt_e2e_01_observe_django_routes_coverage() {
4040 use tempfile::TempDir;
4041
4042 let dir = TempDir::new().unwrap();
4044 let urls_py = dir.path().join("urls.py");
4045 let test_urls_py = dir.path().join("test_urls.py");
4046
4047 std::fs::write(
4048 &urls_py,
4049 r#"from django.urls import path
4050from . import views
4051
4052urlpatterns = [
4053 path("users/", views.user_list),
4054 path("users/<int:pk>/", views.user_detail),
4055]
4056"#,
4057 )
4058 .unwrap();
4059
4060 std::fs::write(
4061 &test_urls_py,
4062 r#"def test_user_list():
4063 pass
4064
4065def test_user_detail():
4066 pass
4067"#,
4068 )
4069 .unwrap();
4070
4071 let urls_source = std::fs::read_to_string(&urls_py).unwrap();
4073 let urls_path = urls_py.to_string_lossy().into_owned();
4074
4075 let routes = extract_django_routes(&urls_source, &urls_path);
4076
4077 assert_eq!(
4079 routes.len(),
4080 2,
4081 "expected 2 routes extracted from urls.py, got {:?}",
4082 routes
4083 );
4084
4085 for r in &routes {
4087 assert_eq!(r.http_method, "ANY", "expected method ANY, got {:?}", r);
4088 }
4089 }
4090
4091 #[test]
4096 fn py_import_04_e2e_bare_import_wildcard_barrel_mapped() {
4097 use tempfile::TempDir;
4098
4099 let dir = TempDir::new().unwrap();
4102 let pkg = dir.path().join("pkg");
4103 std::fs::create_dir_all(&pkg).unwrap();
4104
4105 std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
4106 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
4107
4108 let tests_dir = dir.path().join("tests");
4109 std::fs::create_dir_all(&tests_dir).unwrap();
4110 let test_content = "import pkg\n\ndef test_foo():\n assert pkg.Foo()\n";
4111 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
4112
4113 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
4114 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
4115
4116 let extractor = PythonExtractor::new();
4117 let production_files = vec![module_path.clone()];
4118 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4119 .into_iter()
4120 .collect();
4121
4122 let result = extractor.map_test_files_with_imports(
4124 &production_files,
4125 &test_sources,
4126 dir.path(),
4127 false,
4128 );
4129
4130 let mapping = result.iter().find(|m| m.production_file == module_path);
4132 assert!(
4133 mapping.is_some(),
4134 "module.py not mapped; bare import + wildcard barrel should resolve. mappings={:?}",
4135 result
4136 );
4137 let mapping = mapping.unwrap();
4138 assert!(
4139 mapping.test_files.contains(&test_path),
4140 "test_foo.py not in test_files for module.py: {:?}",
4141 mapping.test_files
4142 );
4143 }
4144
4145 #[test]
4150 fn py_import_05_e2e_bare_import_named_barrel_mapped() {
4151 use tempfile::TempDir;
4152
4153 let dir = TempDir::new().unwrap();
4156 let pkg = dir.path().join("pkg");
4157 std::fs::create_dir_all(&pkg).unwrap();
4158
4159 std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
4160 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
4161
4162 let tests_dir = dir.path().join("tests");
4163 std::fs::create_dir_all(&tests_dir).unwrap();
4164 let test_content = "import pkg\n\ndef test_foo():\n assert pkg.Foo()\n";
4165 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
4166
4167 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
4168 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
4169
4170 let extractor = PythonExtractor::new();
4171 let production_files = vec![module_path.clone()];
4172 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4173 .into_iter()
4174 .collect();
4175
4176 let result = extractor.map_test_files_with_imports(
4178 &production_files,
4179 &test_sources,
4180 dir.path(),
4181 false,
4182 );
4183
4184 let mapping = result.iter().find(|m| m.production_file == module_path);
4186 assert!(
4187 mapping.is_some(),
4188 "module.py not mapped; bare import + named barrel should resolve. mappings={:?}",
4189 result
4190 );
4191 let mapping = mapping.unwrap();
4192 assert!(
4193 mapping.test_files.contains(&test_path),
4194 "test_foo.py not in test_files for module.py: {:?}",
4195 mapping.test_files
4196 );
4197 }
4198
4199 #[test]
4204 fn py_attr_01_bare_import_single_attribute() {
4205 let source = "import httpx\nhttpx.Client()\n";
4207
4208 let extractor = PythonExtractor::new();
4210 let result = extractor.extract_all_import_specifiers(source);
4211
4212 let entry = result.iter().find(|(spec, _)| spec == "httpx");
4214 assert!(entry.is_some(), "httpx not found in {:?}", result);
4215 let (_, symbols) = entry.unwrap();
4216 assert_eq!(
4217 symbols,
4218 &vec!["Client".to_string()],
4219 "expected [\"Client\"] for bare import with attribute access, got {:?}",
4220 symbols
4221 );
4222 }
4223
4224 #[test]
4229 fn py_attr_02_bare_import_multiple_attributes() {
4230 let source = "import httpx\nhttpx.Client()\nhttpx.get()\n";
4232
4233 let extractor = PythonExtractor::new();
4235 let result = extractor.extract_all_import_specifiers(source);
4236
4237 let entry = result.iter().find(|(spec, _)| spec == "httpx");
4239 assert!(entry.is_some(), "httpx not found in {:?}", result);
4240 let (_, symbols) = entry.unwrap();
4241 assert!(
4242 symbols.contains(&"Client".to_string()),
4243 "Client not in symbols: {:?}",
4244 symbols
4245 );
4246 assert!(
4247 symbols.contains(&"get".to_string()),
4248 "get not in symbols: {:?}",
4249 symbols
4250 );
4251 }
4252
4253 #[test]
4258 fn py_attr_03_bare_import_deduplicated_attributes() {
4259 let source = "import httpx\nhttpx.Client()\nhttpx.Client()\n";
4261
4262 let extractor = PythonExtractor::new();
4264 let result = extractor.extract_all_import_specifiers(source);
4265
4266 let entry = result.iter().find(|(spec, _)| spec == "httpx");
4268 assert!(entry.is_some(), "httpx not found in {:?}", result);
4269 let (_, symbols) = entry.unwrap();
4270 assert_eq!(
4271 symbols,
4272 &vec!["Client".to_string()],
4273 "expected [\"Client\"] with deduplication, got {:?}",
4274 symbols
4275 );
4276 }
4277
4278 #[test]
4288 fn py_attr_04_bare_import_no_attribute_fallback() {
4289 let source = "import httpx\n";
4291
4292 let extractor = PythonExtractor::new();
4294 let result = extractor.extract_all_import_specifiers(source);
4295
4296 let entry = result.iter().find(|(spec, _)| spec == "httpx");
4298 assert!(
4299 entry.is_some(),
4300 "httpx not found in {:?}; bare import without attribute access should be included",
4301 result
4302 );
4303 let (_, symbols) = entry.unwrap();
4304 assert!(
4305 symbols.is_empty(),
4306 "expected empty symbols (fallback) for bare import with no attribute access, got {:?}",
4307 symbols
4308 );
4309 }
4310
4311 #[test]
4322 fn py_attr_05_from_import_regression() {
4323 let source = "from httpx import Client\n";
4325
4326 let extractor = PythonExtractor::new();
4328 let result = extractor.extract_all_import_specifiers(source);
4329
4330 let entry = result.iter().find(|(spec, _)| spec == "httpx");
4332 assert!(entry.is_some(), "httpx not found in {:?}", result);
4333 let (_, symbols) = entry.unwrap();
4334 assert!(
4335 symbols.contains(&"Client".to_string()),
4336 "Client not in symbols: {:?}",
4337 symbols
4338 );
4339 }
4340
4341 #[test]
4347 fn py_attr_06_e2e_attribute_access_narrows_barrel_mapping() {
4348 use tempfile::TempDir;
4349
4350 let dir = TempDir::new().unwrap();
4356 let pkg = dir.path().join("pkg");
4357 std::fs::create_dir_all(&pkg).unwrap();
4358
4359 std::fs::write(
4360 pkg.join("__init__.py"),
4361 "from .mod import Foo\nfrom .bar import Bar\n",
4362 )
4363 .unwrap();
4364 std::fs::write(pkg.join("mod.py"), "def Foo(): pass\n").unwrap();
4365 std::fs::write(pkg.join("bar.py"), "def Bar(): pass\n").unwrap();
4366
4367 let tests_dir = dir.path().join("tests");
4368 std::fs::create_dir_all(&tests_dir).unwrap();
4369 let test_content = "import pkg\npkg.Foo()\n";
4371 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
4372
4373 let mod_path = pkg.join("mod.py").to_string_lossy().into_owned();
4374 let bar_path = pkg.join("bar.py").to_string_lossy().into_owned();
4375 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
4376
4377 let extractor = PythonExtractor::new();
4378 let production_files = vec![mod_path.clone(), bar_path.clone()];
4379 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4380 .into_iter()
4381 .collect();
4382
4383 let result = extractor.map_test_files_with_imports(
4385 &production_files,
4386 &test_sources,
4387 dir.path(),
4388 false,
4389 );
4390
4391 let mod_mapping = result.iter().find(|m| m.production_file == mod_path);
4393 assert!(
4394 mod_mapping.is_some(),
4395 "mod.py not mapped; pkg.Foo() should resolve to mod.py via barrel. mappings={:?}",
4396 result
4397 );
4398 assert!(
4399 mod_mapping.unwrap().test_files.contains(&test_path),
4400 "test_foo.py not in test_files for mod.py: {:?}",
4401 mod_mapping.unwrap().test_files
4402 );
4403
4404 let bar_mapping = result.iter().find(|m| m.production_file == bar_path);
4406 let bar_not_mapped = bar_mapping
4407 .map(|m| !m.test_files.contains(&test_path))
4408 .unwrap_or(true);
4409 assert!(
4410 bar_not_mapped,
4411 "bar.py should NOT be mapped for test_foo.py (pkg.Bar() is not accessed), but got: {:?}",
4412 bar_mapping
4413 );
4414 }
4415
4416 #[test]
4426 fn py_l1x_01_stem_only_fallback_cross_directory() {
4427 use tempfile::TempDir;
4428
4429 let dir = TempDir::new().unwrap();
4434 let pkg = dir.path().join("pkg");
4435 std::fs::create_dir_all(&pkg).unwrap();
4436 let tests_dir = dir.path().join("tests");
4437 std::fs::create_dir_all(&tests_dir).unwrap();
4438
4439 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4440
4441 let test_content = "def test_client():\n pass\n";
4443 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4444
4445 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4446 let test_path = tests_dir
4447 .join("test_client.py")
4448 .to_string_lossy()
4449 .into_owned();
4450
4451 let extractor = PythonExtractor::new();
4452 let production_files = vec![client_path.clone()];
4453 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4454 .into_iter()
4455 .collect();
4456
4457 let result = extractor.map_test_files_with_imports(
4459 &production_files,
4460 &test_sources,
4461 dir.path(),
4462 false,
4463 );
4464
4465 let mapping = result.iter().find(|m| m.production_file == client_path);
4467 assert!(
4468 mapping.is_some(),
4469 "pkg/_client.py not mapped; stem-only fallback should match across directories. mappings={:?}",
4470 result
4471 );
4472 let mapping = mapping.unwrap();
4473 assert!(
4474 mapping.test_files.contains(&test_path),
4475 "test_client.py not in test_files for pkg/_client.py: {:?}",
4476 mapping.test_files
4477 );
4478 }
4479
4480 #[test]
4488 fn py_l1x_02_stem_only_underscore_prefix_prod() {
4489 use tempfile::TempDir;
4490
4491 let dir = TempDir::new().unwrap();
4493 let pkg = dir.path().join("pkg");
4494 std::fs::create_dir_all(&pkg).unwrap();
4495 let tests_dir = dir.path().join("tests");
4496 std::fs::create_dir_all(&tests_dir).unwrap();
4497
4498 std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
4499
4500 let test_content = "def test_decode():\n pass\n";
4502 std::fs::write(tests_dir.join("test_decoders.py"), test_content).unwrap();
4503
4504 let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
4505 let test_path = tests_dir
4506 .join("test_decoders.py")
4507 .to_string_lossy()
4508 .into_owned();
4509
4510 let extractor = PythonExtractor::new();
4511 let production_files = vec![decoders_path.clone()];
4512 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4513 .into_iter()
4514 .collect();
4515
4516 let result = extractor.map_test_files_with_imports(
4518 &production_files,
4519 &test_sources,
4520 dir.path(),
4521 false,
4522 );
4523
4524 let mapping = result.iter().find(|m| m.production_file == decoders_path);
4527 assert!(
4528 mapping.is_some(),
4529 "pkg/_decoders.py not mapped; stem-only fallback should strip _ prefix and match. mappings={:?}",
4530 result
4531 );
4532 let mapping = mapping.unwrap();
4533 assert!(
4534 mapping.test_files.contains(&test_path),
4535 "test_decoders.py not in test_files for pkg/_decoders.py: {:?}",
4536 mapping.test_files
4537 );
4538 }
4539
4540 #[test]
4547 fn py_l1x_03_stem_only_subdirectory_prod() {
4548 use tempfile::TempDir;
4549
4550 let dir = TempDir::new().unwrap();
4552 let transports = dir.path().join("pkg").join("transports");
4553 std::fs::create_dir_all(&transports).unwrap();
4554 let tests_dir = dir.path().join("tests");
4555 std::fs::create_dir_all(&tests_dir).unwrap();
4556
4557 std::fs::write(
4558 transports.join("asgi.py"),
4559 "class ASGITransport:\n pass\n",
4560 )
4561 .unwrap();
4562
4563 let test_content = "def test_asgi_transport():\n pass\n";
4565 std::fs::write(tests_dir.join("test_asgi.py"), test_content).unwrap();
4566
4567 let asgi_path = transports.join("asgi.py").to_string_lossy().into_owned();
4568 let test_path = tests_dir
4569 .join("test_asgi.py")
4570 .to_string_lossy()
4571 .into_owned();
4572
4573 let extractor = PythonExtractor::new();
4574 let production_files = vec![asgi_path.clone()];
4575 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4576 .into_iter()
4577 .collect();
4578
4579 let result = extractor.map_test_files_with_imports(
4581 &production_files,
4582 &test_sources,
4583 dir.path(),
4584 false,
4585 );
4586
4587 let mapping = result.iter().find(|m| m.production_file == asgi_path);
4590 assert!(
4591 mapping.is_some(),
4592 "pkg/transports/asgi.py not mapped; stem 'asgi' should match across directory depth. mappings={:?}",
4593 result
4594 );
4595 let mapping = mapping.unwrap();
4596 assert!(
4597 mapping.test_files.contains(&test_path),
4598 "test_asgi.py not in test_files for pkg/transports/asgi.py: {:?}",
4599 mapping.test_files
4600 );
4601 }
4602
4603 #[test]
4611 fn py_l1x_04_stem_collision_defers_to_l2() {
4612 use tempfile::TempDir;
4613
4614 let dir = TempDir::new().unwrap();
4617 let pkg = dir.path().join("pkg");
4618 let pkg_aio = pkg.join("aio");
4619 std::fs::create_dir_all(&pkg).unwrap();
4620 std::fs::create_dir_all(&pkg_aio).unwrap();
4621 let tests_dir = dir.path().join("tests");
4622 std::fs::create_dir_all(&tests_dir).unwrap();
4623
4624 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
4625 std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n pass\n").unwrap();
4626
4627 let test_content = "def test_client():\n pass\n";
4629 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4630
4631 let client_path = pkg.join("client.py").to_string_lossy().into_owned();
4632 let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
4633 let test_path = tests_dir
4634 .join("test_client.py")
4635 .to_string_lossy()
4636 .into_owned();
4637
4638 let extractor = PythonExtractor::new();
4639 let production_files = vec![client_path.clone(), aio_client_path.clone()];
4640 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4641 .into_iter()
4642 .collect();
4643
4644 let result = extractor.map_test_files_with_imports(
4646 &production_files,
4647 &test_sources,
4648 dir.path(),
4649 false,
4650 );
4651
4652 let client_mapped = result
4654 .iter()
4655 .find(|m| m.production_file == client_path)
4656 .map(|m| m.test_files.contains(&test_path))
4657 .unwrap_or(false);
4658 assert!(
4659 !client_mapped,
4660 "test_client.py should NOT be mapped to pkg/client.py (stem collision -> defer to L2). mappings={:?}",
4661 result
4662 );
4663
4664 let aio_mapped = result
4665 .iter()
4666 .find(|m| m.production_file == aio_client_path)
4667 .map(|m| m.test_files.contains(&test_path))
4668 .unwrap_or(false);
4669 assert!(
4670 !aio_mapped,
4671 "test_client.py should NOT be mapped to pkg/aio/client.py (stem collision -> defer to L2). mappings={:?}",
4672 result
4673 );
4674 }
4675
4676 #[test]
4684 fn py_l1x_05_l1_core_match_suppresses_fallback() {
4685 use tempfile::TempDir;
4686
4687 let dir = TempDir::new().unwrap();
4691 let pkg = dir.path().join("pkg");
4692 let svc = dir.path().join("svc");
4693 std::fs::create_dir_all(&pkg).unwrap();
4694 std::fs::create_dir_all(&svc).unwrap();
4695
4696 std::fs::write(svc.join("client.py"), "class Client:\n pass\n").unwrap();
4697 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
4698
4699 let test_content = "def test_client():\n pass\n";
4701 std::fs::write(svc.join("test_client.py"), test_content).unwrap();
4702
4703 let svc_client_path = svc.join("client.py").to_string_lossy().into_owned();
4704 let pkg_client_path = pkg.join("client.py").to_string_lossy().into_owned();
4705 let test_path = svc.join("test_client.py").to_string_lossy().into_owned();
4706
4707 let extractor = PythonExtractor::new();
4708 let production_files = vec![svc_client_path.clone(), pkg_client_path.clone()];
4709 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4710 .into_iter()
4711 .collect();
4712
4713 let result = extractor.map_test_files_with_imports(
4715 &production_files,
4716 &test_sources,
4717 dir.path(),
4718 false,
4719 );
4720
4721 let svc_client_mapped = result
4723 .iter()
4724 .find(|m| m.production_file == svc_client_path)
4725 .map(|m| m.test_files.contains(&test_path))
4726 .unwrap_or(false);
4727 assert!(
4728 svc_client_mapped,
4729 "test_client.py should be mapped to svc/client.py via L1 core. mappings={:?}",
4730 result
4731 );
4732
4733 let pkg_not_mapped = result
4735 .iter()
4736 .find(|m| m.production_file == pkg_client_path)
4737 .map(|m| !m.test_files.contains(&test_path))
4738 .unwrap_or(true);
4739 assert!(
4740 pkg_not_mapped,
4741 "pkg/client.py should NOT be mapped (L1 core match suppresses stem-only fallback). mappings={:?}",
4742 result
4743 );
4744 }
4745
4746 #[test]
4753 fn py_l1x_06_stem_collision_with_l2_import_resolves_correctly() {
4754 use std::collections::HashMap;
4755 use tempfile::TempDir;
4756
4757 let dir = TempDir::new().unwrap();
4760 let pkg = dir.path().join("pkg");
4761 let pkg_aio = pkg.join("aio");
4762 std::fs::create_dir_all(&pkg).unwrap();
4763 std::fs::create_dir_all(&pkg_aio).unwrap();
4764 let tests_dir = dir.path().join("tests");
4765 std::fs::create_dir_all(&tests_dir).unwrap();
4766
4767 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
4768 std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n pass\n").unwrap();
4769
4770 let test_content =
4772 "from pkg.client import Client\n\ndef test_client():\n assert Client()\n";
4773 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4774
4775 let client_path = pkg.join("client.py").to_string_lossy().into_owned();
4776 let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
4777 let test_path = tests_dir
4778 .join("test_client.py")
4779 .to_string_lossy()
4780 .into_owned();
4781
4782 let extractor = PythonExtractor::new();
4783 let production_files = vec![client_path.clone(), aio_client_path.clone()];
4784 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4785 .into_iter()
4786 .collect();
4787
4788 let result = extractor.map_test_files_with_imports(
4790 &production_files,
4791 &test_sources,
4792 dir.path(),
4793 false,
4794 );
4795
4796 let client_mapping = result.iter().find(|m| m.production_file == client_path);
4798 assert!(
4799 client_mapping.is_some(),
4800 "pkg/client.py not found in mappings: {:?}",
4801 result
4802 );
4803 let client_mapping = client_mapping.unwrap();
4804 assert!(
4805 client_mapping.test_files.contains(&test_path),
4806 "test_client.py should be mapped to pkg/client.py via L2. mappings={:?}",
4807 result
4808 );
4809 assert_eq!(
4810 client_mapping.strategy,
4811 MappingStrategy::ImportTracing,
4812 "strategy should be ImportTracing (L2), got {:?}",
4813 client_mapping.strategy
4814 );
4815
4816 let aio_mapped = result
4818 .iter()
4819 .find(|m| m.production_file == aio_client_path)
4820 .map(|m| m.test_files.contains(&test_path))
4821 .unwrap_or(false);
4822 assert!(
4823 !aio_mapped,
4824 "test_client.py should NOT be mapped to pkg/aio/client.py. mappings={:?}",
4825 result
4826 );
4827 }
4828
4829 #[test]
4836 fn py_l1x_07_stem_collision_with_barrel_import_resolves_correctly() {
4837 use std::collections::HashMap;
4838 use tempfile::TempDir;
4839
4840 let dir = TempDir::new().unwrap();
4844 let pkg = dir.path().join("pkg");
4845 let pkg_aio = pkg.join("aio");
4846 std::fs::create_dir_all(&pkg).unwrap();
4847 std::fs::create_dir_all(&pkg_aio).unwrap();
4848 let tests_dir = dir.path().join("tests");
4849 std::fs::create_dir_all(&tests_dir).unwrap();
4850
4851 std::fs::write(pkg.join("__init__.py"), "from .client import Client\n").unwrap();
4853 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
4854 std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n pass\n").unwrap();
4855
4856 let test_content = "from pkg import Client\n\ndef test_client():\n assert Client()\n";
4858 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4859
4860 let client_path = pkg.join("client.py").to_string_lossy().into_owned();
4861 let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
4862 let test_path = tests_dir
4863 .join("test_client.py")
4864 .to_string_lossy()
4865 .into_owned();
4866
4867 let extractor = PythonExtractor::new();
4868 let production_files = vec![client_path.clone(), aio_client_path.clone()];
4869 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4870 .into_iter()
4871 .collect();
4872
4873 let result = extractor.map_test_files_with_imports(
4875 &production_files,
4876 &test_sources,
4877 dir.path(),
4878 false,
4879 );
4880
4881 let client_mapped = result
4884 .iter()
4885 .find(|m| m.production_file == client_path)
4886 .map(|m| m.test_files.contains(&test_path))
4887 .unwrap_or(false);
4888 assert!(
4889 client_mapped,
4890 "test_client.py should be mapped to pkg/client.py via barrel L2. mappings={:?}",
4891 result
4892 );
4893
4894 let aio_mapped = result
4896 .iter()
4897 .find(|m| m.production_file == aio_client_path)
4898 .map(|m| m.test_files.contains(&test_path))
4899 .unwrap_or(false);
4900 assert!(
4901 !aio_mapped,
4902 "test_client.py should NOT be mapped to pkg/aio/client.py. mappings={:?}",
4903 result
4904 );
4905 }
4906
4907 #[test]
4917 fn py_sup_01_barrel_suppression_l1_matched_no_barrel_fan_out() {
4918 use tempfile::TempDir;
4919
4920 let dir = TempDir::new().unwrap();
4924 let pkg = dir.path().join("pkg");
4925 std::fs::create_dir_all(&pkg).unwrap();
4926 let tests_dir = dir.path().join("tests");
4927 std::fs::create_dir_all(&tests_dir).unwrap();
4928
4929 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4930 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4931 std::fs::write(
4932 pkg.join("__init__.py"),
4933 "from ._client import Client\nfrom ._utils import format_url\n",
4934 )
4935 .unwrap();
4936
4937 let test_content = "import pkg\n\ndef test_client():\n pass\n";
4941 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4942
4943 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4944 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4945 let test_path = tests_dir
4946 .join("test_client.py")
4947 .to_string_lossy()
4948 .into_owned();
4949
4950 let extractor = PythonExtractor::new();
4951 let production_files = vec![client_path.clone(), utils_path.clone()];
4952 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4953 .into_iter()
4954 .collect();
4955
4956 let result = extractor.map_test_files_with_imports(
4958 &production_files,
4959 &test_sources,
4960 dir.path(),
4961 false,
4962 );
4963
4964 let client_mapped = result
4966 .iter()
4967 .find(|m| m.production_file == client_path)
4968 .map(|m| m.test_files.contains(&test_path))
4969 .unwrap_or(false);
4970 assert!(
4971 client_mapped,
4972 "pkg/_client.py should be mapped via L1 stem-only. mappings={:?}",
4973 result
4974 );
4975
4976 let utils_not_mapped = result
4978 .iter()
4979 .find(|m| m.production_file == utils_path)
4980 .map(|m| !m.test_files.contains(&test_path))
4981 .unwrap_or(true);
4982 assert!(
4983 utils_not_mapped,
4984 "pkg/_utils.py should NOT be mapped (barrel suppression for L1-matched test_client.py). mappings={:?}",
4985 result
4986 );
4987 }
4988
4989 #[test]
4996 fn py_sup_02_barrel_suppression_direct_import_still_added() {
4997 use tempfile::TempDir;
4998
4999 let dir = TempDir::new().unwrap();
5005 let pkg = dir.path().join("pkg");
5006 std::fs::create_dir_all(&pkg).unwrap();
5007 let tests_dir = dir.path().join("tests");
5008 std::fs::create_dir_all(&tests_dir).unwrap();
5009
5010 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5011 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
5012 std::fs::write(
5013 pkg.join("__init__.py"),
5014 "from ._client import Client\nfrom ._utils import format_url\n",
5015 )
5016 .unwrap();
5017
5018 let test_content =
5020 "import pkg\nfrom pkg._utils import format_url\n\ndef test_client():\n assert format_url('http://x')\n";
5021 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
5022
5023 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5024 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
5025 let test_path = tests_dir
5026 .join("test_client.py")
5027 .to_string_lossy()
5028 .into_owned();
5029
5030 let extractor = PythonExtractor::new();
5031 let production_files = vec![client_path.clone(), utils_path.clone()];
5032 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5033 .into_iter()
5034 .collect();
5035
5036 let result = extractor.map_test_files_with_imports(
5038 &production_files,
5039 &test_sources,
5040 dir.path(),
5041 false,
5042 );
5043
5044 let utils_mapped = result
5046 .iter()
5047 .find(|m| m.production_file == utils_path)
5048 .map(|m| m.test_files.contains(&test_path))
5049 .unwrap_or(false);
5050 assert!(
5051 utils_mapped,
5052 "pkg/_utils.py should be mapped via direct import (not barrel). mappings={:?}",
5053 result
5054 );
5055 }
5056
5057 #[test]
5064 fn py_sup_03_barrel_suppression_l1_unmatched_gets_barrel() {
5065 use tempfile::TempDir;
5066
5067 let dir = TempDir::new().unwrap();
5071 let pkg = dir.path().join("pkg");
5072 std::fs::create_dir_all(&pkg).unwrap();
5073 let tests_dir = dir.path().join("tests");
5074 std::fs::create_dir_all(&tests_dir).unwrap();
5075
5076 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5077 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
5078 std::fs::write(
5079 pkg.join("__init__.py"),
5080 "from ._client import Client\nfrom ._utils import format_url\n",
5081 )
5082 .unwrap();
5083
5084 let test_content = "import pkg\n\ndef test_exported_members():\n pass\n";
5086 std::fs::write(tests_dir.join("test_exported_members.py"), test_content).unwrap();
5087
5088 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5089 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
5090 let test_path = tests_dir
5091 .join("test_exported_members.py")
5092 .to_string_lossy()
5093 .into_owned();
5094
5095 let extractor = PythonExtractor::new();
5096 let production_files = vec![client_path.clone(), utils_path.clone()];
5097 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5098 .into_iter()
5099 .collect();
5100
5101 let result = extractor.map_test_files_with_imports(
5103 &production_files,
5104 &test_sources,
5105 dir.path(),
5106 false,
5107 );
5108
5109 let client_mapped = result
5112 .iter()
5113 .find(|m| m.production_file == client_path)
5114 .map(|m| m.test_files.contains(&test_path))
5115 .unwrap_or(false);
5116 let utils_mapped = result
5117 .iter()
5118 .find(|m| m.production_file == utils_path)
5119 .map(|m| m.test_files.contains(&test_path))
5120 .unwrap_or(false);
5121
5122 assert!(
5123 client_mapped && utils_mapped,
5124 "L1-unmatched test should fan-out via barrel to both _client.py and _utils.py. client_mapped={}, utils_mapped={}, mappings={:?}",
5125 client_mapped,
5126 utils_mapped,
5127 result
5128 );
5129 }
5130
5131 #[test]
5143 fn py_sup_04_e2e_httpx_like_precision_improvement() {
5144 use tempfile::TempDir;
5145 use HashSet;
5146
5147 let dir = TempDir::new().unwrap();
5155 let pkg = dir.path().join("pkg");
5156 std::fs::create_dir_all(&pkg).unwrap();
5157 let tests_dir = dir.path().join("tests");
5158 std::fs::create_dir_all(&tests_dir).unwrap();
5159
5160 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5161 std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
5162 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
5163 std::fs::write(
5164 pkg.join("__init__.py"),
5165 "from ._client import Client\nfrom ._decoders import decode\nfrom ._utils import format_url\n",
5166 )
5167 .unwrap();
5168
5169 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5170 let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
5171 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
5172 let production_files = vec![
5173 client_path.clone(),
5174 decoders_path.clone(),
5175 utils_path.clone(),
5176 ];
5177
5178 let test_client_content = "import pkg\n\ndef test_client():\n pass\n";
5182 let test_decoders_content = "import pkg\n\ndef test_decode():\n pass\n";
5183 let test_utils_content = "import pkg\n\ndef test_format_url():\n pass\n";
5184 let test_exported_content = "import pkg\n\ndef test_exported_members():\n pass\n";
5185
5186 let test_client_path = tests_dir
5187 .join("test_client.py")
5188 .to_string_lossy()
5189 .into_owned();
5190 let test_decoders_path = tests_dir
5191 .join("test_decoders.py")
5192 .to_string_lossy()
5193 .into_owned();
5194 let test_utils_path = tests_dir
5195 .join("test_utils.py")
5196 .to_string_lossy()
5197 .into_owned();
5198 let test_exported_path = tests_dir
5199 .join("test_exported_members.py")
5200 .to_string_lossy()
5201 .into_owned();
5202
5203 std::fs::write(&test_client_path, test_client_content).unwrap();
5204 std::fs::write(&test_decoders_path, test_decoders_content).unwrap();
5205 std::fs::write(&test_utils_path, test_utils_content).unwrap();
5206 std::fs::write(&test_exported_path, test_exported_content).unwrap();
5207
5208 let test_sources: HashMap<String, String> = [
5209 (test_client_path.clone(), test_client_content.to_string()),
5210 (
5211 test_decoders_path.clone(),
5212 test_decoders_content.to_string(),
5213 ),
5214 (test_utils_path.clone(), test_utils_content.to_string()),
5215 (
5216 test_exported_path.clone(),
5217 test_exported_content.to_string(),
5218 ),
5219 ]
5220 .into_iter()
5221 .collect();
5222
5223 let extractor = PythonExtractor::new();
5224
5225 let result = extractor.map_test_files_with_imports(
5227 &production_files,
5228 &test_sources,
5229 dir.path(),
5230 false,
5231 );
5232
5233 let ground_truth_set: HashSet<(String, String)> = [
5239 (test_client_path.clone(), client_path.clone()),
5240 (test_decoders_path.clone(), decoders_path.clone()),
5241 (test_utils_path.clone(), utils_path.clone()),
5242 (test_exported_path.clone(), client_path.clone()),
5243 (test_exported_path.clone(), decoders_path.clone()),
5244 (test_exported_path.clone(), utils_path.clone()),
5245 ]
5246 .into_iter()
5247 .collect();
5248
5249 let actual_pairs: HashSet<(String, String)> = result
5250 .iter()
5251 .flat_map(|m| {
5252 m.test_files
5253 .iter()
5254 .map(|t| (t.clone(), m.production_file.clone()))
5255 .collect::<Vec<_>>()
5256 })
5257 .collect();
5258
5259 let tp = actual_pairs.intersection(&ground_truth_set).count();
5260 let fp = actual_pairs.difference(&ground_truth_set).count();
5261
5262 let precision = if tp + fp == 0 {
5264 0.0
5265 } else {
5266 tp as f64 / (tp + fp) as f64
5267 };
5268
5269 assert!(
5274 precision >= 0.80,
5275 "Precision {:.1}% < 80% target. TP={}, FP={}, actual_pairs={:?}",
5276 precision * 100.0,
5277 tp,
5278 fp,
5279 actual_pairs
5280 );
5281 }
5282
5283 #[test]
5288 fn py_af_01_assert_via_assigned_var() {
5289 let source = r#"
5291from pkg.client import Client
5292
5293def test_something():
5294 client = Client()
5295 assert client.ok
5296"#;
5297 let result = extract_assertion_referenced_imports(source);
5299
5300 assert!(
5302 result.contains("Client"),
5303 "Client should be in asserted_imports; got {:?}",
5304 result
5305 );
5306 }
5307
5308 #[test]
5313 fn py_af_02_setup_only_import_excluded() {
5314 let source = r#"
5316from pkg.client import Client
5317from pkg.transport import MockTransport
5318
5319def test_something():
5320 transport = MockTransport()
5321 client = Client(transport=transport)
5322 assert client.ok
5323"#;
5324 let result = extract_assertion_referenced_imports(source);
5326
5327 assert!(
5329 !result.contains("MockTransport"),
5330 "MockTransport should NOT be in asserted_imports (setup-only); got {:?}",
5331 result
5332 );
5333 assert!(
5335 result.contains("Client"),
5336 "Client should be in asserted_imports; got {:?}",
5337 result
5338 );
5339 }
5340
5341 #[test]
5346 fn py_af_03_direct_call_in_assertion() {
5347 let source = r#"
5349from pkg.models import A, B
5350
5351def test_equality():
5352 assert A() == B()
5353"#;
5354 let result = extract_assertion_referenced_imports(source);
5356
5357 assert!(
5359 result.contains("A"),
5360 "A should be in asserted_imports (used directly in assert); got {:?}",
5361 result
5362 );
5363 assert!(
5364 result.contains("B"),
5365 "B should be in asserted_imports (used directly in assert); got {:?}",
5366 result
5367 );
5368 }
5369
5370 #[test]
5375 fn py_af_04_pytest_raises_captures_exception_class() {
5376 let source = r#"
5378import pytest
5379from pkg.exceptions import HTTPError
5380
5381def test_raises():
5382 with pytest.raises(HTTPError):
5383 raise HTTPError("fail")
5384"#;
5385 let result = extract_assertion_referenced_imports(source);
5387
5388 assert!(
5390 result.contains("HTTPError"),
5391 "HTTPError should be in asserted_imports (pytest.raises arg); got {:?}",
5392 result
5393 );
5394 }
5395
5396 #[test]
5402 fn py_af_05_chain_tracking_two_hops() {
5403 let source = r#"
5405from pkg.client import Client
5406
5407def test_response():
5408 client = Client()
5409 response = client.get("http://example.com/")
5410 assert response.ok
5411"#;
5412 let result = extract_assertion_referenced_imports(source);
5414
5415 assert!(
5417 result.contains("Client"),
5418 "Client should be in asserted_imports via 2-hop chain; got {:?}",
5419 result
5420 );
5421 }
5422
5423 #[test]
5429 fn py_af_06a_no_assertions_returns_empty() {
5430 let source = r#"
5432from pkg.client import Client
5433from pkg.transport import MockTransport
5434
5435def test_setup_no_assert():
5436 client = Client()
5437 transport = MockTransport()
5438 # No assert statement at all
5439"#;
5440 let result = extract_assertion_referenced_imports(source);
5442
5443 assert!(
5446 result.is_empty(),
5447 "expected empty asserted_imports when no assertions present; got {:?}",
5448 result
5449 );
5450 }
5451
5452 #[test]
5458 fn py_af_06b_assertion_exists_but_no_import_intersection() {
5459 let source = r#"
5461from pkg.client import Client
5462
5463def test_local_only():
5464 local_value = 42
5465 # Assertion references only a local literal, not any imported symbol
5466 assert local_value == 42
5467"#;
5468 let result = extract_assertion_referenced_imports(source);
5470
5471 assert!(
5474 !result.contains("Client"),
5475 "Client should NOT be in asserted_imports (not referenced in assertion); got {:?}",
5476 result
5477 );
5478 }
5481
5482 #[test]
5488 fn py_af_07_unittest_self_assert() {
5489 let source = r#"
5491import unittest
5492from pkg.models import MyModel
5493
5494class TestMyModel(unittest.TestCase):
5495 def test_value(self):
5496 result = MyModel()
5497 self.assertEqual(result.value, 42)
5498"#;
5499 let result = extract_assertion_referenced_imports(source);
5501
5502 assert!(
5504 result.contains("MyModel"),
5505 "MyModel should be in asserted_imports via self.assertEqual; got {:?}",
5506 result
5507 );
5508 }
5509
5510 #[test]
5519 fn py_af_08_e2e_primary_kept_incidental_filtered() {
5520 use std::path::PathBuf;
5521 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5522 .parent()
5523 .unwrap()
5524 .parent()
5525 .unwrap()
5526 .join("tests/fixtures/python/observe/af_pkg");
5527
5528 let test_file = fixture_root
5529 .join("tests/test_client.py")
5530 .to_string_lossy()
5531 .into_owned();
5532 let client_prod = fixture_root
5533 .join("pkg/client.py")
5534 .to_string_lossy()
5535 .into_owned();
5536 let transport_prod = fixture_root
5537 .join("pkg/transport.py")
5538 .to_string_lossy()
5539 .into_owned();
5540
5541 let production_files = vec![client_prod.clone(), transport_prod.clone()];
5542 let test_source =
5543 std::fs::read_to_string(&test_file).expect("fixture test file must exist");
5544 let mut test_sources = HashMap::new();
5545 test_sources.insert(test_file.clone(), test_source);
5546
5547 let extractor = PythonExtractor::new();
5549 let result = extractor.map_test_files_with_imports(
5550 &production_files,
5551 &test_sources,
5552 &fixture_root,
5553 false,
5554 );
5555
5556 let client_mapping = result.iter().find(|m| m.production_file == client_prod);
5558 assert!(
5559 client_mapping.is_some(),
5560 "client.py should be in mappings; got {:?}",
5561 result
5562 .iter()
5563 .map(|m| &m.production_file)
5564 .collect::<Vec<_>>()
5565 );
5566 assert!(
5567 client_mapping.unwrap().test_files.contains(&test_file),
5568 "test_client.py should map to client.py"
5569 );
5570
5571 let transport_mapping = result.iter().find(|m| m.production_file == transport_prod);
5573 let transport_maps_test = transport_mapping
5574 .map(|m| m.test_files.contains(&test_file))
5575 .unwrap_or(false);
5576 assert!(
5577 !transport_maps_test,
5578 "test_client.py should NOT map to transport.py (assertion filter); got {:?}",
5579 result
5580 .iter()
5581 .map(|m| (&m.production_file, &m.test_files))
5582 .collect::<Vec<_>>()
5583 );
5584 }
5585
5586 #[test]
5596 fn py_af_09_e2e_all_incidental_fallback_no_fn() {
5597 use std::path::PathBuf;
5598 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5599 .parent()
5600 .unwrap()
5601 .parent()
5602 .unwrap()
5603 .join("tests/fixtures/python/observe/af_e2e_fallback");
5604
5605 let test_file = fixture_root
5606 .join("tests/test_helpers.py")
5607 .to_string_lossy()
5608 .into_owned();
5609 let helpers_prod = fixture_root
5610 .join("pkg/helpers.py")
5611 .to_string_lossy()
5612 .into_owned();
5613
5614 let production_files = vec![helpers_prod.clone()];
5615 let test_source =
5616 std::fs::read_to_string(&test_file).expect("fixture test file must exist");
5617 let mut test_sources = HashMap::new();
5618 test_sources.insert(test_file.clone(), test_source);
5619
5620 let extractor = PythonExtractor::new();
5622 let result = extractor.map_test_files_with_imports(
5623 &production_files,
5624 &test_sources,
5625 &fixture_root,
5626 false,
5627 );
5628
5629 let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
5631 assert!(
5632 helpers_mapping.is_some(),
5633 "helpers.py should be in mappings (fallback); got {:?}",
5634 result
5635 .iter()
5636 .map(|m| &m.production_file)
5637 .collect::<Vec<_>>()
5638 );
5639 assert!(
5640 helpers_mapping.unwrap().test_files.contains(&test_file),
5641 "test_helpers.py should map to helpers.py (fallback, no FN)"
5642 );
5643 }
5644
5645 #[test]
5659 fn py_af_10_e2e_http_client_primary_mapped() {
5660 use std::path::PathBuf;
5661 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5662 .parent()
5663 .unwrap()
5664 .parent()
5665 .unwrap()
5666 .join("tests/fixtures/python/observe/af_e2e_http");
5667
5668 let test_file = fixture_root
5669 .join("tests/test_http_client.py")
5670 .to_string_lossy()
5671 .into_owned();
5672 let http_client_prod = fixture_root
5673 .join("pkg/http_client.py")
5674 .to_string_lossy()
5675 .into_owned();
5676 let exceptions_prod = fixture_root
5677 .join("pkg/exceptions.py")
5678 .to_string_lossy()
5679 .into_owned();
5680
5681 let production_files = vec![http_client_prod.clone(), exceptions_prod.clone()];
5682 let test_source =
5683 std::fs::read_to_string(&test_file).expect("fixture test file must exist");
5684 let mut test_sources = HashMap::new();
5685 test_sources.insert(test_file.clone(), test_source);
5686
5687 let extractor = PythonExtractor::new();
5689 let result = extractor.map_test_files_with_imports(
5690 &production_files,
5691 &test_sources,
5692 &fixture_root,
5693 false,
5694 );
5695
5696 let http_client_mapping = result
5698 .iter()
5699 .find(|m| m.production_file == http_client_prod);
5700 assert!(
5701 http_client_mapping.is_some(),
5702 "http_client.py should be in mappings; got {:?}",
5703 result
5704 .iter()
5705 .map(|m| &m.production_file)
5706 .collect::<Vec<_>>()
5707 );
5708 assert!(
5709 http_client_mapping.unwrap().test_files.contains(&test_file),
5710 "test_http_client.py should map to http_client.py (primary SUT)"
5711 );
5712 }
5713
5714 #[test]
5718 fn py_e2e_helper_excluded_from_mappings() {
5719 let tmp = tempfile::tempdir().unwrap();
5722 let root = tmp.path();
5723
5724 let files: &[(&str, &str)] = &[
5726 ("pkg/__init__.py", ""),
5727 ("pkg/client.py", "class Client:\n def connect(self):\n return True\n"),
5728 ("tests/__init__.py", ""),
5729 ("tests/helpers.py", "def mock_client():\n return \"mock\"\n"),
5730 (
5731 "tests/test_client.py",
5732 "from pkg.client import Client\nfrom tests.helpers import mock_client\n\ndef test_connect():\n client = Client()\n assert client.connect()\n\ndef test_with_mock():\n mc = mock_client()\n assert mc == \"mock\"\n",
5733 ),
5734 ];
5735 for (rel, content) in files {
5736 let path = root.join(rel);
5737 if let Some(parent) = path.parent() {
5738 std::fs::create_dir_all(parent).unwrap();
5739 }
5740 std::fs::write(&path, content).unwrap();
5741 }
5742
5743 let extractor = PythonExtractor::new();
5744
5745 let client_abs = root.join("pkg/client.py").to_string_lossy().into_owned();
5748 let helpers_abs = root.join("tests/helpers.py").to_string_lossy().into_owned();
5749 let production_files = vec![client_abs.clone(), helpers_abs.clone()];
5750
5751 let test_abs = root
5752 .join("tests/test_client.py")
5753 .to_string_lossy()
5754 .into_owned();
5755 let test_content = "from pkg.client import Client\nfrom tests.helpers import mock_client\n\ndef test_connect():\n client = Client()\n assert client.connect()\n\ndef test_with_mock():\n mc = mock_client()\n assert mc == \"mock\"\n";
5756 let test_sources: HashMap<String, String> = [(test_abs.clone(), test_content.to_string())]
5757 .into_iter()
5758 .collect();
5759
5760 let mappings =
5762 extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5763
5764 for m in &mappings {
5766 assert!(
5767 !m.production_file.contains("helpers.py"),
5768 "helpers.py should be excluded as test helper, but found in mapping: {:?}",
5769 m
5770 );
5771 }
5772
5773 let client_mapping = mappings
5775 .iter()
5776 .find(|m| m.production_file.contains("client.py"));
5777 assert!(
5778 client_mapping.is_some(),
5779 "pkg/client.py should be mapped; got {:?}",
5780 mappings
5781 .iter()
5782 .map(|m| &m.production_file)
5783 .collect::<Vec<_>>()
5784 );
5785 let client_mapping = client_mapping.unwrap();
5786 assert!(
5787 client_mapping
5788 .test_files
5789 .iter()
5790 .any(|t| t.contains("test_client.py")),
5791 "pkg/client.py should map to test_client.py; got {:?}",
5792 client_mapping.test_files
5793 );
5794 }
5795
5796 #[test]
5802 fn py_fp_01_mock_transport_fixture_not_mapped() {
5803 use tempfile::TempDir;
5804
5805 let dir = TempDir::new().unwrap();
5806 let root = dir.path();
5807 let pkg = root.join("pkg");
5808 let transports = pkg.join("_transports");
5809 let tests_dir = root.join("tests");
5810 std::fs::create_dir_all(&transports).unwrap();
5811 std::fs::create_dir_all(&tests_dir).unwrap();
5812
5813 std::fs::write(
5814 transports.join("mock.py"),
5815 "class MockTransport:\n pass\n",
5816 )
5817 .unwrap();
5818 std::fs::write(
5819 transports.join("__init__.py"),
5820 "from .mock import MockTransport\n",
5821 )
5822 .unwrap();
5823 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5824 std::fs::write(
5825 pkg.join("__init__.py"),
5826 "from ._transports import *\nfrom ._client import Client\n",
5827 )
5828 .unwrap();
5829
5830 let test_content = "import pkg\n\ndef test_hooks():\n client = pkg.Client(transport=pkg.MockTransport())\n assert client is not None\n";
5831 std::fs::write(tests_dir.join("test_hooks.py"), test_content).unwrap();
5832
5833 let mock_path = transports.join("mock.py").to_string_lossy().into_owned();
5834 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5835 let test_path = tests_dir
5836 .join("test_hooks.py")
5837 .to_string_lossy()
5838 .into_owned();
5839
5840 let extractor = PythonExtractor::new();
5841 let production_files = vec![mock_path.clone(), client_path.clone()];
5842 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5843 .into_iter()
5844 .collect();
5845
5846 let result =
5847 extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5848
5849 let mock_mapping = result.iter().find(|m| m.production_file == mock_path);
5850 assert!(
5851 mock_mapping.is_none() || mock_mapping.unwrap().test_files.is_empty(),
5852 "mock.py should NOT be mapped (fixture); mappings={:?}",
5853 result
5854 );
5855 }
5856
5857 #[test]
5863 fn py_fp_02_version_py_incidental_not_mapped() {
5864 use tempfile::TempDir;
5865
5866 let dir = TempDir::new().unwrap();
5867 let root = dir.path();
5868 let pkg = root.join("pkg");
5869 let tests_dir = root.join("tests");
5870 std::fs::create_dir_all(&pkg).unwrap();
5871 std::fs::create_dir_all(&tests_dir).unwrap();
5872
5873 std::fs::write(pkg.join("__version__.py"), "__version__ = \"1.0.0\"\n").unwrap();
5874 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5875 std::fs::write(
5876 pkg.join("__init__.py"),
5877 "from .__version__ import __version__\nfrom ._client import Client\n",
5878 )
5879 .unwrap();
5880
5881 let test_content = "import pkg\n\ndef test_headers():\n expected = f\"python-pkg/{pkg.__version__}\"\n assert expected == \"python-pkg/1.0.0\"\n";
5882 std::fs::write(tests_dir.join("test_headers.py"), test_content).unwrap();
5883
5884 let version_path = pkg.join("__version__.py").to_string_lossy().into_owned();
5885 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5886 let test_path = tests_dir
5887 .join("test_headers.py")
5888 .to_string_lossy()
5889 .into_owned();
5890
5891 let extractor = PythonExtractor::new();
5892 let production_files = vec![version_path.clone(), client_path.clone()];
5893 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5894 .into_iter()
5895 .collect();
5896
5897 let result =
5898 extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5899
5900 let version_mapping = result.iter().find(|m| m.production_file == version_path);
5901 assert!(
5902 version_mapping.is_none() || version_mapping.unwrap().test_files.is_empty(),
5903 "__version__.py should NOT be mapped (metadata); mappings={:?}",
5904 result
5905 );
5906 }
5907
5908 #[test]
5914 fn py_fp_03_types_py_annotation_not_mapped() {
5915 use tempfile::TempDir;
5916
5917 let dir = TempDir::new().unwrap();
5918 let root = dir.path();
5919 let pkg = root.join("pkg");
5920 let tests_dir = root.join("tests");
5921 std::fs::create_dir_all(&pkg).unwrap();
5922 std::fs::create_dir_all(&tests_dir).unwrap();
5923
5924 std::fs::write(
5925 pkg.join("_types.py"),
5926 "from typing import Union\nQueryParamTypes = Union[str, dict]\n",
5927 )
5928 .unwrap();
5929 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5930 std::fs::write(
5931 pkg.join("__init__.py"),
5932 "from ._types import *\nfrom ._client import Client\n",
5933 )
5934 .unwrap();
5935
5936 let test_content = "import pkg\n\ndef test_client():\n client = pkg.Client()\n assert client is not None\n";
5937 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
5938
5939 let types_path = pkg.join("_types.py").to_string_lossy().into_owned();
5940 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5941 let test_path = tests_dir
5942 .join("test_client.py")
5943 .to_string_lossy()
5944 .into_owned();
5945
5946 let extractor = PythonExtractor::new();
5947 let production_files = vec![types_path.clone(), client_path.clone()];
5948 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5949 .into_iter()
5950 .collect();
5951
5952 let result =
5953 extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5954
5955 let types_mapping = result.iter().find(|m| m.production_file == types_path);
5956 assert!(
5957 types_mapping.is_none() || types_mapping.unwrap().test_files.is_empty(),
5958 "_types.py should NOT be mapped (type definitions); mappings={:?}",
5959 result
5960 );
5961 }
5962
5963 #[test]
5967 fn py_resolve_priority_01_file_wins_over_package() {
5968 let tmp = tempfile::tempdir().unwrap();
5970 let baz_dir = tmp.path().join("foo").join("bar").join("baz");
5971 std::fs::create_dir_all(&baz_dir).unwrap();
5972 let baz_file = tmp.path().join("foo").join("bar").join("baz.py");
5973 std::fs::write(&baz_file, "class Baz: pass\n").unwrap();
5974 let baz_init = baz_dir.join("__init__.py");
5975 std::fs::write(&baz_init, "from .impl import Baz\n").unwrap();
5976
5977 let canonical_root = tmp.path().canonicalize().unwrap();
5978 let base = tmp.path().join("foo").join("bar").join("baz");
5979 let extractor = PythonExtractor::new();
5980
5981 let result =
5983 exspec_core::observe::resolve_absolute_base_to_file(&extractor, &base, &canonical_root);
5984
5985 assert!(result.is_some(), "expected resolution, got None");
5987 let resolved = result.unwrap();
5988 assert!(
5989 resolved.ends_with("baz.py"),
5990 "expected baz.py (file wins over package), got: {resolved}"
5991 );
5992 assert!(
5993 !resolved.contains("__init__"),
5994 "should NOT resolve to __init__.py, got: {resolved}"
5995 );
5996 }
5997
5998 #[test]
6006 fn py_submod_01_direct_import_bypasses_assertion_filter() {
6007 use std::collections::HashMap;
6008 use tempfile::TempDir;
6009
6010 let dir = TempDir::new().unwrap();
6017 let pkg = dir.path().join("pkg");
6018 std::fs::create_dir_all(&pkg).unwrap();
6019 let tests_dir = dir.path().join("tests");
6020 std::fs::create_dir_all(&tests_dir).unwrap();
6021
6022 std::fs::write(pkg.join("_urlparse.py"), "def normalize(url): return url\n").unwrap();
6023 std::fs::write(pkg.join("_client.py"), "class URL:\n pass\n").unwrap();
6024 std::fs::write(pkg.join("__init__.py"), "from ._client import URL\n").unwrap();
6026
6027 let test_content = "from pkg._urlparse import normalize\nimport pkg\n\ndef test_url():\n assert pkg.URL(\"http://example.com\")\n";
6028 std::fs::write(tests_dir.join("test_whatwg.py"), test_content).unwrap();
6029
6030 let urlparse_path = pkg.join("_urlparse.py").to_string_lossy().into_owned();
6031 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
6032 let test_path = tests_dir
6033 .join("test_whatwg.py")
6034 .to_string_lossy()
6035 .into_owned();
6036
6037 let extractor = PythonExtractor::new();
6038 let production_files = vec![urlparse_path.clone(), client_path.clone()];
6039 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6040 .into_iter()
6041 .collect();
6042
6043 let result = extractor.map_test_files_with_imports(
6045 &production_files,
6046 &test_sources,
6047 dir.path(),
6048 false,
6049 );
6050
6051 let urlparse_mapped = result
6053 .iter()
6054 .find(|m| m.production_file == urlparse_path)
6055 .map(|m| m.test_files.contains(&test_path))
6056 .unwrap_or(false);
6057 assert!(
6058 urlparse_mapped,
6059 "pkg/_urlparse.py should be mapped via direct import (assertion filter bypass). mappings={:?}",
6060 result
6061 );
6062 }
6063
6064 #[test]
6071 fn py_submod_02_unre_exported_direct_import_mapped() {
6072 use std::collections::HashMap;
6073 use tempfile::TempDir;
6074
6075 let dir = TempDir::new().unwrap();
6080 let pkg = dir.path().join("pkg");
6081 std::fs::create_dir_all(&pkg).unwrap();
6082 let tests_dir = dir.path().join("tests");
6083 std::fs::create_dir_all(&tests_dir).unwrap();
6084
6085 std::fs::write(pkg.join("_internal.py"), "def helper(): return True\n").unwrap();
6086 std::fs::write(pkg.join("__init__.py"), "# empty barrel\n").unwrap();
6088
6089 let test_content =
6090 "from pkg._internal import helper\n\ndef test_it():\n assert helper()\n";
6091 std::fs::write(tests_dir.join("test_internal.py"), test_content).unwrap();
6092
6093 let internal_path = pkg.join("_internal.py").to_string_lossy().into_owned();
6094 let test_path = tests_dir
6095 .join("test_internal.py")
6096 .to_string_lossy()
6097 .into_owned();
6098
6099 let extractor = PythonExtractor::new();
6100 let production_files = vec![internal_path.clone()];
6101 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6102 .into_iter()
6103 .collect();
6104
6105 let result = extractor.map_test_files_with_imports(
6107 &production_files,
6108 &test_sources,
6109 dir.path(),
6110 false,
6111 );
6112
6113 let internal_mapped = result
6115 .iter()
6116 .find(|m| m.production_file == internal_path)
6117 .map(|m| m.test_files.contains(&test_path))
6118 .unwrap_or(false);
6119 assert!(
6120 internal_mapped,
6121 "pkg/_internal.py should be mapped via direct import. mappings={:?}",
6122 result
6123 );
6124 }
6125
6126 #[test]
6133 fn py_submod_03_nested_submodule_direct_import_mapped() {
6134 use std::collections::HashMap;
6135 use tempfile::TempDir;
6136
6137 let dir = TempDir::new().unwrap();
6142 let pkg = dir.path().join("pkg");
6143 let internal = pkg.join("_internal");
6144 std::fs::create_dir_all(&internal).unwrap();
6145 let tests_dir = dir.path().join("tests");
6146 std::fs::create_dir_all(&tests_dir).unwrap();
6147
6148 std::fs::write(internal.join("_helpers.py"), "def util(): return True\n").unwrap();
6149 std::fs::write(internal.join("__init__.py"), "# empty\n").unwrap();
6150 std::fs::write(pkg.join("__init__.py"), "# empty barrel\n").unwrap();
6151
6152 let test_content =
6153 "from pkg._internal._helpers import util\n\ndef test_util():\n assert util()\n";
6154 std::fs::write(tests_dir.join("test_helpers.py"), test_content).unwrap();
6155
6156 let helpers_path = internal.join("_helpers.py").to_string_lossy().into_owned();
6157 let test_path = tests_dir
6158 .join("test_helpers.py")
6159 .to_string_lossy()
6160 .into_owned();
6161
6162 let extractor = PythonExtractor::new();
6163 let production_files = vec![helpers_path.clone()];
6164 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6165 .into_iter()
6166 .collect();
6167
6168 let result = extractor.map_test_files_with_imports(
6170 &production_files,
6171 &test_sources,
6172 dir.path(),
6173 false,
6174 );
6175
6176 let helpers_mapped = result
6178 .iter()
6179 .find(|m| m.production_file == helpers_path)
6180 .map(|m| m.test_files.contains(&test_path))
6181 .unwrap_or(false);
6182 assert!(
6183 helpers_mapped,
6184 "pkg/_internal/_helpers.py should be mapped via nested direct import. mappings={:?}",
6185 result
6186 );
6187 }
6188
6189 #[test]
6199 fn py_submod_05_non_bare_relative_direct_import_bypass() {
6200 use std::collections::HashMap;
6201 use tempfile::TempDir;
6202
6203 let dir = TempDir::new().unwrap();
6216 let pkg = dir.path().join("pkg");
6217 std::fs::create_dir_all(&pkg).unwrap();
6218
6219 std::fs::write(pkg.join("_config.py"), "class Config:\n pass\n").unwrap();
6220 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
6221 std::fs::write(pkg.join("__init__.py"), "from ._client import Client\n").unwrap();
6223
6224 let test_content = "import pkg\nfrom ._config import Config\n\ndef test_something():\n assert pkg.Client()\n";
6225 std::fs::write(pkg.join("test_app.py"), test_content).unwrap();
6226
6227 let config_path = pkg.join("_config.py").to_string_lossy().into_owned();
6228 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
6229 let test_path = pkg.join("test_app.py").to_string_lossy().into_owned();
6230
6231 let extractor = PythonExtractor::new();
6232 let production_files = vec![config_path.clone(), client_path.clone()];
6233 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6234 .into_iter()
6235 .collect();
6236
6237 let result = extractor.map_test_files_with_imports(
6239 &production_files,
6240 &test_sources,
6241 dir.path(),
6242 false,
6243 );
6244
6245 let config_mapped = result
6247 .iter()
6248 .find(|m| m.production_file == config_path)
6249 .map(|m| m.test_files.contains(&test_path))
6250 .unwrap_or(false);
6251 assert!(
6252 config_mapped,
6253 "pkg/_config.py should be mapped via non-bare relative direct import (assertion filter bypass). mappings={:?}",
6254 result
6255 );
6256 }
6257
6258 #[test]
6268 fn py_submod_06_bare_relative_direct_import_bypass() {
6269 use std::collections::HashMap;
6270 use tempfile::TempDir;
6271
6272 let dir = TempDir::new().unwrap();
6285 let pkg = dir.path().join("pkg");
6286 std::fs::create_dir_all(&pkg).unwrap();
6287
6288 std::fs::write(pkg.join("utils.py"), "def helper(): return True\n").unwrap();
6289 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
6290 std::fs::write(pkg.join("__init__.py"), "from ._client import Client\n").unwrap();
6292
6293 let test_content =
6294 "import pkg\nfrom . import utils\n\ndef test_something():\n assert pkg.Client()\n";
6295 std::fs::write(pkg.join("test_app.py"), test_content).unwrap();
6296
6297 let utils_path = pkg.join("utils.py").to_string_lossy().into_owned();
6298 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
6299 let test_path = pkg.join("test_app.py").to_string_lossy().into_owned();
6300
6301 let extractor = PythonExtractor::new();
6302 let production_files = vec![utils_path.clone(), client_path.clone()];
6303 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6304 .into_iter()
6305 .collect();
6306
6307 let result = extractor.map_test_files_with_imports(
6309 &production_files,
6310 &test_sources,
6311 dir.path(),
6312 false,
6313 );
6314
6315 let utils_mapped = result
6317 .iter()
6318 .find(|m| m.production_file == utils_path)
6319 .map(|m| m.test_files.contains(&test_path))
6320 .unwrap_or(false);
6321 assert!(
6322 utils_mapped,
6323 "pkg/utils.py should be mapped via bare relative direct import (assertion filter bypass). mappings={:?}",
6324 result
6325 );
6326 }
6327
6328 #[test]
6336 fn py_submod_04_regression_barrel_only_assertion_filter_preserved() {
6337 use std::collections::HashMap;
6338 use tempfile::TempDir;
6339
6340 let dir = TempDir::new().unwrap();
6347 let pkg = dir.path().join("pkg");
6348 std::fs::create_dir_all(&pkg).unwrap();
6349 let tests_dir = dir.path().join("tests");
6350 std::fs::create_dir_all(&tests_dir).unwrap();
6351
6352 std::fs::write(pkg.join("_config.py"), "class Config:\n pass\n").unwrap();
6353 std::fs::write(pkg.join("_models.py"), "class Model:\n pass\n").unwrap();
6354 std::fs::write(
6356 pkg.join("__init__.py"),
6357 "from ._config import Config\nfrom ._models import Model\n",
6358 )
6359 .unwrap();
6360
6361 let test_content = "import pkg\n\ndef test_foo():\n assert pkg.Config()\n";
6362 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
6363
6364 let config_path = pkg.join("_config.py").to_string_lossy().into_owned();
6365 let models_path = pkg.join("_models.py").to_string_lossy().into_owned();
6366 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
6367
6368 let extractor = PythonExtractor::new();
6369 let production_files = vec![config_path.clone(), models_path.clone()];
6370 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6371 .into_iter()
6372 .collect();
6373
6374 let result = extractor.map_test_files_with_imports(
6376 &production_files,
6377 &test_sources,
6378 dir.path(),
6379 false,
6380 );
6381
6382 let models_not_mapped = result
6384 .iter()
6385 .find(|m| m.production_file == models_path)
6386 .map(|m| !m.test_files.contains(&test_path))
6387 .unwrap_or(true);
6388 assert!(
6389 models_not_mapped,
6390 "pkg/_models.py should NOT be mapped (barrel import, no assertion on Model). mappings={:?}",
6391 result
6392 );
6393 }
6394}