1use std::io::{self, IsTerminal, Write};
2
3use serde::Serialize;
4
5use crate::graph::filter::KNOWN_NODE_TYPE_LABELS;
6use crate::graph::types::*;
7
8#[derive(Debug, Serialize)]
10pub struct SummaryReport {
11 pub project_name: String,
12 pub source_mode: String,
13 pub node_counts: NodeCounts,
14 pub edge_count: usize,
15 pub vars_count: usize,
16 pub manifest_status: Option<ManifestStatus>,
17}
18
19#[derive(Debug, Serialize)]
20pub struct NodeCounts {
21 pub model: usize,
22 pub source: usize,
23 pub seed: usize,
24 pub snapshot: usize,
25 pub test: usize,
26 pub exposure: usize,
27 pub phantom: usize,
28 pub total: usize,
29}
30
31const MAX_FILES_TEXT: usize = 5;
33
34#[derive(Debug, Serialize)]
35pub struct ManifestStatus {
36 pub found: bool,
37 pub is_stale: bool,
38 pub stale_file_count: usize,
39 pub stale_files: Vec<String>,
40 pub deleted_file_count: usize,
41 pub deleted_files: Vec<String>,
42}
43
44pub fn count_nodes(graph: &LineageGraph) -> NodeCounts {
46 let mut model = 0;
47 let mut source = 0;
48 let mut seed = 0;
49 let mut snapshot = 0;
50 let mut test = 0;
51 let mut exposure = 0;
52 let mut phantom = 0;
53
54 for idx in graph.node_indices() {
55 match graph[idx].node_type {
56 NodeType::Model => model += 1,
57 NodeType::Source => source += 1,
58 NodeType::Seed => seed += 1,
59 NodeType::Snapshot => snapshot += 1,
60 NodeType::Test => test += 1,
61 NodeType::Exposure => exposure += 1,
62 NodeType::Phantom => phantom += 1,
63 }
64 }
65
66 let total = model + source + seed + snapshot + test + exposure + phantom;
67 NodeCounts {
68 model,
69 source,
70 seed,
71 snapshot,
72 test,
73 exposure,
74 phantom,
75 total,
76 }
77}
78
79pub fn render_summary_text_stdout(report: &SummaryReport) {
81 let mut stdout = io::stdout().lock();
82 super::handle_stdout_result(render_summary_text(report, &mut stdout));
83}
84
85pub fn render_summary_json_stdout(report: &SummaryReport) {
87 let mut stdout = io::stdout().lock();
88 let pretty = stdout.is_terminal();
89 super::handle_stdout_result(render_summary_json(report, &mut stdout, pretty));
90}
91
92fn render_file_list<W: Write>(
93 w: &mut W,
94 label: &str,
95 files: &[String],
96 max: usize,
97) -> io::Result<()> {
98 if files.is_empty() {
99 return Ok(());
100 }
101 let show = files.len().min(max);
102 writeln!(w, " {}:", label)?;
103 for f in &files[..show] {
104 writeln!(w, " - {}", f)?;
105 }
106 let remaining = files.len() - show;
107 if remaining > 0 {
108 writeln!(w, " ... and {} more", remaining)?;
109 }
110 Ok(())
111}
112
113pub fn render_summary_text<W: Write>(report: &SummaryReport, w: &mut W) -> io::Result<()> {
114 writeln!(w, "Project: {}", report.project_name)?;
115 writeln!(w, "Source: {}", report.source_mode)?;
116 writeln!(w)?;
117
118 writeln!(w, "Nodes:")?;
119 for &type_label in KNOWN_NODE_TYPE_LABELS {
120 let count = match type_label {
121 "model" => report.node_counts.model,
122 "source" => report.node_counts.source,
123 "seed" => report.node_counts.seed,
124 "snapshot" => report.node_counts.snapshot,
125 "test" => report.node_counts.test,
126 "exposure" => report.node_counts.exposure,
127 _ => 0,
128 };
129 if count > 0 {
130 writeln!(w, " {:<12} {}", type_label, count)?;
131 }
132 }
133 if report.node_counts.phantom > 0 {
134 writeln!(w, " {:<12} {}", "phantom", report.node_counts.phantom)?;
135 }
136 writeln!(w, " {:<12} {}", "total", report.node_counts.total)?;
137
138 writeln!(w, "Edges: {}", report.edge_count)?;
139
140 if report.vars_count > 0 {
141 writeln!(w, "Vars: {}", report.vars_count)?;
142 }
143
144 if let Some(ref ms) = report.manifest_status {
145 writeln!(w)?;
146 if !ms.found {
147 writeln!(w, "Manifest: not found")?;
148 } else if ms.is_stale {
149 let mut parts = Vec::new();
150 if ms.stale_file_count > 0 {
151 parts.push(format!(
152 "{} file{} newer",
153 ms.stale_file_count,
154 if ms.stale_file_count == 1 { "" } else { "s" }
155 ));
156 }
157 if ms.deleted_file_count > 0 {
158 parts.push(format!("{} deleted", ms.deleted_file_count,));
159 }
160 writeln!(w, "Manifest: stale ({})", parts.join(", "))?;
161 render_file_list(w, "newer", &ms.stale_files, MAX_FILES_TEXT)?;
162 render_file_list(w, "deleted", &ms.deleted_files, MAX_FILES_TEXT)?;
163 } else {
164 writeln!(w, "Manifest: up-to-date")?;
165 }
166 }
167
168 Ok(())
169}
170
171pub fn render_summary_json<W: Write>(
172 report: &SummaryReport,
173 w: &mut W,
174 pretty: bool,
175) -> io::Result<()> {
176 if pretty {
177 serde_json::to_writer_pretty(&mut *w, report).map_err(super::serde_io_error)?;
178 } else {
179 serde_json::to_writer(&mut *w, report).map_err(super::serde_io_error)?;
180 }
181 writeln!(w)?;
182 Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn make_report() -> SummaryReport {
190 SummaryReport {
191 project_name: "my_project".to_string(),
192 source_mode: "sql".to_string(),
193 node_counts: NodeCounts {
194 model: 5,
195 source: 2,
196 seed: 1,
197 snapshot: 0,
198 test: 3,
199 exposure: 1,
200 phantom: 0,
201 total: 12,
202 },
203 edge_count: 10,
204 vars_count: 2,
205 manifest_status: None,
206 }
207 }
208
209 #[test]
210 fn test_count_nodes() {
211 let graph = crate::render::test_helpers::make_sample_lineage_graph();
212 let counts = count_nodes(&graph);
213 assert_eq!(counts.model, 2);
214 assert_eq!(counts.source, 1);
215 assert_eq!(counts.test, 1);
216 assert_eq!(counts.exposure, 1);
217 assert_eq!(counts.seed, 0);
218 assert_eq!(counts.snapshot, 0);
219 assert_eq!(counts.phantom, 0);
220 assert_eq!(counts.total, 5);
221 }
222
223 #[test]
224 fn test_text_output() {
225 let report = make_report();
226 let mut buf = Vec::new();
227 render_summary_text(&report, &mut buf).unwrap();
228 let output = String::from_utf8(buf).unwrap();
229 assert!(output.contains("Project: my_project"));
230 assert!(output.contains("Source: sql"));
231 assert!(output.contains("model"));
232 assert!(output.contains("5"));
233 assert!(output.contains("total"));
234 assert!(output.contains("12"));
235 assert!(output.contains("Edges:"));
236 assert!(output.contains("Vars:"));
237 }
238
239 #[test]
240 fn test_text_hides_zero_counts() {
241 let report = make_report();
242 let mut buf = Vec::new();
243 render_summary_text(&report, &mut buf).unwrap();
244 let output = String::from_utf8(buf).unwrap();
245 assert!(!output.contains("snapshot"));
247 }
248
249 #[test]
250 fn test_text_with_manifest_stale() {
251 let mut report = make_report();
252 report.manifest_status = Some(ManifestStatus {
253 found: true,
254 is_stale: true,
255 stale_file_count: 3,
256 stale_files: vec![
257 "models/marts/orders.sql".to_string(),
258 "models/staging/stg_orders.sql".to_string(),
259 "models/staging/stg_payments.sql".to_string(),
260 ],
261 deleted_file_count: 0,
262 deleted_files: vec![],
263 });
264 let mut buf = Vec::new();
265 render_summary_text(&report, &mut buf).unwrap();
266 let output = String::from_utf8(buf).unwrap();
267 assert!(output.contains("Manifest: stale (3 files newer)"));
268 assert!(output.contains("models/marts/orders.sql"));
269 }
270
271 #[test]
272 fn test_text_with_manifest_up_to_date() {
273 let mut report = make_report();
274 report.manifest_status = Some(ManifestStatus {
275 found: true,
276 is_stale: false,
277 stale_file_count: 0,
278 stale_files: vec![],
279 deleted_file_count: 0,
280 deleted_files: vec![],
281 });
282 let mut buf = Vec::new();
283 render_summary_text(&report, &mut buf).unwrap();
284 let output = String::from_utf8(buf).unwrap();
285 assert!(output.contains("Manifest: up-to-date"));
286 }
287
288 #[test]
289 fn test_text_with_manifest_not_found() {
290 let mut report = make_report();
291 report.manifest_status = Some(ManifestStatus {
292 found: false,
293 is_stale: false,
294 stale_file_count: 0,
295 stale_files: vec![],
296 deleted_file_count: 0,
297 deleted_files: vec![],
298 });
299 let mut buf = Vec::new();
300 render_summary_text(&report, &mut buf).unwrap();
301 let output = String::from_utf8(buf).unwrap();
302 assert!(output.contains("Manifest: not found"));
303 }
304
305 #[test]
306 fn test_json_output() {
307 let report = make_report();
308 let mut buf = Vec::new();
309 render_summary_json(&report, &mut buf, false).unwrap();
310 let output = String::from_utf8(buf).unwrap();
311 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
312 assert_eq!(parsed["project_name"], "my_project");
313 assert_eq!(parsed["source_mode"], "sql");
314 assert_eq!(parsed["node_counts"]["model"], 5);
315 assert_eq!(parsed["node_counts"]["total"], 12);
316 assert_eq!(parsed["edge_count"], 10);
317 assert_eq!(parsed["vars_count"], 2);
318 assert!(parsed["manifest_status"].is_null());
319 }
320
321 #[test]
322 fn test_json_with_manifest() {
323 let mut report = make_report();
324 report.manifest_status = Some(ManifestStatus {
325 found: true,
326 is_stale: true,
327 stale_file_count: 5,
328 stale_files: vec![
329 "a.sql".to_string(),
330 "b.sql".to_string(),
331 "c.sql".to_string(),
332 "d.sql".to_string(),
333 "e.sql".to_string(),
334 ],
335 deleted_file_count: 0,
336 deleted_files: vec![],
337 });
338 let mut buf = Vec::new();
339 render_summary_json(&report, &mut buf, false).unwrap();
340 let output = String::from_utf8(buf).unwrap();
341 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
342 assert_eq!(parsed["manifest_status"]["found"], true);
343 assert_eq!(parsed["manifest_status"]["is_stale"], true);
344 assert_eq!(parsed["manifest_status"]["stale_file_count"], 5);
345 }
346
347 #[test]
348 fn test_json_compact_single_line() {
349 let report = make_report();
350 let mut buf = Vec::new();
351 render_summary_json(&report, &mut buf, false).unwrap();
352 let output = String::from_utf8(buf).unwrap();
353 let lines: Vec<&str> = output.trim_end().split('\n').collect();
354 assert_eq!(lines.len(), 1, "compact JSON should be a single line");
355 }
356
357 #[test]
358 fn test_json_pretty_multi_line() {
359 let report = make_report();
360 let mut buf = Vec::new();
361 render_summary_json(&report, &mut buf, true).unwrap();
362 let output = String::from_utf8(buf).unwrap();
363 let lines: Vec<&str> = output.trim_end().split('\n').collect();
364 assert!(lines.len() > 1, "pretty JSON should be multi-line");
365 }
366
367 #[test]
368 fn test_text_no_vars_when_zero() {
369 let mut report = make_report();
370 report.vars_count = 0;
371 let mut buf = Vec::new();
372 render_summary_text(&report, &mut buf).unwrap();
373 let output = String::from_utf8(buf).unwrap();
374 assert!(!output.contains("Vars:"));
375 }
376
377 #[test]
378 fn test_snapshot_summary_text() {
379 let report = make_report();
380 let mut buf = Vec::new();
381 render_summary_text(&report, &mut buf).unwrap();
382 let output = String::from_utf8(buf).unwrap();
383 insta::assert_snapshot!(output);
384 }
385
386 #[test]
387 fn test_snapshot_summary_json() {
388 let report = make_report();
389 let mut buf = Vec::new();
390 render_summary_json(&report, &mut buf, true).unwrap();
391 let output = String::from_utf8(buf).unwrap();
392 insta::assert_snapshot!(output);
393 }
394
395 #[test]
396 fn test_text_phantom_shown_when_nonzero() {
397 let mut report = make_report();
398 report.node_counts.phantom = 2;
399 report.node_counts.total = 14;
400 let mut buf = Vec::new();
401 render_summary_text(&report, &mut buf).unwrap();
402 let output = String::from_utf8(buf).unwrap();
403 assert!(output.contains("phantom"));
404 assert!(output.contains("2"));
405 }
406
407 #[test]
408 fn test_manifest_stale_singular() {
409 let mut report = make_report();
410 report.manifest_status = Some(ManifestStatus {
411 found: true,
412 is_stale: true,
413 stale_file_count: 1,
414 stale_files: vec!["models/staging/stg_orders.sql".to_string()],
415 deleted_file_count: 0,
416 deleted_files: vec![],
417 });
418 let mut buf = Vec::new();
419 render_summary_text(&report, &mut buf).unwrap();
420 let output = String::from_utf8(buf).unwrap();
421 assert!(output.contains("Manifest: stale (1 file newer)"));
422 }
423
424 #[test]
425 fn test_manifest_deleted_only() {
426 let mut report = make_report();
427 report.manifest_status = Some(ManifestStatus {
428 found: true,
429 is_stale: true,
430 stale_file_count: 0,
431 stale_files: vec![],
432 deleted_file_count: 2,
433 deleted_files: vec![
434 "models/old_model.sql".to_string(),
435 "models/removed.sql".to_string(),
436 ],
437 });
438 let mut buf = Vec::new();
439 render_summary_text(&report, &mut buf).unwrap();
440 let output = String::from_utf8(buf).unwrap();
441 assert!(output.contains("Manifest: stale (2 deleted)"));
442 assert!(output.contains("models/old_model.sql"));
443 assert!(!output.contains("newer"));
444 }
445
446 #[test]
447 fn test_manifest_stale_and_deleted() {
448 let mut report = make_report();
449 report.manifest_status = Some(ManifestStatus {
450 found: true,
451 is_stale: true,
452 stale_file_count: 1,
453 stale_files: vec!["models/updated.sql".to_string()],
454 deleted_file_count: 1,
455 deleted_files: vec!["models/removed.sql".to_string()],
456 });
457 let mut buf = Vec::new();
458 render_summary_text(&report, &mut buf).unwrap();
459 let output = String::from_utf8(buf).unwrap();
460 assert!(output.contains("stale (1 file newer, 1 deleted)"));
461 assert!(output.contains("models/updated.sql"));
462 assert!(output.contains("models/removed.sql"));
463 }
464
465 #[test]
466 fn test_manifest_file_list_truncation() {
467 let mut report = make_report();
468 let files: Vec<String> = (0..8).map(|i| format!("models/model_{}.sql", i)).collect();
469 report.manifest_status = Some(ManifestStatus {
470 found: true,
471 is_stale: true,
472 stale_file_count: 8,
473 stale_files: files,
474 deleted_file_count: 0,
475 deleted_files: vec![],
476 });
477 let mut buf = Vec::new();
478 render_summary_text(&report, &mut buf).unwrap();
479 let output = String::from_utf8(buf).unwrap();
480 assert!(output.contains("model_0.sql"));
482 assert!(output.contains("model_4.sql"));
483 assert!(!output.contains("model_5.sql"));
484 assert!(output.contains("... and 3 more"));
485 }
486
487 #[test]
488 fn test_json_includes_file_lists() {
489 let mut report = make_report();
490 report.manifest_status = Some(ManifestStatus {
491 found: true,
492 is_stale: true,
493 stale_file_count: 1,
494 stale_files: vec!["models/a.sql".to_string()],
495 deleted_file_count: 1,
496 deleted_files: vec!["models/b.sql".to_string()],
497 });
498 let mut buf = Vec::new();
499 render_summary_json(&report, &mut buf, false).unwrap();
500 let output = String::from_utf8(buf).unwrap();
501 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
502 let ms = &parsed["manifest_status"];
503 assert_eq!(ms["stale_file_count"], 1);
504 assert_eq!(ms["stale_files"][0], "models/a.sql");
505 assert_eq!(ms["deleted_file_count"], 1);
506 assert_eq!(ms["deleted_files"][0], "models/b.sql");
507 }
508}