Skip to main content

dlin_core/
lib.rs

1pub mod error;
2pub mod graph;
3pub mod input;
4pub mod parser;
5pub mod render;
6
7/// Graph collapse strategy.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
10pub enum CollapseMode {
11    /// Keep topological endpoints (in-degree=0 or out-degree=0) and focus models
12    Endpoints,
13    /// Keep only source/exposure nodes and focus models (ignores BFS window boundaries)
14    Focal,
15}
16
17/// Grouping strategy for graph output.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
20pub enum GroupBy {
21    /// Group nodes by node type (source, model, test, etc.)
22    NodeType,
23    /// Group nodes by their file directory
24    Directory,
25}
26
27/// Layout direction for graph output.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
30pub enum Direction {
31    /// Left to right (default)
32    LR,
33    /// Top to bottom
34    TB,
35}
36
37impl std::fmt::Display for Direction {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Direction::LR => write!(f, "LR"),
41            Direction::TB => write!(f, "TB"),
42        }
43    }
44}
45
46/// Output format for the list command.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
49pub enum ListOutputFormat {
50    Plain,
51    Json,
52}
53
54use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
55
56static QUIET: AtomicBool = AtomicBool::new(false);
57
58/// 0 = text (default), 1 = json
59static ERROR_FORMAT: AtomicU8 = AtomicU8::new(0);
60
61/// Enable quiet mode (suppress warnings on stderr).
62pub fn set_quiet(quiet: bool) {
63    QUIET.store(quiet, Ordering::Release);
64}
65
66/// Set the error output format: `true` for JSON, `false` for text (default).
67pub fn set_error_format_json(json: bool) {
68    ERROR_FORMAT.store(if json { 1 } else { 0 }, Ordering::Release);
69}
70
71/// Returns true if error output should be JSON.
72pub fn is_error_format_json() -> bool {
73    ERROR_FORMAT.load(Ordering::Acquire) == 1
74}
75
76/// Print a warning message to stderr unless quiet mode is enabled.
77/// Respects error format: emits JSON when `--error-format json` is set.
78#[macro_export]
79macro_rules! warn {
80    ($($arg:tt)*) => {
81        if !$crate::is_quiet() {
82            let msg = format!($($arg)*);
83            if $crate::is_error_format_json() {
84                eprintln!("{}", $crate::format_json_diagnostic_structured("warning", &msg, None, None));
85            } else {
86                eprintln!("Warning: {}", msg);
87            }
88        }
89    };
90}
91
92/// Format a structured diagnostic as a JSON object for stderr.
93///
94/// Always emits all four fields `{"level","what","why","hint"}` so that
95/// consumers can rely on a fixed schema. Missing values are `null`.
96pub fn format_json_diagnostic_structured(
97    level: &str,
98    what: &str,
99    why: Option<&str>,
100    hint: Option<&str>,
101) -> String {
102    fn escape_json(s: &str) -> String {
103        let mut out = String::with_capacity(s.len());
104        for c in s.chars() {
105            match c {
106                '\\' => out.push_str(r"\\"),
107                '"' => out.push_str(r#"\""#),
108                '\n' => out.push_str(r"\n"),
109                '\r' => out.push_str(r"\r"),
110                '\t' => out.push_str(r"\t"),
111                c if c < '\x20' => {
112                    out.push_str(&format!(r"\u{:04x}", c as u32));
113                }
114                c => out.push(c),
115            }
116        }
117        out
118    }
119    fn json_str_or_null(val: Option<&str>, escape: &dyn Fn(&str) -> String) -> String {
120        match val {
121            Some(s) => format!(r#""{}""#, escape(s)),
122            None => "null".to_string(),
123        }
124    }
125    let level = escape_json(level);
126    let what = escape_json(what);
127    let why = json_str_or_null(why, &escape_json);
128    let hint = json_str_or_null(hint, &escape_json);
129    format!(r#"{{"level":"{level}","what":"{what}","why":{why},"hint":{hint}}}"#)
130}
131
132/// Structured error with optional Why and Hint fields.
133///
134/// Agents and humans can both recover faster when an error says *what* went
135/// wrong, *why* it happened, and *what to do next*.
136pub struct Diagnostic {
137    pub what: String,
138    pub why: Option<String>,
139    pub hint: Option<String>,
140}
141
142impl Diagnostic {
143    /// Create a diagnostic from an `anyhow::Error`, attaching context-specific
144    /// Why / Hint when the message matches a known pattern.
145    pub fn from_error(err: &anyhow::Error) -> Self {
146        let what = format!("{err}");
147        diagnose(what)
148    }
149}
150
151/// Pattern-match an error message and attach Why / Hint when applicable.
152fn diagnose(what: String) -> Diagnostic {
153    // manifest not found (default path)
154    if what.contains("No manifest.json found at") && what.contains("Use --manifest-path") {
155        return Diagnostic {
156            what,
157            why: Some(
158                "manifest source requires a compiled manifest.json produced by dbt".to_string(),
159            ),
160            hint: Some(
161                "Run `dbt compile` to generate manifest.json, or use `--source sql` to parse SQL files directly".to_string(),
162            ),
163        };
164    }
165
166    // manifest not found (explicit --manifest-path pointing to directory)
167    if what.contains("No manifest.json found at") && what.contains("Expected target/manifest.json")
168    {
169        return Diagnostic {
170            what,
171            why: Some("the specified directory does not contain target/manifest.json".to_string()),
172            hint: Some(
173                "Run `dbt compile` inside that project, or pass the exact file path with --manifest-path <path>/target/manifest.json".to_string(),
174            ),
175        };
176    }
177
178    // manifest path does not exist
179    if what.starts_with("Manifest path does not exist:") {
180        return Diagnostic {
181            what,
182            why: None,
183            hint: Some(
184                "Check the path for typos, or run `dbt compile` to generate manifest.json"
185                    .to_string(),
186            ),
187        };
188    }
189
190    // --source sql + --manifest-path conflict
191    if what.contains("--manifest-path cannot be used with --source sql") {
192        return Diagnostic {
193            what,
194            why: Some(
195                "--source sql parses .sql files directly and does not use manifest.json"
196                    .to_string(),
197            ),
198            hint: Some("Use `--source manifest` to read from manifest.json, or remove --manifest-path to parse SQL files".to_string()),
199        };
200    }
201
202    // model not found
203    if what.starts_with("model not found:") {
204        return Diagnostic {
205            what,
206            why: None,
207            hint: Some("Check the spelling. Run `dlin list` to see available models".to_string()),
208        };
209    }
210
211    // unknown JSON field(s)
212    if what.starts_with("unknown JSON field(s):") {
213        return Diagnostic {
214            what,
215            why: None,
216            hint: Some("Use `--json-full` to emit all fields".to_string()),
217        };
218    }
219
220    // no models found (impact command)
221    if what.starts_with("no models found matching:") {
222        return Diagnostic {
223            what,
224            why: None,
225            hint: Some("Check the spelling. Run `dlin list` to see available models".to_string()),
226        };
227    }
228
229    // no model names provided (impact command)
230    if what.contains("no model names provided") {
231        return Diagnostic {
232            what,
233            why: None,
234            hint: Some(
235                "Provide model names as arguments, e.g. `dlin impact stg_orders`".to_string(),
236            ),
237        };
238    }
239
240    // dbt project not found
241    if what.contains("dbt project not found:") {
242        return Diagnostic {
243            what,
244            why: None,
245            hint: Some(
246                "Ensure you are in a dbt project directory, or use --project-dir to specify one"
247                    .to_string(),
248            ),
249        };
250    }
251
252    // cannot resolve project directory
253    if what.starts_with("cannot resolve project directory") {
254        return Diagnostic {
255            what,
256            why: None,
257            hint: Some("Check that the directory exists and is accessible".to_string()),
258        };
259    }
260
261    // Fallback: no Why/Hint
262    Diagnostic {
263        what,
264        why: None,
265        hint: None,
266    }
267}
268
269/// Format a [`Diagnostic`] for stderr output, respecting the current error format.
270pub fn format_diagnostic(diag: &Diagnostic) -> String {
271    if is_error_format_json() {
272        format_json_diagnostic_structured(
273            "error",
274            &diag.what,
275            diag.why.as_deref(),
276            diag.hint.as_deref(),
277        )
278    } else {
279        let mut out = format!("Error: {}", diag.what);
280        if let Some(ref why) = diag.why {
281            out.push_str(&format!("\n  Why: {why}"));
282        }
283        if let Some(ref hint) = diag.hint {
284            out.push_str(&format!("\n  Hint: {hint}"));
285        }
286        out
287    }
288}
289
290/// Format an error for stderr output, respecting the current error format.
291pub fn format_error(err: &dyn std::fmt::Display) -> String {
292    if is_error_format_json() {
293        format_json_diagnostic_structured("error", &err.to_string(), None, None)
294    } else {
295        format!("Error: {err}")
296    }
297}
298
299/// Returns true if quiet mode is enabled.
300pub fn is_quiet() -> bool {
301    QUIET.load(Ordering::Acquire)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use serial_test::serial;
308
309    #[test]
310    #[serial]
311    fn test_quiet_flag() {
312        // Default is not quiet
313        set_quiet(false);
314        assert!(!is_quiet());
315
316        set_quiet(true);
317        assert!(is_quiet());
318
319        // warn! should not panic in quiet mode
320        warn!("this should be suppressed");
321
322        set_quiet(false);
323        assert!(!is_quiet());
324    }
325
326    #[test]
327    #[serial]
328    fn test_error_format_flag() {
329        set_error_format_json(false);
330        assert!(!is_error_format_json());
331
332        set_error_format_json(true);
333        assert!(is_error_format_json());
334
335        set_error_format_json(false);
336        assert!(!is_error_format_json());
337    }
338
339    #[test]
340    fn test_format_json_diagnostic_structured_basic() {
341        let json = format_json_diagnostic_structured("error", "something broke", None, None);
342        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
343        assert_eq!(parsed["level"], "error");
344        assert_eq!(parsed["what"], "something broke");
345        assert!(parsed["why"].is_null());
346        assert!(parsed["hint"].is_null());
347    }
348
349    #[test]
350    fn test_format_json_diagnostic_structured_escaping_quotes() {
351        let json = format_json_diagnostic_structured(
352            "warning",
353            concat!(r#"bad "quotes" and"#, "\nnewline"),
354            None,
355            None,
356        );
357        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
358        assert_eq!(parsed["level"], "warning");
359        assert_eq!(parsed["what"], concat!(r#"bad "quotes" and"#, "\nnewline"));
360    }
361
362    #[test]
363    fn test_format_json_diagnostic_structured_backslash() {
364        let json = format_json_diagnostic_structured("error", r"path\to\file", None, None);
365        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
366        assert_eq!(parsed["what"], r"path\to\file");
367    }
368
369    #[test]
370    fn test_format_json_diagnostic_structured_control_chars() {
371        // RFC 8259: control characters U+0000–U+001F must be \uXXXX escaped
372        let json = format_json_diagnostic_structured(
373            "error",
374            "null:\x00 bell:\x07 backspace:\x08",
375            None,
376            None,
377        );
378        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
379        assert_eq!(parsed["level"], "error");
380        assert_eq!(parsed["what"], "null:\x00 bell:\x07 backspace:\x08");
381    }
382
383    #[test]
384    #[serial]
385    fn test_format_error_text() {
386        set_error_format_json(false);
387        let msg = format_error(&"something went wrong");
388        assert_eq!(msg, "Error: something went wrong");
389    }
390
391    #[test]
392    #[serial]
393    fn test_format_error_json() {
394        set_error_format_json(true);
395        let msg = format_error(&"something went wrong");
396        let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
397        assert_eq!(parsed["level"], "error");
398        assert_eq!(parsed["what"], "something went wrong");
399        set_error_format_json(false);
400    }
401
402    #[test]
403    fn test_diagnose_manifest_not_found_default() {
404        let diag = diagnose(
405            "No manifest.json found at /foo/target/manifest.json. Use --manifest-path or run `dbt compile` first.".to_string(),
406        );
407        assert!(diag.why.is_some());
408        assert!(diag.hint.as_ref().unwrap().contains("dbt compile"));
409        assert!(diag.hint.as_ref().unwrap().contains("--source sql"));
410    }
411
412    #[test]
413    fn test_diagnose_manifest_not_found_directory() {
414        let diag = diagnose(
415            "No manifest.json found at /foo/target/manifest.json. Expected target/manifest.json in the directory.".to_string(),
416        );
417        assert!(diag.why.is_some());
418        assert!(diag.hint.is_some());
419    }
420
421    #[test]
422    fn test_diagnose_manifest_path_missing() {
423        let diag = diagnose("Manifest path does not exist: /nonexistent".to_string());
424        assert!(diag.why.is_none());
425        assert!(diag.hint.as_ref().unwrap().contains("typos"));
426    }
427
428    #[test]
429    fn test_diagnose_source_flag_conflict() {
430        let diag = diagnose(
431            "--manifest-path cannot be used with --source sql; did you mean --source manifest?"
432                .to_string(),
433        );
434        assert!(diag.why.is_some());
435        assert!(diag.hint.as_ref().unwrap().contains("--source manifest"));
436    }
437
438    #[test]
439    fn test_diagnose_model_not_found() {
440        let diag = diagnose("model not found: stg_orders".to_string());
441        assert!(diag.hint.as_ref().unwrap().contains("dlin list"));
442    }
443
444    #[test]
445    fn test_diagnose_unknown_json_fields() {
446        let diag = diagnose("unknown JSON field(s): foo, bar. Available fields: a, b".to_string());
447        assert!(diag.hint.as_ref().unwrap().contains("--json-full"));
448    }
449
450    #[test]
451    fn test_diagnose_project_not_found() {
452        let diag = diagnose("dbt project not found: no dbt_project.yml in /foo".to_string());
453        assert!(diag.hint.as_ref().unwrap().contains("--project-dir"));
454    }
455
456    #[test]
457    fn test_diagnose_fallback_no_hint() {
458        let diag = diagnose("some unknown error".to_string());
459        assert_eq!(diag.what, "some unknown error");
460        assert!(diag.why.is_none());
461        assert!(diag.hint.is_none());
462    }
463
464    #[test]
465    #[serial]
466    fn test_format_diagnostic_text() {
467        set_error_format_json(false);
468        let diag = Diagnostic {
469            what: "model not found: foo".to_string(),
470            why: None,
471            hint: Some("Run `dlin list`".to_string()),
472        };
473        let out = format_diagnostic(&diag);
474        assert_eq!(out, "Error: model not found: foo\n  Hint: Run `dlin list`");
475    }
476
477    #[test]
478    #[serial]
479    fn test_format_diagnostic_text_with_why() {
480        set_error_format_json(false);
481        let diag = Diagnostic {
482            what: "something failed".to_string(),
483            why: Some("the reason".to_string()),
484            hint: Some("do this".to_string()),
485        };
486        let out = format_diagnostic(&diag);
487        assert_eq!(
488            out,
489            "Error: something failed\n  Why: the reason\n  Hint: do this"
490        );
491    }
492
493    #[test]
494    #[serial]
495    fn test_format_diagnostic_json_with_hint() {
496        set_error_format_json(true);
497        let diag = Diagnostic {
498            what: "model not found: foo".to_string(),
499            why: None,
500            hint: Some("Run `dlin list`".to_string()),
501        };
502        let out = format_diagnostic(&diag);
503        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
504        assert_eq!(parsed["level"], "error");
505        assert_eq!(parsed["what"], "model not found: foo");
506        assert!(parsed["why"].is_null());
507        assert_eq!(parsed["hint"], "Run `dlin list`");
508        set_error_format_json(false);
509    }
510
511    #[test]
512    #[serial]
513    fn test_format_diagnostic_json_full() {
514        set_error_format_json(true);
515        let diag = Diagnostic {
516            what: "it broke".to_string(),
517            why: Some("bad input".to_string()),
518            hint: Some("fix it".to_string()),
519        };
520        let out = format_diagnostic(&diag);
521        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
522        assert_eq!(parsed["what"], "it broke");
523        assert_eq!(parsed["why"], "bad input");
524        assert_eq!(parsed["hint"], "fix it");
525        set_error_format_json(false);
526    }
527
528    #[test]
529    #[serial]
530    fn test_format_diagnostic_json_no_hint() {
531        set_error_format_json(true);
532        let diag = Diagnostic {
533            what: "unknown error".to_string(),
534            why: None,
535            hint: None,
536        };
537        let out = format_diagnostic(&diag);
538        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
539        assert_eq!(parsed["what"], "unknown error");
540        assert!(parsed["why"].is_null());
541        assert!(parsed["hint"].is_null());
542        set_error_format_json(false);
543    }
544
545    #[test]
546    fn test_format_json_diagnostic_structured_escaping() {
547        let json = format_json_diagnostic_structured(
548            "error",
549            r#"bad "quotes""#,
550            Some("line\nnewline"),
551            Some(r"path\to\fix"),
552        );
553        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
554        assert_eq!(parsed["what"], r#"bad "quotes""#);
555        assert_eq!(parsed["why"], "line\nnewline");
556        assert_eq!(parsed["hint"], r"path\to\fix");
557    }
558}