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