1use std::io::{self, IsTerminal, Write};
2
3use colored::Colorize;
4
5use crate::graph::impact::{ImpactReport, ImpactSeverity};
6
7pub 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
95pub 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}