use std::io::{self, IsTerminal, Write};
use colored::Colorize;
use crate::graph::impact::{ImpactReport, ImpactSeverity};
pub fn render_impact_text(report: &ImpactReport) {
super::handle_stdout_result(render_impact_text_to_writer(
report,
&mut std::io::stdout().lock(),
));
}
fn severity_color(severity: ImpactSeverity) -> colored::Color {
match severity {
ImpactSeverity::Low => colored::Color::Green,
ImpactSeverity::Medium => colored::Color::Yellow,
ImpactSeverity::High => colored::Color::Red,
ImpactSeverity::Critical => colored::Color::BrightRed,
}
}
pub fn render_impact_text_to_writer<W: Write>(report: &ImpactReport, w: &mut W) -> io::Result<()> {
writeln!(w)?;
writeln!(
w,
"{}",
format!("Impact Analysis: {}", report.source_model).bold()
)?;
writeln!(w, "{}", "=".repeat(50))?;
let severity_str = report
.overall_severity
.label()
.to_uppercase()
.color(severity_color(report.overall_severity))
.bold();
writeln!(w, "Overall Severity: {}", severity_str)?;
writeln!(w)?;
writeln!(w, "{}", "Summary:".bold())?;
writeln!(w, " Affected models: {}", report.affected_models)?;
writeln!(w, " Affected tests: {}", report.affected_tests)?;
writeln!(w, " Affected exposures: {}", report.affected_exposures)?;
writeln!(w)?;
if !report.exposure_paths.is_empty() {
writeln!(w, "{}", "Exposure Paths:".bold())?;
for ep in &report.exposure_paths {
writeln!(w, " {}", ep.path.join(" -> "))?;
}
if report.exposure_paths_truncated {
writeln!(
w,
" {} Use `dlin graph {}` to see the full lineage.",
"(truncated)".dimmed(),
report.source_model
)?;
}
writeln!(w)?;
}
if !report.impacted_nodes.is_empty() {
writeln!(w, "{}", "Impacted Nodes:".bold())?;
for node in &report.impacted_nodes {
let sev = node.severity.label().color(severity_color(node.severity));
if let Some(ref path) = node.file_path {
writeln!(
w,
" [{:<8}] {} ({}, distance: {}) [{}]",
sev, node.label, node.node_type, node.distance, path
)?;
} else {
writeln!(
w,
" [{:<8}] {} ({}, distance: {})",
sev, node.label, node.node_type, node.distance
)?;
}
if let Some(ref sql) = node.sql_content {
writeln!(w, " {}", "--- SQL ---".dimmed())?;
for line in sql.lines() {
writeln!(w, " {}", line)?;
}
writeln!(w, " {}", "----------".dimmed())?;
}
}
}
writeln!(w)?;
Ok(())
}
pub fn render_impact_json(reports: &[ImpactReport]) {
let mut stdout = std::io::stdout().lock();
let pretty = stdout.is_terminal();
super::handle_stdout_result(render_impact_json_to_writer(reports, &mut stdout, pretty));
}
pub fn render_impact_json_to_writer<W: Write>(
reports: &[ImpactReport],
w: &mut W,
pretty: bool,
) -> io::Result<()> {
if pretty {
serde_json::to_writer_pretty(&mut *w, reports).map_err(super::serde_io_error)?;
} else {
serde_json::to_writer(&mut *w, reports).map_err(super::serde_io_error)?;
}
writeln!(w)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::impact::{ExposurePath, ImpactReport, ImpactSeverity, ImpactedNode};
fn make_report() -> ImpactReport {
ImpactReport {
source_model: "stg_orders".to_string(),
overall_severity: ImpactSeverity::Critical,
affected_models: 1,
affected_tests: 1,
affected_exposures: 1,
exposure_paths: vec![ExposurePath {
exposure: "dashboard".to_string(),
path: vec![
"stg_orders".to_string(),
"orders".to_string(),
"dashboard".to_string(),
],
}],
exposure_paths_truncated: false,
impacted_nodes: vec![
ImpactedNode {
unique_id: "exposure.dashboard".to_string(),
label: "dashboard".to_string(),
node_type: "exposure".to_string(),
file_path: None,
severity: ImpactSeverity::Critical,
distance: 2,
sql_content: None,
},
ImpactedNode {
unique_id: "model.orders".to_string(),
label: "orders".to_string(),
node_type: "model".to_string(),
file_path: Some("models/marts/orders.sql".to_string()),
severity: ImpactSeverity::High,
distance: 1,
sql_content: None,
},
ImpactedNode {
unique_id: "test.orders_positive".to_string(),
label: "orders_positive".to_string(),
node_type: "test".to_string(),
file_path: None,
severity: ImpactSeverity::Low,
distance: 2,
sql_content: None,
},
],
}
}
#[test]
fn test_render_impact_text() {
let report = make_report();
let mut buf = Vec::new();
render_impact_text_to_writer(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Impact Analysis: stg_orders"));
assert!(output.contains("Affected models: 1"));
assert!(output.contains("Affected tests: 1"));
assert!(output.contains("Affected exposures: 1"));
assert!(output.contains("Exposure Paths:"));
assert!(output.contains("stg_orders -> orders -> dashboard"));
assert!(output.contains("Impacted Nodes:"));
}
#[test]
fn test_render_impact_json() {
let report = make_report();
let mut buf = Vec::new();
render_impact_json_to_writer(&[report], &mut buf, true).unwrap();
let output = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
let first = &arr[0];
assert_eq!(first["source_model"], "stg_orders");
assert_eq!(first["overall_severity"], "critical");
assert_eq!(first["affected_models"], 1);
assert_eq!(first["impacted_nodes"].as_array().unwrap().len(), 3);
let paths = first["exposure_paths"].as_array().unwrap();
assert_eq!(paths.len(), 1);
assert_eq!(paths[0]["exposure"], "dashboard");
assert_eq!(
paths[0]["path"].as_array().unwrap(),
&["stg_orders", "orders", "dashboard"]
);
}
#[test]
fn test_render_impact_text_empty() {
let report = ImpactReport {
source_model: "isolated".to_string(),
overall_severity: ImpactSeverity::Low,
affected_models: 0,
affected_tests: 0,
affected_exposures: 0,
exposure_paths: vec![],
exposure_paths_truncated: false,
impacted_nodes: vec![],
};
let mut buf = Vec::new();
render_impact_text_to_writer(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Impact Analysis: isolated"));
assert!(output.contains("Affected models: 0"));
}
#[test]
fn test_severity_color_all_levels() {
assert_eq!(severity_color(ImpactSeverity::Low), colored::Color::Green);
assert_eq!(
severity_color(ImpactSeverity::Medium),
colored::Color::Yellow
);
assert_eq!(severity_color(ImpactSeverity::High), colored::Color::Red);
assert_eq!(
severity_color(ImpactSeverity::Critical),
colored::Color::BrightRed
);
}
#[test]
fn test_render_impact_text_medium_severity() {
let report = ImpactReport {
source_model: "stg_payments".to_string(),
overall_severity: ImpactSeverity::Medium,
affected_models: 2,
affected_tests: 0,
affected_exposures: 0,
exposure_paths: vec![],
exposure_paths_truncated: false,
impacted_nodes: vec![ImpactedNode {
unique_id: "model.payments".to_string(),
label: "payments".to_string(),
node_type: "model".to_string(),
file_path: None,
severity: ImpactSeverity::Medium,
distance: 1,
sql_content: None,
}],
};
let mut buf = Vec::new();
render_impact_text_to_writer(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Impact Analysis: stg_payments"));
assert!(output.contains("MEDIUM"));
assert!(output.contains("Affected models: 2"));
assert!(output.contains("Impacted Nodes:"));
assert!(output.contains("payments"));
}
#[test]
fn test_snapshot_impact_text() {
colored::control::set_override(false);
let report = make_report();
let mut buf = Vec::new();
render_impact_text_to_writer(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
insta::assert_snapshot!(output);
}
#[test]
fn test_snapshot_impact_json() {
let report = make_report();
let mut buf = Vec::new();
render_impact_json_to_writer(&[report], &mut buf, true).unwrap();
let output = String::from_utf8(buf).unwrap();
insta::assert_snapshot!(output);
}
#[test]
fn test_render_impact_json_multiple() {
let report1 = make_report();
let report2 = ImpactReport {
source_model: "orders".to_string(),
overall_severity: ImpactSeverity::Low,
affected_models: 0,
affected_tests: 0,
affected_exposures: 0,
exposure_paths: vec![],
exposure_paths_truncated: false,
impacted_nodes: vec![],
};
let mut buf = Vec::new();
render_impact_json_to_writer(&[report1, report2], &mut buf, true).unwrap();
let output = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["source_model"], "stg_orders");
assert_eq!(arr[1]["source_model"], "orders");
}
#[test]
fn test_compact_impact_json_single_line() {
let report = make_report();
let mut buf = Vec::new();
render_impact_json_to_writer(&[report], &mut buf, false).unwrap();
let output = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = output.trim_end().split('\n').collect();
assert_eq!(lines.len(), 1, "compact JSON should be a single line");
let _: serde_json::Value = serde_json::from_str(&output).unwrap();
}
#[test]
fn test_render_impact_json_empty() {
let mut buf = Vec::new();
render_impact_json_to_writer(&[], &mut buf, true).unwrap();
let output = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed.as_array().unwrap().len(), 0);
}
fn make_report_with_sql() -> ImpactReport {
ImpactReport {
source_model: "stg_orders".to_string(),
overall_severity: ImpactSeverity::Critical,
affected_models: 1,
affected_tests: 1,
affected_exposures: 1,
exposure_paths: vec![ExposurePath {
exposure: "dashboard".to_string(),
path: vec![
"stg_orders".to_string(),
"orders".to_string(),
"dashboard".to_string(),
],
}],
exposure_paths_truncated: false,
impacted_nodes: vec![
ImpactedNode {
unique_id: "exposure.dashboard".to_string(),
label: "dashboard".to_string(),
node_type: "exposure".to_string(),
file_path: None,
severity: ImpactSeverity::Critical,
distance: 2,
sql_content: None,
},
ImpactedNode {
unique_id: "model.orders".to_string(),
label: "orders".to_string(),
node_type: "model".to_string(),
file_path: Some("models/marts/orders.sql".to_string()),
severity: ImpactSeverity::High,
distance: 1,
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()),
},
ImpactedNode {
unique_id: "test.orders_positive".to_string(),
label: "orders_positive".to_string(),
node_type: "test".to_string(),
file_path: None,
severity: ImpactSeverity::Low,
distance: 2,
sql_content: None,
},
],
}
}
#[test]
fn test_snapshot_impact_text_with_sql() {
colored::control::set_override(false);
let report = make_report_with_sql();
let mut buf = Vec::new();
render_impact_text_to_writer(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
insta::assert_snapshot!(output);
}
#[test]
fn test_snapshot_impact_json_with_sql() {
let report = make_report_with_sql();
let mut buf = Vec::new();
render_impact_json_to_writer(&[report], &mut buf, true).unwrap();
let output = String::from_utf8(buf).unwrap();
insta::assert_snapshot!(output);
}
#[test]
fn test_render_impact_text_truncated_paths() {
let report = ImpactReport {
source_model: "stg_orders".to_string(),
overall_severity: ImpactSeverity::Critical,
affected_models: 1,
affected_tests: 0,
affected_exposures: 1,
exposure_paths: vec![ExposurePath {
exposure: "dashboard".to_string(),
path: vec![
"stg_orders".to_string(),
"orders".to_string(),
"dashboard".to_string(),
],
}],
exposure_paths_truncated: true,
impacted_nodes: vec![],
};
let mut buf = Vec::new();
render_impact_text_to_writer(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("(truncated)"));
assert!(output.contains("dlin graph stg_orders"));
}
}