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 semantic_model: usize,
28    pub metric: usize,
29    pub saved_query: usize,
30    pub phantom: usize,
31    pub total: usize,
32}
33
34/// Maximum number of file names to show in text output.
35const 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
47/// Count nodes by type from a lineage graph.
48pub 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
100/// Render summary as human-readable text to stdout.
101pub 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
106/// Render summary as JSON to stdout.
107pub 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        // snapshot count is 0, should not appear
273        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        // Should show first 5 and "... and 3 more"
508        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}