Skip to main content

dlin_core/render/
summary.rs

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/// Summary report data, suitable for both text and JSON rendering.
9#[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
31/// Maximum number of file names to show in text output.
32const 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
44/// Count nodes by type from a lineage graph.
45pub 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
79/// Render summary as human-readable text to stdout.
80pub 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
85/// Render summary as JSON to stdout.
86pub 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        // snapshot count is 0, should not appear
246        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        // Should show first 5 and "... and 3 more"
481        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}