Skip to main content

dlin_core/
input.rs

1use std::io::{self, BufRead, IsTerminal};
2use std::path::{Path, PathBuf};
3
4use path_slash::PathExt as _;
5
6use crate::graph::types::LineageGraph;
7use crate::parser::project::ResolvedPaths;
8use crate::parser::yaml_schema;
9
10/// Classification of a stdin input line
11enum InputLine {
12    /// A .sql file path under dbt project paths (absolute path)
13    SqlFile(PathBuf),
14    /// A .yml/.yaml file path under dbt project paths (absolute path)
15    YamlFile(PathBuf),
16    /// A bare name (no extension) treated as model/source name
17    ModelName(String),
18    /// Ignored (non-dbt extension or file outside dbt project paths)
19    Ignore,
20}
21
22/// Normalize a path by canonicalizing the longest existing ancestor and appending
23/// the remaining components.  This resolves symlinks (macOS `/tmp` → `/private/tmp`)
24/// and platform prefixes (Windows `\\?\`) without requiring the full path to exist.
25pub(crate) fn normalize_path(path: &Path) -> PathBuf {
26    // Try canonicalizing the full path first (works when file exists)
27    if let Ok(canonical) = path.canonicalize() {
28        return canonical;
29    }
30    // Otherwise, walk up until we find an existing ancestor
31    let mut components_to_append = Vec::new();
32    let mut current = path.to_path_buf();
33    loop {
34        if let Ok(canonical) = current.canonicalize() {
35            let mut result = canonical;
36            for component in components_to_append.into_iter().rev() {
37                result.push(component);
38            }
39            return result;
40        }
41        if let Some(file_name) = current.file_name() {
42            components_to_append.push(file_name.to_owned());
43        }
44        if !current.pop() {
45            break;
46        }
47    }
48    path.to_path_buf()
49}
50
51/// Resolve a potentially relative path to absolute using the given base directory.
52/// stdin paths (e.g. from `git diff --name-only`) are relative to the working
53/// directory where the command was invoked, which may differ from the dbt project
54/// directory when `dbt_project.yml` lives in a subdirectory.
55fn to_absolute(path_str: &str, cwd: &Path) -> PathBuf {
56    let path = Path::new(path_str);
57    if path.is_absolute() {
58        path.to_path_buf()
59    } else {
60        cwd.join(path)
61    }
62}
63
64/// Read lines from stdin if data is being piped or redirected from a file.
65/// Returns an empty Vec if stdin is a terminal (interactive mode) or if
66/// stdin is not a pipe/regular file (e.g. /dev/null in CI or AI agent
67/// environments).
68pub fn read_stdin_lines() -> Vec<String> {
69    let stdin = io::stdin();
70    if stdin.is_terminal() {
71        return Vec::new();
72    }
73
74    // In non-TTY environments (CI, AI agents), stdin may be connected to
75    // /dev/null or other non-pipe sources that shouldn't be read.
76    // Only proceed if stdin is actually a pipe (FIFO) or regular file.
77    // We query metadata via the raw file descriptor (fd 0) rather than
78    // /dev/stdin so this works in minimal containers that lack /dev/stdin.
79    // TODO: Add Windows support using GetFileType() Win32 API to check for
80    // FILE_TYPE_PIPE / FILE_TYPE_DISK. Currently Windows falls back to the
81    // is_terminal() check only.
82    #[cfg(unix)]
83    {
84        use std::os::unix::fs::FileTypeExt;
85        use std::os::unix::io::{AsRawFd, FromRawFd};
86        // Safety: we wrap fd 0 in a File to call metadata(). ManuallyDrop
87        // ensures stdin is never closed, even if metadata() were to panic.
88        let ft = {
89            let f = std::mem::ManuallyDrop::new(unsafe {
90                std::fs::File::from_raw_fd(stdin.as_raw_fd())
91            });
92            match f.metadata() {
93                Ok(m) => m.file_type(),
94                Err(_) => return Vec::new(),
95            }
96        };
97        if !ft.is_fifo() && !ft.is_file() {
98            return Vec::new();
99        }
100    }
101
102    stdin
103        .lock()
104        .lines()
105        .map_while(|l| l.ok())
106        .filter(|l| !l.trim().is_empty())
107        .map(|l| l.trim().to_string())
108        .collect()
109}
110
111/// Classify a single stdin line based on its extension and whether it falls
112/// under one of the dbt project source directories.
113fn classify_line(line: &str, resolved_paths: &ResolvedPaths, cwd: &Path) -> InputLine {
114    let path = Path::new(line);
115    match path.extension().and_then(|e| e.to_str()) {
116        Some("sql") => {
117            let abs = normalize_path(&to_absolute(line, cwd));
118            if is_under_dbt_paths(&abs, resolved_paths) {
119                InputLine::SqlFile(abs)
120            } else {
121                InputLine::Ignore
122            }
123        }
124        Some("yml" | "yaml") => {
125            let abs = normalize_path(&to_absolute(line, cwd));
126            if is_under_dbt_paths(&abs, resolved_paths) {
127                InputLine::YamlFile(abs)
128            } else {
129                InputLine::Ignore
130            }
131        }
132        Some(ext) => {
133            // Has a non-dbt file extension. If it has a path separator it's
134            // clearly a file path → ignore.  Without a separator it could be
135            // a dbt source name like "raw.orders" (extension = "orders") or a
136            // root-level file like "README.md".  We distinguish them by
137            // checking against common file extensions.
138            if line.contains('/') || line.contains('\\') || is_common_file_extension(ext) {
139                InputLine::Ignore
140            } else {
141                InputLine::ModelName(line.to_string())
142            }
143        }
144        None => {
145            // No extension at all (e.g. "stg_orders", "Makefile").
146            // Lines with a path separator are non-dbt paths → ignore.
147            if line.contains('/') || line.contains('\\') {
148                InputLine::Ignore
149            } else {
150                InputLine::ModelName(line.to_string())
151            }
152        }
153    }
154}
155
156/// Common file extensions that are NOT dbt source/model names.
157/// Used to distinguish root-level files (e.g. "README.md") from dbt source
158/// references (e.g. "raw.orders") when there is no path separator.
159///
160/// Note: this allowlist is inherently incomplete.  In the rare case that a
161/// dbt source table name collides with a listed extension (e.g. a table
162/// literally named "py" referenced as "raw.py"), the input will be silently
163/// ignored.  Use an explicit model name without the source prefix, or pass
164/// the schema YAML path instead.
165fn is_common_file_extension(ext: &str) -> bool {
166    matches!(
167        ext,
168        "md" | "txt"
169            | "py"
170            | "csv"
171            | "json"
172            | "toml"
173            | "cfg"
174            | "ini"
175            | "rst"
176            | "lock"
177            | "xml"
178            | "html"
179            | "htm"
180            | "js"
181            | "ts"
182            | "sh"
183            | "bat"
184            | "rs"
185            | "go"
186            | "java"
187            | "rb"
188            | "c"
189            | "h"
190            | "cpp"
191            | "hpp"
192            | "swift"
193            | "kt"
194            | "log"
195            | "env"
196            | "gitignore"
197    )
198}
199
200/// Check whether any of the given input strings look like file paths rather
201/// than bare model names.  Used to decide whether to load `DbtProject` for
202/// path resolution.
203pub fn has_path_like_input(inputs: &[String]) -> bool {
204    inputs.iter().any(|s| {
205        s.contains('/')
206            || s.contains('\\')
207            || s.ends_with(".sql")
208            || s.ends_with(".yml")
209            || s.ends_with(".yaml")
210    })
211}
212
213/// Check if an absolute path falls under any of the configured dbt project directories.
214fn is_under_dbt_paths(abs_path: &Path, resolved_paths: &ResolvedPaths) -> bool {
215    let abs_path = normalize_path(abs_path);
216    let all_paths = resolved_paths
217        .model_paths
218        .iter()
219        .chain(&resolved_paths.seed_paths)
220        .chain(&resolved_paths.snapshot_paths)
221        .chain(&resolved_paths.test_paths)
222        .chain(&resolved_paths.analysis_paths);
223
224    all_paths.into_iter().any(|dir| abs_path.starts_with(dir))
225}
226
227/// Find a graph node whose `file_path` matches the given absolute path and return its label.
228fn resolve_sql_to_label(
229    abs_path: &Path,
230    graph: &LineageGraph,
231    project_dir: &Path,
232) -> Option<String> {
233    let abs_path = normalize_path(abs_path);
234    let project_dir = normalize_path(project_dir);
235    let relative = abs_path.strip_prefix(&project_dir).ok()?;
236    // Normalize to forward slashes once (loop-invariant) for Windows compatibility
237    let rel_str = relative.to_slash_lossy();
238
239    graph.node_indices().find_map(|idx| {
240        let node = &graph[idx];
241        match &node.file_path {
242            Some(node_path) => {
243                let node_str = node_path.to_slash_lossy();
244                if node_str == rel_str {
245                    Some(node.label.clone())
246                } else {
247                    None
248                }
249            }
250            None => None,
251        }
252    })
253}
254
255/// Parse a YAML schema file and return source and model names defined in it.
256fn expand_yaml_names(abs_path: &Path) -> Vec<String> {
257    let content = match std::fs::read_to_string(abs_path) {
258        Ok(c) => c,
259        Err(e) => {
260            crate::warn!("could not read {}: {}", abs_path.display(), e);
261            return Vec::new();
262        }
263    };
264
265    let schema = match yaml_schema::parse_schema_file(&content, Some(abs_path)) {
266        Ok(s) => s,
267        Err(e) => {
268            crate::warn!("could not parse {}: {}", abs_path.display(), e);
269            return Vec::new();
270        }
271    };
272
273    let mut names = Vec::new();
274    for source in &schema.sources {
275        for table in &source.tables {
276            names.push(format!("{}.{}", source.name, table.name));
277        }
278    }
279    for model in &schema.models {
280        names.push(model.name.clone());
281    }
282    for sm in &schema.semantic_models {
283        names.push(format!("semantic_model.{}", sm.name));
284    }
285    for metric in &schema.metrics {
286        names.push(format!("metric.{}", metric.name));
287    }
288    for sq in &schema.saved_queries {
289        names.push(format!("saved_query.{}", sq.name));
290    }
291    names
292}
293
294/// Process input lines and resolve them to model/source names suitable for
295/// use as focus models in `filter_graph`.
296///
297/// `cwd` is the working directory used to resolve relative file paths (e.g.
298/// paths from `git diff --name-only`).  This may differ from `project_dir`
299/// when the dbt project lives in a subdirectory of the git repository.
300pub fn resolve_stdin_inputs(
301    lines: &[String],
302    graph: &LineageGraph,
303    resolved_paths: &ResolvedPaths,
304    project_dir: &Path,
305    cwd: &Path,
306) -> Vec<String> {
307    let mut seen = std::collections::HashSet::new();
308    let mut names = Vec::new();
309
310    for line in lines {
311        match classify_line(line, resolved_paths, cwd) {
312            InputLine::SqlFile(abs_path) => {
313                if let Some(label) = resolve_sql_to_label(&abs_path, graph, project_dir) {
314                    if seen.insert(label.clone()) {
315                        names.push(label);
316                    }
317                } else {
318                    crate::warn!("no node found for file {}, skipping.", abs_path.display());
319                }
320            }
321            InputLine::YamlFile(abs_path) => {
322                for name in expand_yaml_names(&abs_path) {
323                    if seen.insert(name.clone()) {
324                        names.push(name);
325                    }
326                }
327            }
328            InputLine::ModelName(name) => {
329                if seen.insert(name.clone()) {
330                    names.push(name);
331                }
332            }
333            InputLine::Ignore => {}
334        }
335    }
336
337    names
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::graph::types::{NodeData, NodeType};
344    use std::fs;
345
346    fn make_resolved_paths(project_dir: &Path) -> ResolvedPaths {
347        let norm = |name: &str| vec![normalize_path(&project_dir.join(name))];
348        ResolvedPaths {
349            model_paths: norm("models"),
350            seed_paths: norm("seeds"),
351            snapshot_paths: norm("snapshots"),
352            test_paths: norm("tests"),
353            macro_paths: norm("macros"),
354            analysis_paths: norm("analyses"),
355        }
356    }
357
358    fn make_node(unique_id: &str, label: &str, node_type: NodeType) -> NodeData {
359        NodeData {
360            unique_id: unique_id.to_string(),
361            label: label.to_string(),
362            node_type,
363            file_path: None,
364            description: None,
365            materialization: None,
366            tags: vec![],
367            columns: vec![],
368            exposure: None,
369            aliases: vec![],
370        }
371    }
372
373    // --- classify_line tests ---
374    // classify_line uses `cwd` to resolve relative paths to absolute.
375    // In tests, we pass the tempdir as cwd so that relative paths resolve correctly.
376
377    #[test]
378    fn test_classify_sql_under_models() {
379        let tmp = tempfile::tempdir().unwrap();
380        let paths = make_resolved_paths(tmp.path());
381        let result = classify_line("models/staging/stg_orders.sql", &paths, tmp.path());
382        assert!(matches!(result, InputLine::SqlFile(_)));
383    }
384
385    #[test]
386    fn test_classify_sql_under_snapshots() {
387        let tmp = tempfile::tempdir().unwrap();
388        let paths = make_resolved_paths(tmp.path());
389        let result = classify_line("snapshots/snap_orders.sql", &paths, tmp.path());
390        assert!(matches!(result, InputLine::SqlFile(_)));
391    }
392
393    #[test]
394    fn test_classify_sql_under_analyses() {
395        let tmp = tempfile::tempdir().unwrap();
396        let paths = make_resolved_paths(tmp.path());
397        let result = classify_line("analyses/my_analysis.sql", &paths, tmp.path());
398        assert!(matches!(result, InputLine::SqlFile(_)));
399    }
400
401    #[test]
402    fn test_classify_sql_outside_dbt_paths() {
403        let tmp = tempfile::tempdir().unwrap();
404        let paths = make_resolved_paths(tmp.path());
405        let result = classify_line("other/script.sql", &paths, tmp.path());
406        assert!(matches!(result, InputLine::Ignore));
407    }
408
409    #[test]
410    fn test_classify_yml_under_models() {
411        let tmp = tempfile::tempdir().unwrap();
412        let paths = make_resolved_paths(tmp.path());
413        let result = classify_line("models/staging/schema.yml", &paths, tmp.path());
414        assert!(matches!(result, InputLine::YamlFile(_)));
415    }
416
417    #[test]
418    fn test_classify_yaml_under_models() {
419        let tmp = tempfile::tempdir().unwrap();
420        let paths = make_resolved_paths(tmp.path());
421        let result = classify_line("models/schema.yaml", &paths, tmp.path());
422        assert!(matches!(result, InputLine::YamlFile(_)));
423    }
424
425    #[test]
426    fn test_classify_yml_outside_dbt_paths() {
427        let tmp = tempfile::tempdir().unwrap();
428        let paths = make_resolved_paths(tmp.path());
429        let result = classify_line(".github/workflows/ci.yml", &paths, tmp.path());
430        assert!(matches!(result, InputLine::Ignore));
431    }
432
433    #[test]
434    fn test_classify_non_dbt_extension_with_separator() {
435        let tmp = tempfile::tempdir().unwrap();
436        let paths = make_resolved_paths(tmp.path());
437        // Files with path separators and non-dbt extensions are ignored
438        assert!(matches!(
439            classify_line("seeds/data.csv", &paths, tmp.path()),
440            InputLine::Ignore
441        ));
442        assert!(matches!(
443            classify_line("models/model.py", &paths, tmp.path()),
444            InputLine::Ignore
445        ));
446    }
447
448    #[test]
449    fn test_classify_non_dbt_extension_without_separator() {
450        let tmp = tempfile::tempdir().unwrap();
451        let paths = make_resolved_paths(tmp.path());
452        // Root-level files with common extensions are ignored
453        assert!(matches!(
454            classify_line("README.md", &paths, tmp.path()),
455            InputLine::Ignore
456        ));
457        assert!(matches!(
458            classify_line("Cargo.toml", &paths, tmp.path()),
459            InputLine::Ignore
460        ));
461        assert!(matches!(
462            classify_line("setup.py", &paths, tmp.path()),
463            InputLine::Ignore
464        ));
465    }
466
467    #[test]
468    fn test_classify_no_extension() {
469        let tmp = tempfile::tempdir().unwrap();
470        let paths = make_resolved_paths(tmp.path());
471        let result = classify_line("stg_orders", &paths, tmp.path());
472        assert!(matches!(result, InputLine::ModelName(ref n) if n == "stg_orders"));
473    }
474
475    #[test]
476    fn test_classify_source_name() {
477        let tmp = tempfile::tempdir().unwrap();
478        let paths = make_resolved_paths(tmp.path());
479        // "raw.orders" has no recognized extension (.orders is not .sql/.yml/.yaml)
480        let result = classify_line("raw.orders", &paths, tmp.path());
481        assert!(matches!(result, InputLine::ModelName(ref n) if n == "raw.orders"));
482    }
483
484    // --- is_under_dbt_paths tests ---
485
486    #[test]
487    fn test_is_under_dbt_paths_nested() {
488        let tmp = tempfile::tempdir().unwrap();
489        let paths = make_resolved_paths(tmp.path());
490        let abs = tmp.path().join("models/staging/stg_orders.sql");
491        assert!(is_under_dbt_paths(&abs, &paths));
492    }
493
494    #[test]
495    fn test_is_under_dbt_paths_absolute() {
496        let tmp = tempfile::tempdir().unwrap();
497        let paths = make_resolved_paths(tmp.path());
498        let abs = tmp.path().join("models/orders.sql");
499        assert!(is_under_dbt_paths(&abs, &paths));
500    }
501
502    #[test]
503    fn test_is_not_under_dbt_paths() {
504        let tmp = tempfile::tempdir().unwrap();
505        let paths = make_resolved_paths(tmp.path());
506        let abs = tmp.path().join("other/file.sql");
507        assert!(!is_under_dbt_paths(&abs, &paths));
508    }
509
510    // --- resolve_sql_to_label tests ---
511
512    #[test]
513    fn test_resolve_sql_to_label_found() {
514        let project_dir = Path::new("/project");
515        let mut graph = LineageGraph::new();
516        let mut node = make_node("model.stg_orders", "stg_orders", NodeType::Model);
517        node.file_path = Some(PathBuf::from("models/staging/stg_orders.sql"));
518        graph.add_node(node);
519
520        let abs = Path::new("/project/models/staging/stg_orders.sql");
521        let result = resolve_sql_to_label(abs, &graph, project_dir);
522        assert_eq!(result, Some("stg_orders".to_string()));
523    }
524
525    #[test]
526    fn test_resolve_sql_to_label_not_found() {
527        let project_dir = Path::new("/project");
528        let graph = LineageGraph::new();
529
530        let abs = Path::new("/project/models/nonexistent.sql");
531        let result = resolve_sql_to_label(abs, &graph, project_dir);
532        assert_eq!(result, None);
533    }
534
535    // --- expand_yaml_names tests ---
536
537    #[test]
538    fn test_expand_yaml_sources() {
539        let tmp = tempfile::tempdir().unwrap();
540        let yaml_path = tmp.path().join("schema.yml");
541        fs::write(
542            &yaml_path,
543            r#"
544sources:
545  - name: raw
546    tables:
547      - name: orders
548      - name: customers
549"#,
550        )
551        .unwrap();
552
553        let names = expand_yaml_names(&yaml_path);
554        assert_eq!(names, vec!["raw.orders", "raw.customers"]);
555    }
556
557    #[test]
558    fn test_expand_yaml_models() {
559        let tmp = tempfile::tempdir().unwrap();
560        let yaml_path = tmp.path().join("schema.yml");
561        fs::write(
562            &yaml_path,
563            r#"
564models:
565  - name: stg_orders
566  - name: stg_customers
567"#,
568        )
569        .unwrap();
570
571        let names = expand_yaml_names(&yaml_path);
572        assert_eq!(names, vec!["stg_orders", "stg_customers"]);
573    }
574
575    #[test]
576    fn test_expand_yaml_mixed() {
577        let tmp = tempfile::tempdir().unwrap();
578        let yaml_path = tmp.path().join("schema.yml");
579        fs::write(
580            &yaml_path,
581            r#"
582sources:
583  - name: raw
584    tables:
585      - name: orders
586models:
587  - name: stg_orders
588"#,
589        )
590        .unwrap();
591
592        let names = expand_yaml_names(&yaml_path);
593        assert_eq!(names, vec!["raw.orders", "stg_orders"]);
594    }
595
596    #[test]
597    fn test_expand_yaml_semantic_layer_types() {
598        let tmp = tempfile::tempdir().unwrap();
599        let yaml_path = tmp.path().join("semantic.yml");
600        fs::write(
601            &yaml_path,
602            r#"
603semantic_models:
604  - name: orders_sm
605    model: ref('orders')
606metrics:
607  - name: revenue
608    type: simple
609    type_params:
610      measure: total_revenue
611saved_queries:
612  - name: revenue_by_month
613"#,
614        )
615        .unwrap();
616
617        let names = expand_yaml_names(&yaml_path);
618        assert!(
619            names.contains(&"semantic_model.orders_sm".to_string()),
620            "missing semantic_model.orders_sm in {:?}",
621            names
622        );
623        assert!(
624            names.contains(&"metric.revenue".to_string()),
625            "missing metric.revenue in {:?}",
626            names
627        );
628        assert!(
629            names.contains(&"saved_query.revenue_by_month".to_string()),
630            "missing saved_query.revenue_by_month in {:?}",
631            names
632        );
633    }
634
635    #[test]
636    fn test_expand_yaml_file_not_found() {
637        let names = expand_yaml_names(Path::new("/nonexistent/schema.yml"));
638        assert!(names.is_empty());
639    }
640
641    #[test]
642    fn test_expand_yaml_empty_file() {
643        let tmp = tempfile::tempdir().unwrap();
644        let yaml_path = tmp.path().join("schema.yml");
645        fs::write(&yaml_path, "").unwrap();
646
647        let names = expand_yaml_names(&yaml_path);
648        assert!(names.is_empty());
649    }
650
651    // --- has_path_like_input tests ---
652
653    #[test]
654    fn test_has_path_like_input_with_paths() {
655        assert!(has_path_like_input(&["models/foo.sql".into()]));
656        assert!(has_path_like_input(&[
657            "stg_orders".into(),
658            "models/bar.yml".into()
659        ]));
660        assert!(has_path_like_input(&["schema.yaml".into()]));
661    }
662
663    #[test]
664    fn test_has_path_like_input_model_names_only() {
665        assert!(!has_path_like_input(&["stg_orders".into()]));
666        assert!(!has_path_like_input(&[
667            "raw.orders".into(),
668            "customers".into()
669        ]));
670    }
671
672    // --- resolve_stdin_inputs integration tests ---
673
674    #[test]
675    fn test_resolve_stdin_model_name() {
676        let tmp = tempfile::tempdir().unwrap();
677        let paths = make_resolved_paths(tmp.path());
678        let graph = LineageGraph::new();
679
680        let lines = vec!["stg_orders".to_string()];
681        let result = resolve_stdin_inputs(&lines, &graph, &paths, tmp.path(), tmp.path());
682        assert_eq!(result, vec!["stg_orders"]);
683    }
684
685    #[test]
686    fn test_resolve_stdin_ignores_non_dbt() {
687        let tmp = tempfile::tempdir().unwrap();
688        let paths = make_resolved_paths(tmp.path());
689        let graph = LineageGraph::new();
690
691        // Files with path separators and non-dbt extensions are ignored
692        let lines = vec!["docs/README.md".to_string(), "seeds/data.csv".to_string()];
693        let result = resolve_stdin_inputs(&lines, &graph, &paths, tmp.path(), tmp.path());
694        assert!(result.is_empty());
695    }
696
697    #[test]
698    fn test_resolve_stdin_deduplicates() {
699        let tmp = tempfile::tempdir().unwrap();
700        let paths = make_resolved_paths(tmp.path());
701        let mut graph = LineageGraph::new();
702        let mut node = make_node("model.stg_orders", "stg_orders", NodeType::Model);
703        node.file_path = Some(PathBuf::from("models/stg_orders.sql"));
704        graph.add_node(node);
705
706        // Same model referenced both as file path and model name
707        let models_dir = tmp.path().join("models");
708        fs::create_dir_all(&models_dir).unwrap();
709        let lines = vec![
710            "models/stg_orders.sql".to_string(),
711            "stg_orders".to_string(),
712        ];
713        let result = resolve_stdin_inputs(&lines, &graph, &paths, tmp.path(), tmp.path());
714        assert_eq!(result, vec!["stg_orders"]);
715    }
716
717    #[test]
718    fn test_resolve_stdin_ignores_root_files() {
719        let tmp = tempfile::tempdir().unwrap();
720        let paths = make_resolved_paths(tmp.path());
721        let graph = LineageGraph::new();
722
723        // Root-level files with common extensions are ignored (no separator)
724        let lines = vec![
725            "README.md".to_string(),
726            "Cargo.toml".to_string(),
727            "stg_orders".to_string(),
728        ];
729        let result = resolve_stdin_inputs(&lines, &graph, &paths, tmp.path(), tmp.path());
730        assert_eq!(result, vec!["stg_orders"]);
731    }
732
733    // --- classify_line + resolve integration (cwd-aware) ---
734
735    #[test]
736    fn test_classify_and_resolve_sql() {
737        let tmp = tempfile::tempdir().unwrap();
738        let paths = make_resolved_paths(tmp.path());
739        let mut graph = LineageGraph::new();
740        let mut node = make_node("model.stg_orders", "stg_orders", NodeType::Model);
741        node.file_path = Some(PathBuf::from("models/staging/stg_orders.sql"));
742        graph.add_node(node);
743
744        // Simulate: cwd = tmp.path(), stdin line = relative path
745        let line = "models/staging/stg_orders.sql";
746        match classify_line(line, &paths, tmp.path()) {
747            InputLine::SqlFile(abs_path) => {
748                let label = resolve_sql_to_label(&abs_path, &graph, tmp.path());
749                assert_eq!(label, Some("stg_orders".to_string()));
750            }
751            other => panic!("Expected SqlFile, got {:?}", std::mem::discriminant(&other)),
752        }
753    }
754
755    #[test]
756    fn test_classify_and_resolve_yaml() {
757        let tmp = tempfile::tempdir().unwrap();
758        let models_dir = tmp.path().join("models");
759        fs::create_dir_all(&models_dir).unwrap();
760        fs::write(
761            models_dir.join("schema.yml"),
762            "sources:\n  - name: raw\n    tables:\n      - name: orders\n",
763        )
764        .unwrap();
765
766        let paths = make_resolved_paths(tmp.path());
767
768        let line = "models/schema.yml";
769        match classify_line(line, &paths, tmp.path()) {
770            InputLine::YamlFile(abs_path) => {
771                let names = expand_yaml_names(&abs_path);
772                assert_eq!(names, vec!["raw.orders"]);
773            }
774            other => panic!(
775                "Expected YamlFile, got {:?}",
776                std::mem::discriminant(&other)
777            ),
778        }
779    }
780
781    #[test]
782    fn test_classify_and_resolve_mixed() {
783        let tmp = tempfile::tempdir().unwrap();
784        let models_dir = tmp.path().join("models");
785        fs::create_dir_all(models_dir.join("staging")).unwrap();
786        fs::write(
787            models_dir.join("schema.yml"),
788            "sources:\n  - name: raw\n    tables:\n      - name: orders\n",
789        )
790        .unwrap();
791
792        let paths = make_resolved_paths(tmp.path());
793        let mut graph = LineageGraph::new();
794        let mut node = make_node("model.stg_orders", "stg_orders", NodeType::Model);
795        node.file_path = Some(PathBuf::from("models/staging/stg_orders.sql"));
796        graph.add_node(node);
797
798        let inputs = vec![
799            "models/staging/stg_orders.sql",
800            "models/schema.yml",
801            "raw.customers",
802            ".github/workflows/ci.yml",
803            "docs/README.md",
804        ];
805
806        let mut result = Vec::new();
807        for line in inputs {
808            match classify_line(line, &paths, tmp.path()) {
809                InputLine::SqlFile(abs) => {
810                    if let Some(label) = resolve_sql_to_label(&abs, &graph, tmp.path()) {
811                        result.push(label);
812                    }
813                }
814                InputLine::YamlFile(abs) => {
815                    result.extend(expand_yaml_names(&abs));
816                }
817                InputLine::ModelName(name) => result.push(name),
818                InputLine::Ignore => {}
819            }
820        }
821        assert_eq!(result, vec!["stg_orders", "raw.orders", "raw.customers"]);
822    }
823
824    #[test]
825    fn test_subdir_project_path_resolution() {
826        // Simulate: git root = tmp, dbt project in tmp/dbt/
827        let tmp = tempfile::tempdir().unwrap();
828        let dbt_dir = tmp.path().join("dbt");
829        let models_dir = dbt_dir.join("models");
830        fs::create_dir_all(&models_dir).unwrap();
831
832        // resolved_paths are absolute under dbt_dir
833        let paths = make_resolved_paths(&dbt_dir);
834
835        let mut graph = LineageGraph::new();
836        let mut node = make_node("model.stg_orders", "stg_orders", NodeType::Model);
837        // file_path stored relative to project_dir (dbt/)
838        node.file_path = Some(PathBuf::from("models/stg_orders.sql"));
839        graph.add_node(node);
840
841        // stdin line is relative to CWD (git root), so includes "dbt/" prefix
842        let line = "dbt/models/stg_orders.sql";
843        // cwd = git root (tmp.path())
844        match classify_line(line, &paths, tmp.path()) {
845            InputLine::SqlFile(abs_path) => {
846                // abs_path should be tmp/dbt/models/stg_orders.sql
847                // project_dir = dbt_dir = tmp/dbt
848                let label = resolve_sql_to_label(&abs_path, &graph, &dbt_dir);
849                assert_eq!(label, Some("stg_orders".to_string()));
850            }
851            other => panic!("Expected SqlFile, got {:?}", std::mem::discriminant(&other)),
852        }
853    }
854
855    // --- stdin file-type detection tests ---
856
857    /// Verify that the fd-based metadata approach correctly identifies
858    /// /dev/null as neither a FIFO nor a regular file (so it would be skipped).
859    #[cfg(unix)]
860    #[test]
861    fn test_dev_null_is_not_fifo_or_file() {
862        use std::os::unix::fs::FileTypeExt;
863
864        let f = std::fs::File::open("/dev/null").unwrap();
865        let ft = f.metadata().unwrap().file_type();
866        // /dev/null is a character device — not a pipe, not a regular file
867        assert!(!ft.is_fifo());
868        assert!(!ft.is_file());
869    }
870
871    /// Verify that a regular file is correctly detected as a file (readable stdin source).
872    #[cfg(unix)]
873    #[test]
874    fn test_regular_file_is_file() {
875        let tmp = tempfile::NamedTempFile::new().unwrap();
876        let ft = tmp.as_file().metadata().unwrap().file_type();
877        assert!(ft.is_file());
878    }
879}