1pub mod error;
2pub mod graph;
3pub mod input;
4pub mod parser;
5pub mod render;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
10pub enum CollapseMode {
11 Endpoints,
13 Focal,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
20pub enum GroupBy {
21 NodeType,
23 Directory,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
30pub enum Direction {
31 LR,
33 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#[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
58static ERROR_FORMAT: AtomicU8 = AtomicU8::new(0);
60
61pub fn set_quiet(quiet: bool) {
63 QUIET.store(quiet, Ordering::Release);
64}
65
66pub fn set_error_format_json(json: bool) {
68 ERROR_FORMAT.store(if json { 1 } else { 0 }, Ordering::Release);
69}
70
71pub fn is_error_format_json() -> bool {
73 ERROR_FORMAT.load(Ordering::Acquire) == 1
74}
75
76#[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
92pub 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
132pub struct Diagnostic {
137 pub what: String,
138 pub why: Option<String>,
139 pub hint: Option<String>,
140}
141
142impl Diagnostic {
143 pub fn from_error(err: &anyhow::Error) -> Self {
146 let what = format!("{err}");
147 diagnose(what)
148 }
149}
150
151fn diagnose(what: String) -> Diagnostic {
153 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 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 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 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 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 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 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 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 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 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 Diagnostic {
263 what,
264 why: None,
265 hint: None,
266 }
267}
268
269pub 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
290pub 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
299pub 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 set_quiet(false);
314 assert!(!is_quiet());
315
316 set_quiet(true);
317 assert!(is_quiet());
318
319 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 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}