Skip to main content

dlin_core/render/
impact.rs

1use std::io::{self, IsTerminal, Write};
2
3use colored::Colorize;
4
5use crate::graph::impact::{ImpactReport, ImpactSeverity};
6
7/// Render impact report as colored text to stdout
8pub fn render_impact_text(report: &ImpactReport) {
9    super::handle_stdout_result(render_impact_text_to_writer(
10        report,
11        &mut std::io::stdout().lock(),
12    ));
13}
14
15fn severity_color(severity: ImpactSeverity) -> colored::Color {
16    match severity {
17        ImpactSeverity::Low => colored::Color::Green,
18        ImpactSeverity::Medium => colored::Color::Yellow,
19        ImpactSeverity::High => colored::Color::Red,
20        ImpactSeverity::Critical => colored::Color::BrightRed,
21    }
22}
23
24pub fn render_impact_text_to_writer<W: Write>(report: &ImpactReport, w: &mut W) -> io::Result<()> {
25    writeln!(w)?;
26    writeln!(
27        w,
28        "{}",
29        format!("Impact Analysis: {}", report.source_model).bold()
30    )?;
31    writeln!(w, "{}", "=".repeat(50))?;
32
33    let severity_str = report
34        .overall_severity
35        .label()
36        .to_uppercase()
37        .color(severity_color(report.overall_severity))
38        .bold();
39    writeln!(w, "Overall Severity: {}", severity_str)?;
40    writeln!(w)?;
41
42    writeln!(w, "{}", "Summary:".bold())?;
43    writeln!(w, "  Affected models:    {}", report.affected_models)?;
44    writeln!(w, "  Affected tests:     {}", report.affected_tests)?;
45    writeln!(w, "  Affected exposures: {}", report.affected_exposures)?;
46    writeln!(w)?;
47
48    if !report.exposure_paths.is_empty() {
49        writeln!(w, "{}", "Exposure Paths:".bold())?;
50        for ep in &report.exposure_paths {
51            writeln!(w, "  {}", ep.path.join(" -> "))?;
52        }
53        if report.exposure_paths_truncated {
54            writeln!(
55                w,
56                "  {} Use `dlin graph {}` to see the full lineage.",
57                "(truncated)".dimmed(),
58                report.source_model
59            )?;
60        }
61        writeln!(w)?;
62    }
63
64    if !report.impacted_nodes.is_empty() {
65        writeln!(w, "{}", "Impacted Nodes:".bold())?;
66        for node in &report.impacted_nodes {
67            let sev = node.severity.label().color(severity_color(node.severity));
68            if let Some(ref path) = node.file_path {
69                writeln!(
70                    w,
71                    "  [{:<8}] {} ({}, distance: {}) [{}]",
72                    sev, node.label, node.node_type, node.distance, path
73                )?;
74            } else {
75                writeln!(
76                    w,
77                    "  [{:<8}] {} ({}, distance: {})",
78                    sev, node.label, node.node_type, node.distance
79                )?;
80            }
81            if let Some(ref sql) = node.sql_content {
82                writeln!(w, "    {}", "--- SQL ---".dimmed())?;
83                for line in sql.lines() {
84                    writeln!(w, "    {}", line)?;
85                }
86                writeln!(w, "    {}", "----------".dimmed())?;
87            }
88        }
89    }
90
91    writeln!(w)?;
92    Ok(())
93}
94
95/// Render impact reports as a JSON array to stdout.
96/// Pretty-prints when stdout is a terminal, compact otherwise.
97pub fn render_impact_json(reports: &[ImpactReport]) {
98    let mut stdout = std::io::stdout().lock();
99    let pretty = stdout.is_terminal();
100    super::handle_stdout_result(render_impact_json_to_writer(reports, &mut stdout, pretty));
101}
102
103pub fn render_impact_json_to_writer<W: Write>(
104    reports: &[ImpactReport],
105    w: &mut W,
106    pretty: bool,
107) -> io::Result<()> {
108    if pretty {
109        serde_json::to_writer_pretty(&mut *w, reports).map_err(super::serde_io_error)?;
110    } else {
111        serde_json::to_writer(&mut *w, reports).map_err(super::serde_io_error)?;
112    }
113    writeln!(w)?;
114    Ok(())
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::graph::impact::{ExposurePath, ImpactReport, ImpactSeverity, ImpactedNode};
121
122    fn make_report() -> ImpactReport {
123        ImpactReport {
124            source_model: "stg_orders".to_string(),
125            overall_severity: ImpactSeverity::Critical,
126            affected_models: 1,
127            affected_tests: 1,
128            affected_exposures: 1,
129            exposure_paths: vec![ExposurePath {
130                exposure: "dashboard".to_string(),
131                path: vec![
132                    "stg_orders".to_string(),
133                    "orders".to_string(),
134                    "dashboard".to_string(),
135                ],
136            }],
137            exposure_paths_truncated: false,
138            impacted_nodes: vec![
139                ImpactedNode {
140                    unique_id: "exposure.dashboard".to_string(),
141                    label: "dashboard".to_string(),
142                    node_type: "exposure".to_string(),
143                    file_path: None,
144                    severity: ImpactSeverity::Critical,
145                    distance: 2,
146                    sql_content: None,
147                },
148                ImpactedNode {
149                    unique_id: "model.orders".to_string(),
150                    label: "orders".to_string(),
151                    node_type: "model".to_string(),
152                    file_path: Some("models/marts/orders.sql".to_string()),
153                    severity: ImpactSeverity::High,
154                    distance: 1,
155                    sql_content: None,
156                },
157                ImpactedNode {
158                    unique_id: "test.orders_positive".to_string(),
159                    label: "orders_positive".to_string(),
160                    node_type: "test".to_string(),
161                    file_path: None,
162                    severity: ImpactSeverity::Low,
163                    distance: 2,
164                    sql_content: None,
165                },
166            ],
167        }
168    }
169
170    #[test]
171    fn test_render_impact_text() {
172        let report = make_report();
173        let mut buf = Vec::new();
174        render_impact_text_to_writer(&report, &mut buf).unwrap();
175        let output = String::from_utf8(buf).unwrap();
176
177        assert!(output.contains("Impact Analysis: stg_orders"));
178        assert!(output.contains("Affected models:    1"));
179        assert!(output.contains("Affected tests:     1"));
180        assert!(output.contains("Affected exposures: 1"));
181        assert!(output.contains("Exposure Paths:"));
182        assert!(output.contains("stg_orders -> orders -> dashboard"));
183        assert!(output.contains("Impacted Nodes:"));
184    }
185
186    #[test]
187    fn test_render_impact_json() {
188        let report = make_report();
189        let mut buf = Vec::new();
190        render_impact_json_to_writer(&[report], &mut buf, true).unwrap();
191        let output = String::from_utf8(buf).unwrap();
192
193        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
194        let arr = parsed.as_array().unwrap();
195        assert_eq!(arr.len(), 1);
196        let first = &arr[0];
197        assert_eq!(first["source_model"], "stg_orders");
198        assert_eq!(first["overall_severity"], "critical");
199        assert_eq!(first["affected_models"], 1);
200        assert_eq!(first["impacted_nodes"].as_array().unwrap().len(), 3);
201
202        let paths = first["exposure_paths"].as_array().unwrap();
203        assert_eq!(paths.len(), 1);
204        assert_eq!(paths[0]["exposure"], "dashboard");
205        assert_eq!(
206            paths[0]["path"].as_array().unwrap(),
207            &["stg_orders", "orders", "dashboard"]
208        );
209    }
210
211    #[test]
212    fn test_render_impact_text_empty() {
213        let report = ImpactReport {
214            source_model: "isolated".to_string(),
215            overall_severity: ImpactSeverity::Low,
216            affected_models: 0,
217            affected_tests: 0,
218            affected_exposures: 0,
219            exposure_paths: vec![],
220            exposure_paths_truncated: false,
221            impacted_nodes: vec![],
222        };
223        let mut buf = Vec::new();
224        render_impact_text_to_writer(&report, &mut buf).unwrap();
225        let output = String::from_utf8(buf).unwrap();
226        assert!(output.contains("Impact Analysis: isolated"));
227        assert!(output.contains("Affected models:    0"));
228    }
229
230    #[test]
231    fn test_severity_color_all_levels() {
232        assert_eq!(severity_color(ImpactSeverity::Low), colored::Color::Green);
233        assert_eq!(
234            severity_color(ImpactSeverity::Medium),
235            colored::Color::Yellow
236        );
237        assert_eq!(severity_color(ImpactSeverity::High), colored::Color::Red);
238        assert_eq!(
239            severity_color(ImpactSeverity::Critical),
240            colored::Color::BrightRed
241        );
242    }
243
244    #[test]
245    fn test_render_impact_text_medium_severity() {
246        let report = ImpactReport {
247            source_model: "stg_payments".to_string(),
248            overall_severity: ImpactSeverity::Medium,
249            affected_models: 2,
250            affected_tests: 0,
251            affected_exposures: 0,
252            exposure_paths: vec![],
253            exposure_paths_truncated: false,
254            impacted_nodes: vec![ImpactedNode {
255                unique_id: "model.payments".to_string(),
256                label: "payments".to_string(),
257                node_type: "model".to_string(),
258                file_path: None,
259                severity: ImpactSeverity::Medium,
260                distance: 1,
261                sql_content: None,
262            }],
263        };
264        let mut buf = Vec::new();
265        render_impact_text_to_writer(&report, &mut buf).unwrap();
266        let output = String::from_utf8(buf).unwrap();
267        assert!(output.contains("Impact Analysis: stg_payments"));
268        assert!(output.contains("MEDIUM"));
269        assert!(output.contains("Affected models:    2"));
270        assert!(output.contains("Impacted Nodes:"));
271        assert!(output.contains("payments"));
272    }
273
274    #[test]
275    fn test_snapshot_impact_text() {
276        colored::control::set_override(false);
277        let report = make_report();
278        let mut buf = Vec::new();
279        render_impact_text_to_writer(&report, &mut buf).unwrap();
280        let output = String::from_utf8(buf).unwrap();
281        insta::assert_snapshot!(output);
282    }
283
284    #[test]
285    fn test_snapshot_impact_json() {
286        let report = make_report();
287        let mut buf = Vec::new();
288        render_impact_json_to_writer(&[report], &mut buf, true).unwrap();
289        let output = String::from_utf8(buf).unwrap();
290        insta::assert_snapshot!(output);
291    }
292
293    #[test]
294    fn test_render_impact_json_multiple() {
295        let report1 = make_report();
296        let report2 = ImpactReport {
297            source_model: "orders".to_string(),
298            overall_severity: ImpactSeverity::Low,
299            affected_models: 0,
300            affected_tests: 0,
301            affected_exposures: 0,
302            exposure_paths: vec![],
303            exposure_paths_truncated: false,
304            impacted_nodes: vec![],
305        };
306        let mut buf = Vec::new();
307        render_impact_json_to_writer(&[report1, report2], &mut buf, true).unwrap();
308        let output = String::from_utf8(buf).unwrap();
309
310        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
311        let arr = parsed.as_array().unwrap();
312        assert_eq!(arr.len(), 2);
313        assert_eq!(arr[0]["source_model"], "stg_orders");
314        assert_eq!(arr[1]["source_model"], "orders");
315    }
316
317    #[test]
318    fn test_compact_impact_json_single_line() {
319        let report = make_report();
320        let mut buf = Vec::new();
321        render_impact_json_to_writer(&[report], &mut buf, false).unwrap();
322        let output = String::from_utf8(buf).unwrap();
323        let lines: Vec<&str> = output.trim_end().split('\n').collect();
324        assert_eq!(lines.len(), 1, "compact JSON should be a single line");
325        let _: serde_json::Value = serde_json::from_str(&output).unwrap();
326    }
327
328    #[test]
329    fn test_render_impact_json_empty() {
330        let mut buf = Vec::new();
331        render_impact_json_to_writer(&[], &mut buf, true).unwrap();
332        let output = String::from_utf8(buf).unwrap();
333
334        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
335        assert_eq!(parsed.as_array().unwrap().len(), 0);
336    }
337
338    fn make_report_with_sql() -> ImpactReport {
339        ImpactReport {
340            source_model: "stg_orders".to_string(),
341            overall_severity: ImpactSeverity::Critical,
342            affected_models: 1,
343            affected_tests: 1,
344            affected_exposures: 1,
345            exposure_paths: vec![ExposurePath {
346                exposure: "dashboard".to_string(),
347                path: vec![
348                    "stg_orders".to_string(),
349                    "orders".to_string(),
350                    "dashboard".to_string(),
351                ],
352            }],
353            exposure_paths_truncated: false,
354            impacted_nodes: vec![
355                ImpactedNode {
356                    unique_id: "exposure.dashboard".to_string(),
357                    label: "dashboard".to_string(),
358                    node_type: "exposure".to_string(),
359                    file_path: None,
360                    severity: ImpactSeverity::Critical,
361                    distance: 2,
362                    sql_content: None,
363                },
364                ImpactedNode {
365                    unique_id: "model.orders".to_string(),
366                    label: "orders".to_string(),
367                    node_type: "model".to_string(),
368                    file_path: Some("models/marts/orders.sql".to_string()),
369                    severity: ImpactSeverity::High,
370                    distance: 1,
371                    sql_content: Some("SELECT\n    o.id,\n    o.status,\n    s.total\nFROM {{ ref('stg_orders') }} o\nJOIN {{ ref('stg_payments') }} s ON o.id = s.order_id".to_string()),
372                },
373                ImpactedNode {
374                    unique_id: "test.orders_positive".to_string(),
375                    label: "orders_positive".to_string(),
376                    node_type: "test".to_string(),
377                    file_path: None,
378                    severity: ImpactSeverity::Low,
379                    distance: 2,
380                    sql_content: None,
381                },
382            ],
383        }
384    }
385
386    #[test]
387    fn test_snapshot_impact_text_with_sql() {
388        colored::control::set_override(false);
389        let report = make_report_with_sql();
390        let mut buf = Vec::new();
391        render_impact_text_to_writer(&report, &mut buf).unwrap();
392        let output = String::from_utf8(buf).unwrap();
393        insta::assert_snapshot!(output);
394    }
395
396    #[test]
397    fn test_snapshot_impact_json_with_sql() {
398        let report = make_report_with_sql();
399        let mut buf = Vec::new();
400        render_impact_json_to_writer(&[report], &mut buf, true).unwrap();
401        let output = String::from_utf8(buf).unwrap();
402        insta::assert_snapshot!(output);
403    }
404
405    #[test]
406    fn test_render_impact_text_truncated_paths() {
407        let report = ImpactReport {
408            source_model: "stg_orders".to_string(),
409            overall_severity: ImpactSeverity::Critical,
410            affected_models: 1,
411            affected_tests: 0,
412            affected_exposures: 1,
413            exposure_paths: vec![ExposurePath {
414                exposure: "dashboard".to_string(),
415                path: vec![
416                    "stg_orders".to_string(),
417                    "orders".to_string(),
418                    "dashboard".to_string(),
419                ],
420            }],
421            exposure_paths_truncated: true,
422            impacted_nodes: vec![],
423        };
424        let mut buf = Vec::new();
425        render_impact_text_to_writer(&report, &mut buf).unwrap();
426        let output = String::from_utf8(buf).unwrap();
427        assert!(output.contains("(truncated)"));
428        assert!(output.contains("dlin graph stg_orders"));
429    }
430}