Skip to main content

trilogy_parser/
directory_resolver.rs

1use crate::parser::parse_file;
2use std::collections::{HashMap, HashSet};
3use std::fs;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone)]
7pub struct FileInfo {
8    pub path: PathBuf,
9    pub datasources: Vec<String>,
10    pub persists: Vec<String>,
11}
12
13#[derive(Debug, Clone)]
14pub struct DirectoryGraph {
15    pub files: HashMap<PathBuf, FileInfo>,
16    pub imports: HashMap<PathBuf, Vec<PathBuf>>,
17    pub warnings: Vec<String>,
18}
19
20#[derive(Debug, Clone)]
21pub struct Edge {
22    pub from: PathBuf,
23    pub to: PathBuf,
24    pub reason: EdgeReason,
25}
26
27#[derive(Debug, Clone)]
28pub enum EdgeReason {
29    Import,
30    PersistBeforeDeclare { datasource: String },
31    TransitivePersistOrder {
32        upstream_datasource: String,
33        downstream_datasource: String,
34    },
35}
36
37/// Collect .preql files in a directory, optionally recursing into subdirectories.
38pub fn collect_preql_files(
39    dir: &PathBuf,
40    recursive: bool,
41) -> Result<Vec<PathBuf>, std::io::Error> {
42    let mut files = Vec::new();
43
44    if recursive {
45        collect_preql_files_recursive(dir, &mut files)?;
46    } else {
47        for entry in fs::read_dir(dir)? {
48            let entry = entry?;
49            let path = entry.path();
50            if path.is_file() && is_preql_file(&path) {
51                files.push(path);
52            }
53        }
54    }
55
56    files.sort();
57    Ok(files)
58}
59
60fn collect_preql_files_recursive(
61    dir: &PathBuf,
62    files: &mut Vec<PathBuf>,
63) -> Result<(), std::io::Error> {
64    for entry in fs::read_dir(dir)? {
65        let entry = entry?;
66        let path = entry.path();
67
68        if path.is_dir() {
69            collect_preql_files_recursive(&path, files)?;
70        } else if path.is_file() && is_preql_file(&path) {
71            files.push(path);
72        }
73    }
74
75    Ok(())
76}
77
78fn is_preql_file(path: &PathBuf) -> bool {
79    path.extension().is_some_and(|ext| ext == "preql")
80}
81
82/// Process files in a directory, discovering transitive imports
83pub fn process_directory_with_imports(
84    initial_files: Vec<PathBuf>,
85) -> Result<DirectoryGraph, String> {
86    let mut all_imports: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
87    let mut files_info: HashMap<PathBuf, FileInfo> = HashMap::new();
88    let mut files_to_process = initial_files;
89    let mut processed_files: HashSet<PathBuf> = HashSet::new();
90    let mut warnings = Vec::new();
91
92    while let Some(file) = files_to_process.pop() {
93        let canonical = match fs::canonicalize(&file) {
94            Ok(c) => c,
95            Err(e) => {
96                warnings.push(format!("Failed to canonicalize {}: {}", file.display(), e));
97                continue;
98            }
99        };
100
101        if processed_files.contains(&canonical) {
102            continue;
103        }
104        processed_files.insert(canonical.clone());
105
106        let content = match fs::read_to_string(&file) {
107            Ok(c) => c,
108            Err(e) => {
109                warnings.push(format!("Failed to read {}: {}", file.display(), e));
110                continue;
111            }
112        };
113
114        let parsed = match parse_file(&content) {
115            Ok(p) => p,
116            Err(e) => {
117                warnings.push(format!("Failed to parse {}: {}", file.display(), e));
118                continue;
119            }
120        };
121
122        let mut resolved_imports = Vec::new();
123        let file_dir = file.parent().unwrap_or(std::path::Path::new("."));
124
125        for import in &parsed.imports {
126            if import.is_stdlib {
127                continue;
128            }
129            if let Some(resolved) = import.resolve(file_dir) {
130                if resolved.exists() {
131                    if let Ok(resolved_canonical) = fs::canonicalize(&resolved) {
132                        resolved_imports.push(resolved_canonical.clone());
133                        if !processed_files.contains(&resolved_canonical) {
134                            files_to_process.push(resolved_canonical);
135                        }
136                    }
137                }
138            }
139        }
140
141        let datasources: Vec<String> = parsed.datasources.iter().map(|d| d.name.clone()).collect();
142        let persists: Vec<String> = parsed.persists.iter().map(|p| p.target_datasource.clone()).collect();
143
144        all_imports.insert(canonical.clone(), resolved_imports);
145        files_info.insert(
146            canonical.clone(),
147            FileInfo {
148                path: canonical,
149                datasources,
150                persists,
151            },
152        );
153    }
154
155    Ok(DirectoryGraph {
156        files: files_info,
157        imports: all_imports,
158        warnings,
159    })
160}
161
162/// Build edges from a directory graph
163pub fn build_edges(graph: &DirectoryGraph) -> Vec<Edge> {
164    let mut edges = Vec::new();
165    let known_files: HashSet<PathBuf> = graph.files.keys().cloned().collect();
166
167    // Rule 1: Import dependencies (imported files run before importing files)
168    for (file, imports) in &graph.imports {
169        for resolved_path in imports {
170            if !known_files.contains(resolved_path) {
171                continue;
172            }
173            edges.push(Edge {
174                from: resolved_path.clone(),
175                to: file.clone(),
176                reason: EdgeReason::Import,
177            });
178        }
179    }
180
181    // Rule 2: Persist-before-declare
182    // Files that persist to a datasource must run BEFORE files that declare that datasource.
183    // This takes precedence over import edges - if the updater imports the declarer,
184    // we need to remove that import edge and add the persist-before-declare edge instead.
185    for (declarer_path, declarer_info) in &graph.files {
186        for ds_name in &declarer_info.datasources {
187            for (updater_path, updater_info) in &graph.files {
188                if updater_path == declarer_path {
189                    continue;
190                }
191
192                if updater_info.persists.contains(ds_name) {
193                    edges.push(Edge {
194                        from: updater_path.clone(),
195                        to: declarer_path.clone(),
196                        reason: EdgeReason::PersistBeforeDeclare {
197                            datasource: ds_name.clone(),
198                        },
199                    });
200                }
201            }
202        }
203    }
204
205    // Rule 3: Remove import edges that conflict with persist-before-declare
206    // If file A imports file B, but A also persists to a datasource declared by B,
207    // then the import edge (B -> A) conflicts with persist-before-declare (A -> B).
208    // In this case, persist-before-declare takes precedence.
209    let persist_edges: HashSet<(PathBuf, PathBuf)> = edges
210        .iter()
211        .filter(|e| matches!(e.reason, EdgeReason::PersistBeforeDeclare { .. }))
212        .map(|e| (e.from.clone(), e.to.clone()))
213        .collect();
214
215    edges.retain(|edge| {
216        if matches!(edge.reason, EdgeReason::Import) {
217            // Check if there's a conflicting persist-before-declare edge in the opposite direction
218            let reverse = (edge.to.clone(), edge.from.clone());
219            !persist_edges.contains(&reverse)
220        } else {
221            true
222        }
223    });
224
225    // Rule 4: Transitive persist ordering between updaters
226    // If X1 persists to datasource A (declared in declarer_A), and
227    // X2 persists to datasource B (declared in declarer_B), and
228    // declarer_B imports declarer_A (directly or transitively),
229    // then X1 must run before X2.
230    //
231    // This ensures that when B's datasource depends on A's data (through imports),
232    // any updates to A complete before updates to B.
233
234    // First, compute transitive imports for each file
235    let transitive_imports = compute_transitive_imports(&graph.imports);
236
237    // Build a map from datasource name to the file that declares it
238    let mut datasource_to_declarer: HashMap<String, PathBuf> = HashMap::new();
239    for (path, info) in &graph.files {
240        for ds_name in &info.datasources {
241            datasource_to_declarer.insert(ds_name.clone(), path.clone());
242        }
243    }
244
245    // Build a map from datasource name to files that persist to it
246    let mut datasource_to_updaters: HashMap<String, Vec<PathBuf>> = HashMap::new();
247    for (path, info) in &graph.files {
248        for persist_target in &info.persists {
249            datasource_to_updaters
250                .entry(persist_target.clone())
251                .or_default()
252                .push(path.clone());
253        }
254    }
255
256    // For each pair of datasources where one's declarer imports the other's declarer,
257    // add edges between their updaters
258    for (ds_a, declarer_a) in &datasource_to_declarer {
259        for (ds_b, declarer_b) in &datasource_to_declarer {
260            if ds_a == ds_b || declarer_a == declarer_b {
261                continue;
262            }
263
264            // Check if declarer_B transitively imports declarer_A
265            if let Some(b_imports) = transitive_imports.get(declarer_b) {
266                if b_imports.contains(declarer_a) {
267                    // declarer_B imports declarer_A, so all updaters of A must run before updaters of B
268                    if let (Some(updaters_a), Some(updaters_b)) = (
269                        datasource_to_updaters.get(ds_a),
270                        datasource_to_updaters.get(ds_b),
271                    ) {
272                        for updater_a in updaters_a {
273                            for updater_b in updaters_b {
274                                if updater_a != updater_b && known_files.contains(updater_a) && known_files.contains(updater_b) {
275                                    edges.push(Edge {
276                                        from: updater_a.clone(),
277                                        to: updater_b.clone(),
278                                        reason: EdgeReason::TransitivePersistOrder {
279                                            upstream_datasource: ds_a.clone(),
280                                            downstream_datasource: ds_b.clone(),
281                                        },
282                                    });
283                                }
284                            }
285                        }
286                    }
287                }
288            }
289        }
290    }
291
292    edges
293}
294
295/// Compute transitive imports for each file
296fn compute_transitive_imports(
297    imports: &HashMap<PathBuf, Vec<PathBuf>>,
298) -> HashMap<PathBuf, HashSet<PathBuf>> {
299    let mut result: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
300
301    for file in imports.keys() {
302        let mut visited: HashSet<PathBuf> = HashSet::new();
303        let mut stack: Vec<PathBuf> = imports.get(file).cloned().unwrap_or_default();
304
305        while let Some(current) = stack.pop() {
306            if visited.contains(&current) {
307                continue;
308            }
309            visited.insert(current.clone());
310
311            if let Some(current_imports) = imports.get(&current) {
312                for imp in current_imports {
313                    if !visited.contains(imp) {
314                        stack.push(imp.clone());
315                    }
316                }
317            }
318        }
319
320        result.insert(file.clone(), visited);
321    }
322
323    result
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::fs;
330    use tempfile::TempDir;
331
332    fn create_test_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
333        let path = dir.join(name);
334        if let Some(parent) = path.parent() {
335            fs::create_dir_all(parent).unwrap();
336        }
337        fs::write(&path, content).unwrap();
338        path
339    }
340
341    #[test]
342    fn test_transitive_persist_order() {
343        // Setup:
344        // - order_product_items.preql: declares datasource "order_product_items"
345        // - sales_reporting.preql: declares datasource "sales_reporting" AND imports order_product_items
346        // - incremental_opi.preql: persists to "order_product_items"
347        // - incremental_sales.preql: persists to "sales_reporting"
348        //
349        // Expected: incremental_opi -> incremental_sales (transitive persist order)
350        // Because sales_reporting imports order_product_items, so updates to order_product_items
351        // must complete before updates to sales_reporting.
352
353        let temp = TempDir::new().unwrap();
354        let root = temp.path();
355
356        create_test_file(
357            root,
358            "order_product_items.preql",
359            r#"
360            datasource order_product_items (
361                id: key
362            )
363            address db.opi;
364            "#,
365        );
366
367        create_test_file(
368            root,
369            "sales_reporting.preql",
370            r#"
371            import order_product_items;
372
373            datasource sales_reporting (
374                id: key
375            )
376            address db.sales;
377            "#,
378        );
379
380        create_test_file(
381            root,
382            "incremental_opi.preql",
383            r#"
384            import order_product_items;
385            persist order_product_items where id = 1;
386            "#,
387        );
388
389        create_test_file(
390            root,
391            "incremental_sales.preql",
392            r#"
393            import sales_reporting;
394            persist sales_reporting where id = 1;
395            "#,
396        );
397
398        // Collect all files
399        let files: Vec<PathBuf> = fs::read_dir(root)
400            .unwrap()
401            .filter_map(|e| e.ok())
402            .map(|e| e.path())
403            .filter(|p| p.extension().map_or(false, |ext| ext == "preql"))
404            .collect();
405
406        let graph = process_directory_with_imports(files).unwrap();
407        let edges = build_edges(&graph);
408
409        // Find the transitive persist order edge
410        let transitive_edge = edges.iter().find(|e| {
411            matches!(e.reason, EdgeReason::TransitivePersistOrder { .. })
412        });
413
414        assert!(
415            transitive_edge.is_some(),
416            "Expected a transitive persist order edge. Edges: {:?}",
417            edges.iter().map(|e| (e.from.file_name(), e.to.file_name(), &e.reason)).collect::<Vec<_>>()
418        );
419
420        let edge = transitive_edge.unwrap();
421        assert!(
422            edge.from.ends_with("incremental_opi.preql"),
423            "Expected from to be incremental_opi.preql, got {:?}",
424            edge.from
425        );
426        assert!(
427            edge.to.ends_with("incremental_sales.preql"),
428            "Expected to to be incremental_sales.preql, got {:?}",
429            edge.to
430        );
431    }
432
433    #[test]
434    fn test_collect_preql_files_recursive_flag() {
435        let temp = TempDir::new().unwrap();
436        let root = temp.path();
437
438        create_test_file(root, "top.preql", "");
439        create_test_file(root, "raw/nested.preql", "");
440        create_test_file(root, "raw/deep/deeper.preql", "");
441        create_test_file(root, "raw/notes.txt", "");
442
443        let flat = collect_preql_files(&root.to_path_buf(), false).unwrap();
444        assert_eq!(flat.len(), 1);
445        assert!(flat[0].ends_with("top.preql"));
446
447        let recursive = collect_preql_files(&root.to_path_buf(), true).unwrap();
448        let names: Vec<_> = recursive
449            .iter()
450            .filter_map(|p| p.file_name())
451            .collect();
452        assert_eq!(recursive.len(), 3, "got {:?}", names);
453        assert!(names.contains(&std::ffi::OsStr::new("nested.preql")));
454        assert!(names.contains(&std::ffi::OsStr::new("deeper.preql")));
455    }
456}