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
37pub 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
82pub 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
162pub 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 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 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 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 let reverse = (edge.to.clone(), edge.from.clone());
219 !persist_edges.contains(&reverse)
220 } else {
221 true
222 }
223 });
224
225 let transitive_imports = compute_transitive_imports(&graph.imports);
236
237 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 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 (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 if let Some(b_imports) = transitive_imports.get(declarer_b) {
266 if b_imports.contains(declarer_a) {
267 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
295fn 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(¤t) {
307 continue;
308 }
309 visited.insert(current.clone());
310
311 if let Some(current_imports) = imports.get(¤t) {
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 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 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 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}