use std::io::{self, IsTerminal, Write};
use serde::Serialize;
use crate::graph::filter::KNOWN_NODE_TYPE_LABELS;
use crate::graph::types::*;
#[derive(Debug, Serialize)]
pub struct SummaryReport {
pub project_name: String,
pub source_mode: String,
pub node_counts: NodeCounts,
pub edge_count: usize,
pub vars_count: usize,
pub manifest_status: Option<ManifestStatus>,
}
#[derive(Debug, Serialize)]
pub struct NodeCounts {
pub model: usize,
pub source: usize,
pub seed: usize,
pub snapshot: usize,
pub test: usize,
pub exposure: usize,
pub phantom: usize,
pub total: usize,
}
const MAX_FILES_TEXT: usize = 5;
#[derive(Debug, Serialize)]
pub struct ManifestStatus {
pub found: bool,
pub is_stale: bool,
pub stale_file_count: usize,
pub stale_files: Vec<String>,
pub deleted_file_count: usize,
pub deleted_files: Vec<String>,
}
pub fn count_nodes(graph: &LineageGraph) -> NodeCounts {
let mut model = 0;
let mut source = 0;
let mut seed = 0;
let mut snapshot = 0;
let mut test = 0;
let mut exposure = 0;
let mut phantom = 0;
for idx in graph.node_indices() {
match graph[idx].node_type {
NodeType::Model => model += 1,
NodeType::Source => source += 1,
NodeType::Seed => seed += 1,
NodeType::Snapshot => snapshot += 1,
NodeType::Test => test += 1,
NodeType::Exposure => exposure += 1,
NodeType::Phantom => phantom += 1,
}
}
let total = model + source + seed + snapshot + test + exposure + phantom;
NodeCounts {
model,
source,
seed,
snapshot,
test,
exposure,
phantom,
total,
}
}
pub fn render_summary_text_stdout(report: &SummaryReport) {
let mut stdout = io::stdout().lock();
super::handle_stdout_result(render_summary_text(report, &mut stdout));
}
pub fn render_summary_json_stdout(report: &SummaryReport) {
let mut stdout = io::stdout().lock();
let pretty = stdout.is_terminal();
super::handle_stdout_result(render_summary_json(report, &mut stdout, pretty));
}
fn render_file_list<W: Write>(
w: &mut W,
label: &str,
files: &[String],
max: usize,
) -> io::Result<()> {
if files.is_empty() {
return Ok(());
}
let show = files.len().min(max);
writeln!(w, " {}:", label)?;
for f in &files[..show] {
writeln!(w, " - {}", f)?;
}
let remaining = files.len() - show;
if remaining > 0 {
writeln!(w, " ... and {} more", remaining)?;
}
Ok(())
}
pub fn render_summary_text<W: Write>(report: &SummaryReport, w: &mut W) -> io::Result<()> {
writeln!(w, "Project: {}", report.project_name)?;
writeln!(w, "Source: {}", report.source_mode)?;
writeln!(w)?;
writeln!(w, "Nodes:")?;
for &type_label in KNOWN_NODE_TYPE_LABELS {
let count = match type_label {
"model" => report.node_counts.model,
"source" => report.node_counts.source,
"seed" => report.node_counts.seed,
"snapshot" => report.node_counts.snapshot,
"test" => report.node_counts.test,
"exposure" => report.node_counts.exposure,
_ => 0,
};
if count > 0 {
writeln!(w, " {:<12} {}", type_label, count)?;
}
}
if report.node_counts.phantom > 0 {
writeln!(w, " {:<12} {}", "phantom", report.node_counts.phantom)?;
}
writeln!(w, " {:<12} {}", "total", report.node_counts.total)?;
writeln!(w, "Edges: {}", report.edge_count)?;
if report.vars_count > 0 {
writeln!(w, "Vars: {}", report.vars_count)?;
}
if let Some(ref ms) = report.manifest_status {
writeln!(w)?;
if !ms.found {
writeln!(w, "Manifest: not found")?;
} else if ms.is_stale {
let mut parts = Vec::new();
if ms.stale_file_count > 0 {
parts.push(format!(
"{} file{} newer",
ms.stale_file_count,
if ms.stale_file_count == 1 { "" } else { "s" }
));
}
if ms.deleted_file_count > 0 {
parts.push(format!("{} deleted", ms.deleted_file_count,));
}
writeln!(w, "Manifest: stale ({})", parts.join(", "))?;
render_file_list(w, "newer", &ms.stale_files, MAX_FILES_TEXT)?;
render_file_list(w, "deleted", &ms.deleted_files, MAX_FILES_TEXT)?;
} else {
writeln!(w, "Manifest: up-to-date")?;
}
}
Ok(())
}
pub fn render_summary_json<W: Write>(
report: &SummaryReport,
w: &mut W,
pretty: bool,
) -> io::Result<()> {
if pretty {
serde_json::to_writer_pretty(&mut *w, report).map_err(super::serde_io_error)?;
} else {
serde_json::to_writer(&mut *w, report).map_err(super::serde_io_error)?;
}
writeln!(w)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_report() -> SummaryReport {
SummaryReport {
project_name: "my_project".to_string(),
source_mode: "sql".to_string(),
node_counts: NodeCounts {
model: 5,
source: 2,
seed: 1,
snapshot: 0,
test: 3,
exposure: 1,
phantom: 0,
total: 12,
},
edge_count: 10,
vars_count: 2,
manifest_status: None,
}
}
#[test]
fn test_count_nodes() {
let graph = crate::render::test_helpers::make_sample_lineage_graph();
let counts = count_nodes(&graph);
assert_eq!(counts.model, 2);
assert_eq!(counts.source, 1);
assert_eq!(counts.test, 1);
assert_eq!(counts.exposure, 1);
assert_eq!(counts.seed, 0);
assert_eq!(counts.snapshot, 0);
assert_eq!(counts.phantom, 0);
assert_eq!(counts.total, 5);
}
#[test]
fn test_text_output() {
let report = make_report();
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Project: my_project"));
assert!(output.contains("Source: sql"));
assert!(output.contains("model"));
assert!(output.contains("5"));
assert!(output.contains("total"));
assert!(output.contains("12"));
assert!(output.contains("Edges:"));
assert!(output.contains("Vars:"));
}
#[test]
fn test_text_hides_zero_counts() {
let report = make_report();
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(!output.contains("snapshot"));
}
#[test]
fn test_text_with_manifest_stale() {
let mut report = make_report();
report.manifest_status = Some(ManifestStatus {
found: true,
is_stale: true,
stale_file_count: 3,
stale_files: vec![
"models/marts/orders.sql".to_string(),
"models/staging/stg_orders.sql".to_string(),
"models/staging/stg_payments.sql".to_string(),
],
deleted_file_count: 0,
deleted_files: vec![],
});
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Manifest: stale (3 files newer)"));
assert!(output.contains("models/marts/orders.sql"));
}
#[test]
fn test_text_with_manifest_up_to_date() {
let mut report = make_report();
report.manifest_status = Some(ManifestStatus {
found: true,
is_stale: false,
stale_file_count: 0,
stale_files: vec![],
deleted_file_count: 0,
deleted_files: vec![],
});
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Manifest: up-to-date"));
}
#[test]
fn test_text_with_manifest_not_found() {
let mut report = make_report();
report.manifest_status = Some(ManifestStatus {
found: false,
is_stale: false,
stale_file_count: 0,
stale_files: vec![],
deleted_file_count: 0,
deleted_files: vec![],
});
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Manifest: not found"));
}
#[test]
fn test_json_output() {
let report = make_report();
let mut buf = Vec::new();
render_summary_json(&report, &mut buf, false).unwrap();
let output = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["project_name"], "my_project");
assert_eq!(parsed["source_mode"], "sql");
assert_eq!(parsed["node_counts"]["model"], 5);
assert_eq!(parsed["node_counts"]["total"], 12);
assert_eq!(parsed["edge_count"], 10);
assert_eq!(parsed["vars_count"], 2);
assert!(parsed["manifest_status"].is_null());
}
#[test]
fn test_json_with_manifest() {
let mut report = make_report();
report.manifest_status = Some(ManifestStatus {
found: true,
is_stale: true,
stale_file_count: 5,
stale_files: vec![
"a.sql".to_string(),
"b.sql".to_string(),
"c.sql".to_string(),
"d.sql".to_string(),
"e.sql".to_string(),
],
deleted_file_count: 0,
deleted_files: vec![],
});
let mut buf = Vec::new();
render_summary_json(&report, &mut buf, false).unwrap();
let output = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["manifest_status"]["found"], true);
assert_eq!(parsed["manifest_status"]["is_stale"], true);
assert_eq!(parsed["manifest_status"]["stale_file_count"], 5);
}
#[test]
fn test_json_compact_single_line() {
let report = make_report();
let mut buf = Vec::new();
render_summary_json(&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");
}
#[test]
fn test_json_pretty_multi_line() {
let report = make_report();
let mut buf = Vec::new();
render_summary_json(&report, &mut buf, true).unwrap();
let output = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = output.trim_end().split('\n').collect();
assert!(lines.len() > 1, "pretty JSON should be multi-line");
}
#[test]
fn test_text_no_vars_when_zero() {
let mut report = make_report();
report.vars_count = 0;
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(!output.contains("Vars:"));
}
#[test]
fn test_snapshot_summary_text() {
let report = make_report();
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
insta::assert_snapshot!(output);
}
#[test]
fn test_snapshot_summary_json() {
let report = make_report();
let mut buf = Vec::new();
render_summary_json(&report, &mut buf, true).unwrap();
let output = String::from_utf8(buf).unwrap();
insta::assert_snapshot!(output);
}
#[test]
fn test_text_phantom_shown_when_nonzero() {
let mut report = make_report();
report.node_counts.phantom = 2;
report.node_counts.total = 14;
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("phantom"));
assert!(output.contains("2"));
}
#[test]
fn test_manifest_stale_singular() {
let mut report = make_report();
report.manifest_status = Some(ManifestStatus {
found: true,
is_stale: true,
stale_file_count: 1,
stale_files: vec!["models/staging/stg_orders.sql".to_string()],
deleted_file_count: 0,
deleted_files: vec![],
});
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Manifest: stale (1 file newer)"));
}
#[test]
fn test_manifest_deleted_only() {
let mut report = make_report();
report.manifest_status = Some(ManifestStatus {
found: true,
is_stale: true,
stale_file_count: 0,
stale_files: vec![],
deleted_file_count: 2,
deleted_files: vec![
"models/old_model.sql".to_string(),
"models/removed.sql".to_string(),
],
});
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Manifest: stale (2 deleted)"));
assert!(output.contains("models/old_model.sql"));
assert!(!output.contains("newer"));
}
#[test]
fn test_manifest_stale_and_deleted() {
let mut report = make_report();
report.manifest_status = Some(ManifestStatus {
found: true,
is_stale: true,
stale_file_count: 1,
stale_files: vec!["models/updated.sql".to_string()],
deleted_file_count: 1,
deleted_files: vec!["models/removed.sql".to_string()],
});
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("stale (1 file newer, 1 deleted)"));
assert!(output.contains("models/updated.sql"));
assert!(output.contains("models/removed.sql"));
}
#[test]
fn test_manifest_file_list_truncation() {
let mut report = make_report();
let files: Vec<String> = (0..8).map(|i| format!("models/model_{}.sql", i)).collect();
report.manifest_status = Some(ManifestStatus {
found: true,
is_stale: true,
stale_file_count: 8,
stale_files: files,
deleted_file_count: 0,
deleted_files: vec![],
});
let mut buf = Vec::new();
render_summary_text(&report, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("model_0.sql"));
assert!(output.contains("model_4.sql"));
assert!(!output.contains("model_5.sql"));
assert!(output.contains("... and 3 more"));
}
#[test]
fn test_json_includes_file_lists() {
let mut report = make_report();
report.manifest_status = Some(ManifestStatus {
found: true,
is_stale: true,
stale_file_count: 1,
stale_files: vec!["models/a.sql".to_string()],
deleted_file_count: 1,
deleted_files: vec!["models/b.sql".to_string()],
});
let mut buf = Vec::new();
render_summary_json(&report, &mut buf, false).unwrap();
let output = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let ms = &parsed["manifest_status"];
assert_eq!(ms["stale_file_count"], 1);
assert_eq!(ms["stale_files"][0], "models/a.sql");
assert_eq!(ms["deleted_file_count"], 1);
assert_eq!(ms["deleted_files"][0], "models/b.sql");
}
}