Skip to main content

nusy_codegraph/
ingest.rs

1//! Batch ingestion — walk a directory tree, parse all Python files, build Arrow tables.
2//!
3//! Two ingestion modes are available:
4//!
5//! - **Generic** (`ingest_directory` / `ingest_files`): uses `parser::parse_python_file`,
6//!   emitting language-agnostic `CodeNodeKind` variants (File, Module, Class, Function, etc.).
7//!   Backward-compatible with existing callers.
8//!
9//! - **Python-specific** (`ingest_python_directory` / `Language::Python`): uses
10//!   `PythonParser`, emitting Python-specific kinds (PythonFunction, PythonClass, etc.)
11//!   with full position metadata populated.
12//!
13//! Usage:
14//! ```ignore
15//! // Generic (backward compat)
16//! let result = ingest_directory(Path::new("brain/"))?;
17//! println!("{}", result.summary());
18//!
19//! // Python-specific with position metadata
20//! let result = ingest_python_directory(Path::new("_archive/brain-v13/brain/"))?;
21//! println!("{}", result.summary());
22//! ```
23
24use crate::edges::{NameResolver, extract_edges};
25use crate::parser::{ParseResult, parse_python_file};
26use crate::python_parser::{PythonParseResult, PythonParser};
27use crate::rust_parser::parse_rust_file;
28use crate::schema::{
29    CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind, build_code_edges_batch,
30    build_code_nodes_batch,
31};
32use arrow::array::RecordBatch;
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36/// Language selection for the ingest pipeline.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Language {
39    /// Use the generic parser (language-agnostic `CodeNodeKind` variants).
40    ///
41    /// Backward-compatible. Does not populate position metadata.
42    Generic,
43    /// Use the Python-specific parser (`PythonParser`).
44    ///
45    /// Emits `PythonFunction`, `PythonClass`, etc. with full position metadata.
46    Python,
47}
48
49/// Errors from ingestion.
50#[derive(Debug, thiserror::Error)]
51pub enum IngestError {
52    #[error("IO error: {0}")]
53    Io(#[from] std::io::Error),
54
55    #[error("Parse error: {0}")]
56    Parse(#[from] crate::parser::ParseError),
57
58    #[error("Python parser error: {0}")]
59    PythonParse(#[from] crate::python_parser::PythonParserError),
60
61    #[error("Arrow error: {0}")]
62    Arrow(#[from] arrow::error::ArrowError),
63
64    #[error("Directory not found: {0}")]
65    DirNotFound(String),
66}
67
68pub type Result<T> = std::result::Result<T, IngestError>;
69
70/// Result of a full directory ingestion.
71#[derive(Debug)]
72pub struct IngestResult {
73    /// All parsed CodeNodes.
74    pub nodes: Vec<CodeNode>,
75    /// All extracted CodeEdges.
76    pub edges: Vec<CodeEdge>,
77    /// Per-file parse results (for call edge extraction).
78    pub parse_results: Vec<ParseResult>,
79    /// Files that failed to parse (path, error message).
80    pub errors: Vec<(PathBuf, String)>,
81    /// Source text by file path (for call edge extraction).
82    pub source_texts: HashMap<String, String>,
83}
84
85impl IngestResult {
86    /// Build CodeNodes RecordBatch from ingestion results.
87    pub fn nodes_batch(&self) -> std::result::Result<RecordBatch, arrow::error::ArrowError> {
88        build_code_nodes_batch(&self.nodes)
89    }
90
91    /// Build CodeEdges RecordBatch from ingestion results.
92    pub fn edges_batch(&self) -> std::result::Result<RecordBatch, arrow::error::ArrowError> {
93        build_code_edges_batch(&self.edges)
94    }
95
96    /// Human-readable summary of the ingestion.
97    pub fn summary(&self) -> String {
98        let mut s = String::new();
99
100        // Node counts by kind
101        s.push_str("=== CodeGraph Ingestion Summary ===\n");
102        s.push_str(&format!("Total nodes: {}\n", self.nodes.len()));
103        for kind in CodeNodeKind::ALL {
104            let count = self.nodes.iter().filter(|n| n.kind == kind).count();
105            if count > 0 {
106                s.push_str(&format!("  {}: {}\n", kind.as_str(), count));
107            }
108        }
109
110        // Edge counts by predicate
111        s.push_str(&format!("Total edges: {}\n", self.edges.len()));
112        for pred in CodeEdgePredicate::ALL {
113            let count = self.edges.iter().filter(|e| e.predicate == pred).count();
114            if count > 0 {
115                s.push_str(&format!("  {}: {}\n", pred.as_str(), count));
116            }
117        }
118
119        // Unresolved references
120        let unresolved = self
121            .edges
122            .iter()
123            .filter(|e| e.target_id.starts_with("ext:"))
124            .count();
125        s.push_str(&format!("Unresolved references: {}\n", unresolved));
126
127        // Errors
128        if !self.errors.is_empty() {
129            s.push_str(&format!("Parse errors: {}\n", self.errors.len()));
130            for (path, err) in &self.errors {
131                s.push_str(&format!("  {}: {}\n", path.display(), err));
132            }
133        }
134
135        s
136    }
137}
138
139/// Ingest all Python and Rust files under a directory into a CodeGraph.
140///
141/// Walks the directory tree recursively, parses each `.py` and `.rs` file,
142/// extracts cross-file edges, and returns the full graph.
143pub fn ingest_directory(root: &Path) -> Result<IngestResult> {
144    if !root.is_dir() {
145        return Err(IngestError::DirNotFound(root.display().to_string()));
146    }
147
148    let source_files = collect_source_files(root)?;
149
150    ingest_files(root, &source_files)
151}
152
153/// Ingest a specific list of source files (Python and Rust).
154///
155/// `root` is the base directory (used to compute relative paths for node IDs).
156pub fn ingest_files(root: &Path, files: &[PathBuf]) -> Result<IngestResult> {
157    let mut all_parse_results = Vec::new();
158    let mut errors = Vec::new();
159    let mut source_texts = HashMap::new();
160
161    for file_path in files {
162        let rel_path = file_path
163            .strip_prefix(root)
164            .unwrap_or(file_path)
165            .to_path_buf();
166
167        let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
168
169        match std::fs::read_to_string(file_path) {
170            Ok(source) => {
171                let path_str = rel_path.display().to_string();
172                source_texts.insert(path_str, source.clone());
173
174                let parse_result = match ext {
175                    "py" => parse_python_file(&rel_path, &source),
176                    "rs" => parse_rust_file(&rel_path, &source),
177                    _ => continue, // Skip unsupported extensions
178                };
179
180                match parse_result {
181                    Ok(result) => {
182                        all_parse_results.push(result);
183                    }
184                    Err(e) => {
185                        errors.push((file_path.clone(), e.to_string()));
186                    }
187                }
188            }
189            Err(e) => {
190                errors.push((file_path.clone(), format!("{}: {}", file_path.display(), e)));
191            }
192        }
193    }
194
195    // Collect all nodes
196    let all_nodes: Vec<CodeNode> = all_parse_results
197        .iter()
198        .flat_map(|r| r.nodes.clone())
199        .collect();
200
201    // Build resolver and extract edges
202    let resolver = NameResolver::from_nodes(&all_nodes);
203    let mut edges = extract_edges(&all_parse_results, &resolver);
204
205    // Extract call edges — prefer SCIP when rust-analyzer is available (Rust),
206    // fall back to text scanning for Python or when SCIP is unavailable.
207    let call_edges =
208        crate::edges::extract_call_edges(&all_parse_results, &resolver, &source_texts, Some(root));
209    edges.extend(call_edges);
210
211    // Extract cross-file edges for Rust crates using module resolution
212    if let Some(mut module_resolver) = crate::module_resolver::RustModuleResolver::from_crate(root)
213    {
214        module_resolver.index_nodes(&all_nodes);
215        let cross_edges =
216            crate::edges::extract_cross_file_edges(&all_parse_results, &module_resolver);
217        edges.extend(cross_edges);
218    }
219
220    Ok(IngestResult {
221        nodes: all_nodes,
222        edges,
223        parse_results: all_parse_results,
224        errors,
225        source_texts,
226    })
227}
228
229// ─── Python-specific ingestion (Language::Python) ───────────────────────────
230
231/// Ingest all Python files under a directory using the Python-specific parser.
232///
233/// Unlike `ingest_directory`, this uses `PythonParser` which:
234/// - Emits Python-specific `CodeNodeKind` variants (PythonFunction, PythonClass, etc.)
235/// - Populates position metadata for all nodes (start_line, end_line, etc.)
236/// - Emits PythonDecorator, PythonImport, PythonAsync, PythonProperty nodes
237pub fn ingest_python_directory(root: &Path) -> Result<IngestResult> {
238    if !root.is_dir() {
239        return Err(IngestError::DirNotFound(root.display().to_string()));
240    }
241    let py_files = collect_python_files(root)?;
242    ingest_python_files(root, &py_files)
243}
244
245/// Ingest a specific list of Python files using the Python-specific parser.
246///
247/// `root` is used to compute relative paths for node IDs.
248pub fn ingest_python_files(root: &Path, files: &[PathBuf]) -> Result<IngestResult> {
249    let mut parser = PythonParser::new()?;
250
251    let mut all_parse_results: Vec<ParseResult> = Vec::new();
252    let mut py_results: Vec<PythonParseResult> = Vec::new();
253    let mut errors = Vec::new();
254    let mut source_texts = HashMap::new();
255
256    for file_path in files {
257        let rel_path = file_path
258            .strip_prefix(root)
259            .unwrap_or(file_path)
260            .to_path_buf();
261
262        match std::fs::read_to_string(file_path) {
263            Ok(source) => {
264                let path_str = rel_path.display().to_string();
265                source_texts.insert(path_str, source.clone());
266
267                match parser.parse_file(&rel_path, &source) {
268                    Ok(result) => {
269                        py_results.push(result);
270                    }
271                    Err(e) => {
272                        errors.push((file_path.clone(), e.to_string()));
273                    }
274                }
275            }
276            Err(e) => {
277                errors.push((file_path.clone(), format!("{}: {}", file_path.display(), e)));
278            }
279        }
280    }
281
282    // Collect nodes from Python-specific results
283    let all_nodes: Vec<CodeNode> = py_results.iter().flat_map(|r| r.nodes.clone()).collect();
284
285    // Convert PythonParseResult imports to ParseResult-compatible form for edge extraction
286    // We build minimal ParseResult entries for the import graph.
287    for py_result in &py_results {
288        // Build a minimal ParseResult so edge extraction can process imports
289        let parse_result = ParseResult {
290            nodes: py_result.nodes.clone(),
291            imports: py_result.imports.clone(),
292        };
293        all_parse_results.push(parse_result);
294    }
295
296    let resolver = NameResolver::from_nodes(&all_nodes);
297    let mut edges = extract_edges(&all_parse_results, &resolver);
298    // Python: text scanning only (SCIP is Rust-only).
299    let call_edges =
300        crate::edges::extract_call_edges(&all_parse_results, &resolver, &source_texts, None);
301    edges.extend(call_edges);
302
303    Ok(IngestResult {
304        nodes: all_nodes,
305        edges,
306        parse_results: all_parse_results,
307        errors,
308        source_texts,
309    })
310}
311
312/// Recursively collect all `.py` files under a directory.
313fn collect_python_files(dir: &Path) -> Result<Vec<PathBuf>> {
314    let mut files = Vec::new();
315    collect_source_files_recursive(dir, &mut files)?;
316    // Filter to .py only
317    files.retain(|f| f.extension().is_some_and(|e| e == "py"));
318    files.sort();
319    Ok(files)
320}
321
322/// Recursively collect all `.py` and `.rs` files under a directory.
323fn collect_source_files(dir: &Path) -> Result<Vec<PathBuf>> {
324    let mut files = Vec::new();
325    collect_source_files_recursive(dir, &mut files)?;
326    files.sort();
327    Ok(files)
328}
329
330fn collect_source_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
331    for entry in std::fs::read_dir(dir)? {
332        let entry = entry?;
333        let path = entry.path();
334
335        if path.is_dir() {
336            // Skip common non-source directories
337            let name = path
338                .file_name()
339                .map(|n| n.to_string_lossy().to_string())
340                .unwrap_or_default();
341            if name.starts_with('.')
342                || name == "__pycache__"
343                || name == "node_modules"
344                || name == ".git"
345                || name == "venv"
346                || name == ".venv"
347                || name == "target"
348            {
349                continue;
350            }
351            collect_source_files_recursive(&path, files)?;
352        } else if path
353            .extension()
354            .is_some_and(|ext| ext == "py" || ext == "rs")
355        {
356            files.push(path);
357        }
358    }
359    Ok(())
360}
361
362/// Query nodes by file path (returns nodes whose ID contains the path).
363pub fn nodes_in_file<'a>(nodes: &'a [CodeNode], file_path: &str) -> Vec<&'a CodeNode> {
364    nodes.iter().filter(|n| n.id.contains(file_path)).collect()
365}
366
367/// Query callers of a function/method by name.
368///
369/// Uses exact segment matching on `::` boundaries to avoid false positives
370/// (e.g., searching for "fuse" won't match "defuse").
371pub fn callers_of<'a>(edges: &'a [CodeEdge], target_name: &str) -> Vec<&'a CodeEdge> {
372    edges
373        .iter()
374        .filter(|e| {
375            if e.predicate != CodeEdgePredicate::Calls {
376                return false;
377            }
378            // Check if the last segment of the target_id matches exactly
379            if let Some(last_segment) = e.target_id.rsplit("::").next() {
380                last_segment == target_name
381            } else {
382                // No :: separator — check if the whole ID ends with the name
383                e.target_id.ends_with(target_name)
384            }
385        })
386        .collect()
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_ingest_files_from_source() {
395        // Create temp files
396        let dir = tempfile::tempdir().expect("create temp dir");
397        let file_a = dir.path().join("module_a.py");
398        let file_b = dir.path().join("module_b.py");
399
400        std::fs::write(
401            &file_a,
402            r#"
403"""Module A."""
404
405from module_b import helper
406
407class Processor:
408    """Processes data."""
409    def process(self, data):
410        """Process data."""
411        return helper(data)
412
413def standalone():
414    """Standalone function."""
415    return 42
416"#,
417        )
418        .expect("write a");
419
420        std::fs::write(
421            &file_b,
422            r#"
423"""Module B."""
424
425def helper(x):
426    """Help with x."""
427    return x * 2
428
429def another():
430    """Another function."""
431    return helper(10)
432"#,
433        )
434        .expect("write b");
435
436        let result = ingest_directory(dir.path()).expect("ingest should succeed");
437
438        // Should have nodes from both files
439        assert!(
440            result.nodes.len() >= 8,
441            "Expected >= 8 nodes, got {}",
442            result.nodes.len()
443        );
444
445        // Should have edges
446        assert!(!result.edges.is_empty(), "Should have edges");
447
448        // Should have containment edges
449        let containment = result
450            .edges
451            .iter()
452            .filter(|e| e.predicate == CodeEdgePredicate::Contains)
453            .count();
454        assert!(
455            containment >= 4,
456            "Expected >= 4 containment edges, got {}",
457            containment
458        );
459
460        // Should have import edge
461        let imports = result
462            .edges
463            .iter()
464            .filter(|e| e.predicate == CodeEdgePredicate::Imports)
465            .count();
466        assert!(imports >= 1, "Expected >= 1 import edge, got {}", imports);
467
468        // Summary should work
469        let summary = result.summary();
470        assert!(summary.contains("Total nodes:"));
471        assert!(summary.contains("Total edges:"));
472
473        // No parse errors
474        assert!(result.errors.is_empty(), "Should have no parse errors");
475    }
476
477    #[test]
478    fn test_ingest_builds_record_batches() {
479        let dir = tempfile::tempdir().expect("create temp dir");
480        std::fs::write(
481            dir.path().join("test.py"),
482            r#"
483def foo():
484    """A function."""
485    return 1
486
487def bar():
488    """Another function."""
489    return foo()
490"#,
491        )
492        .expect("write");
493
494        let result = ingest_directory(dir.path()).expect("ingest");
495        let nodes_batch = result.nodes_batch().expect("nodes batch");
496        let edges_batch = result.edges_batch().expect("edges batch");
497
498        assert!(nodes_batch.num_rows() > 0);
499        assert_eq!(nodes_batch.num_columns(), 19);
500        assert!(edges_batch.num_rows() > 0);
501        assert_eq!(edges_batch.num_columns(), 5);
502    }
503
504    #[test]
505    fn test_ingest_dir_not_found() {
506        let result = ingest_directory(Path::new("/nonexistent/path"));
507        assert!(result.is_err());
508    }
509
510    #[test]
511    fn test_ingest_skips_pycache() {
512        let dir = tempfile::tempdir().expect("create temp dir");
513        let pycache = dir.path().join("__pycache__");
514        std::fs::create_dir(&pycache).expect("create pycache");
515        std::fs::write(pycache.join("cached.py"), "x = 1").expect("write cached");
516        std::fs::write(dir.path().join("real.py"), "def foo(): pass").expect("write real");
517
518        let result = ingest_directory(dir.path()).expect("ingest");
519
520        // Should only have nodes from real.py, not __pycache__/cached.py
521        let file_nodes: Vec<_> = result
522            .nodes
523            .iter()
524            .filter(|n| n.kind == CodeNodeKind::File)
525            .collect();
526        assert_eq!(file_nodes.len(), 1, "Should only parse real.py");
527    }
528
529    #[test]
530    fn test_nodes_in_file_query() {
531        let nodes = vec![
532            CodeNode {
533                id: "func:brain/utils.py::helper".to_string(),
534                kind: CodeNodeKind::Function,
535                parent_id: None,
536                name: "helper".to_string(),
537                signature: None,
538                docstring: None,
539                body_hash: None,
540                body: None,
541                loc: None,
542                cyclomatic_complexity: None,
543                coverage_pct: None,
544                last_modified: None,
545                ..Default::default()
546            },
547            CodeNode {
548                id: "func:brain/main.py::main".to_string(),
549                kind: CodeNodeKind::Function,
550                parent_id: None,
551                name: "main".to_string(),
552                signature: None,
553                docstring: None,
554                body_hash: None,
555                body: None,
556                loc: None,
557                cyclomatic_complexity: None,
558                coverage_pct: None,
559                last_modified: None,
560                ..Default::default()
561            },
562        ];
563
564        let utils_nodes = nodes_in_file(&nodes, "brain/utils.py");
565        assert_eq!(utils_nodes.len(), 1);
566        assert_eq!(utils_nodes[0].name, "helper");
567    }
568
569    #[test]
570    fn test_callers_of_query() {
571        let edges = vec![
572            CodeEdge {
573                source_id: "func:a.py::caller".to_string(),
574                target_id: "func:b.py::target".to_string(),
575                predicate: CodeEdgePredicate::Calls,
576                weight: Some(1.0),
577                commit_id: None,
578            },
579            CodeEdge {
580                source_id: "func:a.py::other".to_string(),
581                target_id: "func:c.py::unrelated".to_string(),
582                predicate: CodeEdgePredicate::Calls,
583                weight: Some(1.0),
584                commit_id: None,
585            },
586        ];
587
588        let callers = callers_of(&edges, "target");
589        assert_eq!(callers.len(), 1);
590        assert_eq!(callers[0].source_id, "func:a.py::caller");
591    }
592
593    #[test]
594    fn test_ingest_handles_syntax_errors_gracefully() {
595        let dir = tempfile::tempdir().expect("create temp dir");
596        std::fs::write(dir.path().join("good.py"), "def foo(): pass").expect("write good");
597        // tree-sitter is very lenient — it won't fail on most syntax errors
598        // but we test that the pipeline doesn't crash
599        std::fs::write(dir.path().join("weird.py"), "def (: pass").expect("write weird");
600
601        let result = ingest_directory(dir.path()).expect("ingest should succeed");
602        // Should have at least the good file's nodes
603        assert!(!result.nodes.is_empty());
604    }
605
606    #[test]
607    fn test_ingest_rust_files() {
608        let dir = tempfile::tempdir().expect("create temp dir");
609        std::fs::create_dir(dir.path().join("src")).expect("mkdir");
610        std::fs::write(
611            dir.path().join("src/lib.rs"),
612            r#"
613use std::collections::HashMap;
614
615pub struct Config {
616    pub name: String,
617}
618
619impl Config {
620    pub fn new(name: &str) -> Self {
621        Config { name: name.to_string() }
622    }
623}
624
625pub fn process(input: &str) -> String {
626    input.to_uppercase()
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn test_process() {
635        assert_eq!(process("hello"), "HELLO");
636    }
637}
638"#,
639        )
640        .expect("write rust");
641
642        let result = ingest_directory(dir.path()).expect("ingest should succeed");
643
644        // Should have Rust-specific nodes
645        let rust_nodes: Vec<_> = result
646            .nodes
647            .iter()
648            .filter(|n| n.kind.is_rust_specific())
649            .collect();
650        assert!(
651            rust_nodes.len() >= 5,
652            "Expected >= 5 Rust-specific nodes, got {} (kinds: {:?})",
653            rust_nodes.len(),
654            rust_nodes
655                .iter()
656                .map(|n| (n.kind, &n.name))
657                .collect::<Vec<_>>()
658        );
659
660        // Should have position metadata
661        for node in &rust_nodes {
662            if !matches!(node.kind, CodeNodeKind::RustUse) {
663                assert!(
664                    node.start_line.is_some() && node.start_line.unwrap() > 0,
665                    "Rust node {} should have start_line > 0",
666                    node.id
667                );
668            }
669        }
670
671        // Should have containment edges
672        let containment = result
673            .edges
674            .iter()
675            .filter(|e| e.predicate == CodeEdgePredicate::Contains)
676            .count();
677        assert!(
678            containment >= 3,
679            "Expected >= 3 containment edges, got {}",
680            containment
681        );
682
683        // No parse errors
684        assert!(
685            result.errors.is_empty(),
686            "Should have no parse errors: {:?}",
687            result.errors
688        );
689    }
690
691    #[test]
692    fn test_ingest_mixed_py_and_rs() {
693        let dir = tempfile::tempdir().expect("create temp dir");
694        std::fs::write(dir.path().join("app.py"), "def main():\n    pass\n").expect("write py");
695        std::fs::write(dir.path().join("lib.rs"), "fn helper() -> i32 { 42 }\n").expect("write rs");
696
697        let result = ingest_directory(dir.path()).expect("ingest");
698
699        // Should have both Python and Rust file nodes
700        let file_nodes: Vec<_> = result
701            .nodes
702            .iter()
703            .filter(|n| n.kind == CodeNodeKind::File)
704            .collect();
705        assert_eq!(file_nodes.len(), 2, "Should have 2 file nodes");
706
707        // Should have Python function
708        let py_func = result
709            .nodes
710            .iter()
711            .find(|n| n.kind == CodeNodeKind::Function && n.name == "main");
712        assert!(py_func.is_some(), "Should have Python function main");
713
714        // Should have Rust function
715        let rs_func = result
716            .nodes
717            .iter()
718            .find(|n| n.kind == CodeNodeKind::RustFn && n.name == "helper");
719        assert!(rs_func.is_some(), "Should have Rust function helper");
720    }
721
722    #[test]
723    fn test_ingest_skips_target_dir() {
724        let dir = tempfile::tempdir().expect("create temp dir");
725        let target = dir.path().join("target");
726        std::fs::create_dir(&target).expect("create target");
727        std::fs::write(target.join("build.rs"), "fn main() {}").expect("write target file");
728        std::fs::write(dir.path().join("real.rs"), "fn real() {}").expect("write real");
729
730        let result = ingest_directory(dir.path()).expect("ingest");
731
732        let file_nodes: Vec<_> = result
733            .nodes
734            .iter()
735            .filter(|n| n.kind == CodeNodeKind::File)
736            .collect();
737        assert_eq!(
738            file_nodes.len(),
739            1,
740            "Should only parse real.rs, not target/build.rs"
741        );
742    }
743
744    #[test]
745    fn test_collect_source_files_sorted() {
746        let dir = tempfile::tempdir().expect("create temp dir");
747        std::fs::write(dir.path().join("z.py"), "x=1").expect("write");
748        std::fs::write(dir.path().join("a.py"), "x=1").expect("write");
749        std::fs::create_dir(dir.path().join("sub")).expect("mkdir");
750        std::fs::write(dir.path().join("sub/m.py"), "x=1").expect("write");
751
752        let files = collect_source_files(dir.path()).expect("collect");
753        assert_eq!(files.len(), 3);
754        // Should be sorted
755        let names: Vec<_> = files
756            .iter()
757            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
758            .collect();
759        assert!(names.windows(2).all(|w| w[0] <= w[1]));
760    }
761}