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 (best-effort text scanning)
206    let call_edges = crate::edges::extract_call_edges(&all_parse_results, &resolver, &source_texts);
207    edges.extend(call_edges);
208
209    // Extract cross-file edges for Rust crates using module resolution
210    if let Some(mut module_resolver) = crate::module_resolver::RustModuleResolver::from_crate(root)
211    {
212        module_resolver.index_nodes(&all_nodes);
213        let cross_edges =
214            crate::edges::extract_cross_file_edges(&all_parse_results, &module_resolver);
215        edges.extend(cross_edges);
216    }
217
218    Ok(IngestResult {
219        nodes: all_nodes,
220        edges,
221        parse_results: all_parse_results,
222        errors,
223        source_texts,
224    })
225}
226
227// ─── Python-specific ingestion (Language::Python) ───────────────────────────
228
229/// Ingest all Python files under a directory using the Python-specific parser.
230///
231/// Unlike `ingest_directory`, this uses `PythonParser` which:
232/// - Emits Python-specific `CodeNodeKind` variants (PythonFunction, PythonClass, etc.)
233/// - Populates position metadata for all nodes (start_line, end_line, etc.)
234/// - Emits PythonDecorator, PythonImport, PythonAsync, PythonProperty nodes
235pub fn ingest_python_directory(root: &Path) -> Result<IngestResult> {
236    if !root.is_dir() {
237        return Err(IngestError::DirNotFound(root.display().to_string()));
238    }
239    let py_files = collect_python_files(root)?;
240    ingest_python_files(root, &py_files)
241}
242
243/// Ingest a specific list of Python files using the Python-specific parser.
244///
245/// `root` is used to compute relative paths for node IDs.
246pub fn ingest_python_files(root: &Path, files: &[PathBuf]) -> Result<IngestResult> {
247    let mut parser = PythonParser::new()?;
248
249    let mut all_parse_results: Vec<ParseResult> = Vec::new();
250    let mut py_results: Vec<PythonParseResult> = Vec::new();
251    let mut errors = Vec::new();
252    let mut source_texts = HashMap::new();
253
254    for file_path in files {
255        let rel_path = file_path
256            .strip_prefix(root)
257            .unwrap_or(file_path)
258            .to_path_buf();
259
260        match std::fs::read_to_string(file_path) {
261            Ok(source) => {
262                let path_str = rel_path.display().to_string();
263                source_texts.insert(path_str, source.clone());
264
265                match parser.parse_file(&rel_path, &source) {
266                    Ok(result) => {
267                        py_results.push(result);
268                    }
269                    Err(e) => {
270                        errors.push((file_path.clone(), e.to_string()));
271                    }
272                }
273            }
274            Err(e) => {
275                errors.push((file_path.clone(), format!("{}: {}", file_path.display(), e)));
276            }
277        }
278    }
279
280    // Collect nodes from Python-specific results
281    let all_nodes: Vec<CodeNode> = py_results.iter().flat_map(|r| r.nodes.clone()).collect();
282
283    // Convert PythonParseResult imports to ParseResult-compatible form for edge extraction
284    // We build minimal ParseResult entries for the import graph.
285    for py_result in &py_results {
286        // Build a minimal ParseResult so edge extraction can process imports
287        let parse_result = ParseResult {
288            nodes: py_result.nodes.clone(),
289            imports: py_result.imports.clone(),
290        };
291        all_parse_results.push(parse_result);
292    }
293
294    let resolver = NameResolver::from_nodes(&all_nodes);
295    let mut edges = extract_edges(&all_parse_results, &resolver);
296    let call_edges = crate::edges::extract_call_edges(&all_parse_results, &resolver, &source_texts);
297    edges.extend(call_edges);
298
299    Ok(IngestResult {
300        nodes: all_nodes,
301        edges,
302        parse_results: all_parse_results,
303        errors,
304        source_texts,
305    })
306}
307
308/// Recursively collect all `.py` files under a directory.
309fn collect_python_files(dir: &Path) -> Result<Vec<PathBuf>> {
310    let mut files = Vec::new();
311    collect_source_files_recursive(dir, &mut files)?;
312    // Filter to .py only
313    files.retain(|f| f.extension().is_some_and(|e| e == "py"));
314    files.sort();
315    Ok(files)
316}
317
318/// Recursively collect all `.py` and `.rs` files under a directory.
319fn collect_source_files(dir: &Path) -> Result<Vec<PathBuf>> {
320    let mut files = Vec::new();
321    collect_source_files_recursive(dir, &mut files)?;
322    files.sort();
323    Ok(files)
324}
325
326fn collect_source_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
327    for entry in std::fs::read_dir(dir)? {
328        let entry = entry?;
329        let path = entry.path();
330
331        if path.is_dir() {
332            // Skip common non-source directories
333            let name = path
334                .file_name()
335                .map(|n| n.to_string_lossy().to_string())
336                .unwrap_or_default();
337            if name.starts_with('.')
338                || name == "__pycache__"
339                || name == "node_modules"
340                || name == ".git"
341                || name == "venv"
342                || name == ".venv"
343                || name == "target"
344            {
345                continue;
346            }
347            collect_source_files_recursive(&path, files)?;
348        } else if path
349            .extension()
350            .is_some_and(|ext| ext == "py" || ext == "rs")
351        {
352            files.push(path);
353        }
354    }
355    Ok(())
356}
357
358/// Query nodes by file path (returns nodes whose ID contains the path).
359pub fn nodes_in_file<'a>(nodes: &'a [CodeNode], file_path: &str) -> Vec<&'a CodeNode> {
360    nodes.iter().filter(|n| n.id.contains(file_path)).collect()
361}
362
363/// Query callers of a function/method by name.
364///
365/// Uses exact segment matching on `::` boundaries to avoid false positives
366/// (e.g., searching for "fuse" won't match "defuse").
367pub fn callers_of<'a>(edges: &'a [CodeEdge], target_name: &str) -> Vec<&'a CodeEdge> {
368    edges
369        .iter()
370        .filter(|e| {
371            if e.predicate != CodeEdgePredicate::Calls {
372                return false;
373            }
374            // Check if the last segment of the target_id matches exactly
375            if let Some(last_segment) = e.target_id.rsplit("::").next() {
376                last_segment == target_name
377            } else {
378                // No :: separator — check if the whole ID ends with the name
379                e.target_id.ends_with(target_name)
380            }
381        })
382        .collect()
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_ingest_files_from_source() {
391        // Create temp files
392        let dir = tempfile::tempdir().expect("create temp dir");
393        let file_a = dir.path().join("module_a.py");
394        let file_b = dir.path().join("module_b.py");
395
396        std::fs::write(
397            &file_a,
398            r#"
399"""Module A."""
400
401from module_b import helper
402
403class Processor:
404    """Processes data."""
405    def process(self, data):
406        """Process data."""
407        return helper(data)
408
409def standalone():
410    """Standalone function."""
411    return 42
412"#,
413        )
414        .expect("write a");
415
416        std::fs::write(
417            &file_b,
418            r#"
419"""Module B."""
420
421def helper(x):
422    """Help with x."""
423    return x * 2
424
425def another():
426    """Another function."""
427    return helper(10)
428"#,
429        )
430        .expect("write b");
431
432        let result = ingest_directory(dir.path()).expect("ingest should succeed");
433
434        // Should have nodes from both files
435        assert!(
436            result.nodes.len() >= 8,
437            "Expected >= 8 nodes, got {}",
438            result.nodes.len()
439        );
440
441        // Should have edges
442        assert!(!result.edges.is_empty(), "Should have edges");
443
444        // Should have containment edges
445        let containment = result
446            .edges
447            .iter()
448            .filter(|e| e.predicate == CodeEdgePredicate::Contains)
449            .count();
450        assert!(
451            containment >= 4,
452            "Expected >= 4 containment edges, got {}",
453            containment
454        );
455
456        // Should have import edge
457        let imports = result
458            .edges
459            .iter()
460            .filter(|e| e.predicate == CodeEdgePredicate::Imports)
461            .count();
462        assert!(imports >= 1, "Expected >= 1 import edge, got {}", imports);
463
464        // Summary should work
465        let summary = result.summary();
466        assert!(summary.contains("Total nodes:"));
467        assert!(summary.contains("Total edges:"));
468
469        // No parse errors
470        assert!(result.errors.is_empty(), "Should have no parse errors");
471    }
472
473    #[test]
474    fn test_ingest_builds_record_batches() {
475        let dir = tempfile::tempdir().expect("create temp dir");
476        std::fs::write(
477            dir.path().join("test.py"),
478            r#"
479def foo():
480    """A function."""
481    return 1
482
483def bar():
484    """Another function."""
485    return foo()
486"#,
487        )
488        .expect("write");
489
490        let result = ingest_directory(dir.path()).expect("ingest");
491        let nodes_batch = result.nodes_batch().expect("nodes batch");
492        let edges_batch = result.edges_batch().expect("edges batch");
493
494        assert!(nodes_batch.num_rows() > 0);
495        assert_eq!(nodes_batch.num_columns(), 19);
496        assert!(edges_batch.num_rows() > 0);
497        assert_eq!(edges_batch.num_columns(), 5);
498    }
499
500    #[test]
501    fn test_ingest_dir_not_found() {
502        let result = ingest_directory(Path::new("/nonexistent/path"));
503        assert!(result.is_err());
504    }
505
506    #[test]
507    fn test_ingest_skips_pycache() {
508        let dir = tempfile::tempdir().expect("create temp dir");
509        let pycache = dir.path().join("__pycache__");
510        std::fs::create_dir(&pycache).expect("create pycache");
511        std::fs::write(pycache.join("cached.py"), "x = 1").expect("write cached");
512        std::fs::write(dir.path().join("real.py"), "def foo(): pass").expect("write real");
513
514        let result = ingest_directory(dir.path()).expect("ingest");
515
516        // Should only have nodes from real.py, not __pycache__/cached.py
517        let file_nodes: Vec<_> = result
518            .nodes
519            .iter()
520            .filter(|n| n.kind == CodeNodeKind::File)
521            .collect();
522        assert_eq!(file_nodes.len(), 1, "Should only parse real.py");
523    }
524
525    #[test]
526    fn test_nodes_in_file_query() {
527        let nodes = vec![
528            CodeNode {
529                id: "func:brain/utils.py::helper".to_string(),
530                kind: CodeNodeKind::Function,
531                parent_id: None,
532                name: "helper".to_string(),
533                signature: None,
534                docstring: None,
535                body_hash: None,
536                body: None,
537                loc: None,
538                cyclomatic_complexity: None,
539                coverage_pct: None,
540                last_modified: None,
541                ..Default::default()
542            },
543            CodeNode {
544                id: "func:brain/main.py::main".to_string(),
545                kind: CodeNodeKind::Function,
546                parent_id: None,
547                name: "main".to_string(),
548                signature: None,
549                docstring: None,
550                body_hash: None,
551                body: None,
552                loc: None,
553                cyclomatic_complexity: None,
554                coverage_pct: None,
555                last_modified: None,
556                ..Default::default()
557            },
558        ];
559
560        let utils_nodes = nodes_in_file(&nodes, "brain/utils.py");
561        assert_eq!(utils_nodes.len(), 1);
562        assert_eq!(utils_nodes[0].name, "helper");
563    }
564
565    #[test]
566    fn test_callers_of_query() {
567        let edges = vec![
568            CodeEdge {
569                source_id: "func:a.py::caller".to_string(),
570                target_id: "func:b.py::target".to_string(),
571                predicate: CodeEdgePredicate::Calls,
572                weight: Some(1.0),
573                commit_id: None,
574            },
575            CodeEdge {
576                source_id: "func:a.py::other".to_string(),
577                target_id: "func:c.py::unrelated".to_string(),
578                predicate: CodeEdgePredicate::Calls,
579                weight: Some(1.0),
580                commit_id: None,
581            },
582        ];
583
584        let callers = callers_of(&edges, "target");
585        assert_eq!(callers.len(), 1);
586        assert_eq!(callers[0].source_id, "func:a.py::caller");
587    }
588
589    #[test]
590    fn test_ingest_handles_syntax_errors_gracefully() {
591        let dir = tempfile::tempdir().expect("create temp dir");
592        std::fs::write(dir.path().join("good.py"), "def foo(): pass").expect("write good");
593        // tree-sitter is very lenient — it won't fail on most syntax errors
594        // but we test that the pipeline doesn't crash
595        std::fs::write(dir.path().join("weird.py"), "def (: pass").expect("write weird");
596
597        let result = ingest_directory(dir.path()).expect("ingest should succeed");
598        // Should have at least the good file's nodes
599        assert!(!result.nodes.is_empty());
600    }
601
602    #[test]
603    fn test_ingest_rust_files() {
604        let dir = tempfile::tempdir().expect("create temp dir");
605        std::fs::create_dir(dir.path().join("src")).expect("mkdir");
606        std::fs::write(
607            dir.path().join("src/lib.rs"),
608            r#"
609use std::collections::HashMap;
610
611pub struct Config {
612    pub name: String,
613}
614
615impl Config {
616    pub fn new(name: &str) -> Self {
617        Config { name: name.to_string() }
618    }
619}
620
621pub fn process(input: &str) -> String {
622    input.to_uppercase()
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn test_process() {
631        assert_eq!(process("hello"), "HELLO");
632    }
633}
634"#,
635        )
636        .expect("write rust");
637
638        let result = ingest_directory(dir.path()).expect("ingest should succeed");
639
640        // Should have Rust-specific nodes
641        let rust_nodes: Vec<_> = result
642            .nodes
643            .iter()
644            .filter(|n| n.kind.is_rust_specific())
645            .collect();
646        assert!(
647            rust_nodes.len() >= 5,
648            "Expected >= 5 Rust-specific nodes, got {} (kinds: {:?})",
649            rust_nodes.len(),
650            rust_nodes
651                .iter()
652                .map(|n| (n.kind, &n.name))
653                .collect::<Vec<_>>()
654        );
655
656        // Should have position metadata
657        for node in &rust_nodes {
658            if !matches!(node.kind, CodeNodeKind::RustUse) {
659                assert!(
660                    node.start_line.is_some() && node.start_line.unwrap() > 0,
661                    "Rust node {} should have start_line > 0",
662                    node.id
663                );
664            }
665        }
666
667        // Should have containment edges
668        let containment = result
669            .edges
670            .iter()
671            .filter(|e| e.predicate == CodeEdgePredicate::Contains)
672            .count();
673        assert!(
674            containment >= 3,
675            "Expected >= 3 containment edges, got {}",
676            containment
677        );
678
679        // No parse errors
680        assert!(
681            result.errors.is_empty(),
682            "Should have no parse errors: {:?}",
683            result.errors
684        );
685    }
686
687    #[test]
688    fn test_ingest_mixed_py_and_rs() {
689        let dir = tempfile::tempdir().expect("create temp dir");
690        std::fs::write(dir.path().join("app.py"), "def main():\n    pass\n").expect("write py");
691        std::fs::write(dir.path().join("lib.rs"), "fn helper() -> i32 { 42 }\n").expect("write rs");
692
693        let result = ingest_directory(dir.path()).expect("ingest");
694
695        // Should have both Python and Rust file nodes
696        let file_nodes: Vec<_> = result
697            .nodes
698            .iter()
699            .filter(|n| n.kind == CodeNodeKind::File)
700            .collect();
701        assert_eq!(file_nodes.len(), 2, "Should have 2 file nodes");
702
703        // Should have Python function
704        let py_func = result
705            .nodes
706            .iter()
707            .find(|n| n.kind == CodeNodeKind::Function && n.name == "main");
708        assert!(py_func.is_some(), "Should have Python function main");
709
710        // Should have Rust function
711        let rs_func = result
712            .nodes
713            .iter()
714            .find(|n| n.kind == CodeNodeKind::RustFn && n.name == "helper");
715        assert!(rs_func.is_some(), "Should have Rust function helper");
716    }
717
718    #[test]
719    fn test_ingest_skips_target_dir() {
720        let dir = tempfile::tempdir().expect("create temp dir");
721        let target = dir.path().join("target");
722        std::fs::create_dir(&target).expect("create target");
723        std::fs::write(target.join("build.rs"), "fn main() {}").expect("write target file");
724        std::fs::write(dir.path().join("real.rs"), "fn real() {}").expect("write real");
725
726        let result = ingest_directory(dir.path()).expect("ingest");
727
728        let file_nodes: Vec<_> = result
729            .nodes
730            .iter()
731            .filter(|n| n.kind == CodeNodeKind::File)
732            .collect();
733        assert_eq!(
734            file_nodes.len(),
735            1,
736            "Should only parse real.rs, not target/build.rs"
737        );
738    }
739
740    #[test]
741    fn test_collect_source_files_sorted() {
742        let dir = tempfile::tempdir().expect("create temp dir");
743        std::fs::write(dir.path().join("z.py"), "x=1").expect("write");
744        std::fs::write(dir.path().join("a.py"), "x=1").expect("write");
745        std::fs::create_dir(dir.path().join("sub")).expect("mkdir");
746        std::fs::write(dir.path().join("sub/m.py"), "x=1").expect("write");
747
748        let files = collect_source_files(dir.path()).expect("collect");
749        assert_eq!(files.len(), 3);
750        // Should be sorted
751        let names: Vec<_> = files
752            .iter()
753            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
754            .collect();
755        assert!(names.windows(2).all(|w| w[0] <= w[1]));
756    }
757}