Skip to main content

flowscope_cli/output/
table.rs

1//! Human-readable table output formatting.
2
3use flowscope_core::{AnalyzeResult, NodeType, Severity};
4use is_terminal::IsTerminal;
5use owo_colors::OwoColorize;
6use std::collections::{HashMap, HashSet};
7use std::fmt::Write;
8
9/// Format the analysis result as human-readable text with optional colors.
10pub fn format_table(result: &AnalyzeResult, quiet: bool, use_colors: bool) -> String {
11    let colored = use_colors && std::io::stdout().is_terminal();
12    let mut out = String::new();
13
14    write_header(&mut out, colored);
15    write_summary(&mut out, result, colored);
16    write_lineage(&mut out, result, colored);
17
18    if !quiet {
19        write_issues(&mut out, result, colored);
20    }
21
22    out
23}
24
25fn write_header(out: &mut String, colored: bool) {
26    let title = "FlowScope Analysis";
27    let line = "═".repeat(50);
28
29    if colored {
30        writeln!(out, "{}", title.bold()).unwrap();
31        writeln!(out, "{}", line.dimmed()).unwrap();
32    } else {
33        writeln!(out, "{title}").unwrap();
34        writeln!(out, "{line}").unwrap();
35    }
36}
37
38fn write_summary(out: &mut String, result: &AnalyzeResult, colored: bool) {
39    let summary = &result.summary;
40
41    // Collect unique source names
42    let sources: HashSet<_> = result
43        .statements
44        .iter()
45        .filter_map(|s| s.source_name.as_ref())
46        .collect();
47
48    if !sources.is_empty() {
49        let files: Vec<_> = sources.iter().map(|s| s.as_str()).collect();
50        writeln!(out, "Files: {}", files.join(", ")).unwrap();
51    }
52
53    writeln!(out).unwrap();
54
55    let stats = format!(
56        "Summary: {} statements | {} tables | {} columns",
57        summary.statement_count, summary.table_count, summary.column_count
58    );
59
60    if colored {
61        writeln!(out, "{}", stats.cyan()).unwrap();
62    } else {
63        writeln!(out, "{stats}").unwrap();
64    }
65
66    writeln!(out).unwrap();
67}
68
69fn write_lineage(out: &mut String, result: &AnalyzeResult, colored: bool) {
70    // Build table relationships from global lineage.
71    //
72    // Note: "Output" nodes are virtual sinks representing SELECT statement results.
73    // They appear in lineage to show which tables contribute to each SELECT's output,
74    // including join-only tables that don't contribute projected columns. This gives
75    // a complete picture of data dependencies for queries that don't write to tables.
76    let mut source_tables: HashMap<String, HashSet<String>> = HashMap::new();
77
78    for edge in &result.edges {
79        if !matches!(
80            edge.edge_type,
81            flowscope_core::EdgeType::DataFlow
82                | flowscope_core::EdgeType::Derivation
83                | flowscope_core::EdgeType::JoinDependency
84        ) {
85            continue;
86        }
87
88        let from_node = result.nodes.iter().find(|n| n.id == edge.from);
89        let to_node = result.nodes.iter().find(|n| n.id == edge.to);
90
91        if let (Some(from), Some(to)) = (from_node, to_node) {
92            // Include Output nodes as relation-like targets to show SELECT dependencies
93            if from.node_type.is_relation() && to.node_type.is_relation() {
94                source_tables
95                    .entry(to.label.to_string())
96                    .or_default()
97                    .insert(from.label.to_string());
98            }
99        }
100    }
101
102    if source_tables.is_empty() {
103        // Just list tables/views if no relationships found
104        let tables: Vec<_> = result
105            .nodes
106            .iter()
107            .filter(|n| matches!(n.node_type, NodeType::Table | NodeType::View))
108            .map(|n| n.label.to_string())
109            .collect();
110
111        if !tables.is_empty() {
112            if colored {
113                writeln!(out, "{}", "Tables:".bold()).unwrap();
114            } else {
115                writeln!(out, "Tables:").unwrap();
116            }
117
118            for table in tables {
119                writeln!(out, "  {table}").unwrap();
120            }
121            writeln!(out).unwrap();
122        }
123    } else {
124        if colored {
125            writeln!(out, "{}", "Table Lineage:".bold()).unwrap();
126        } else {
127            writeln!(out, "Table Lineage:").unwrap();
128        }
129
130        for (target, sources) in &source_tables {
131            let source_list: Vec<_> = sources.iter().map(|s| s.as_str()).collect();
132            let arrow = if colored {
133                "→".green().to_string()
134            } else {
135                "→".to_string()
136            };
137            writeln!(out, "  {} {} {}", source_list.join(", "), arrow, target).unwrap();
138        }
139        writeln!(out).unwrap();
140    }
141}
142
143fn write_issues(out: &mut String, result: &AnalyzeResult, colored: bool) {
144    if result.issues.is_empty() {
145        return;
146    }
147
148    let error_count = result.summary.issue_count.errors;
149    let warning_count = result.summary.issue_count.warnings;
150    let info_count = result.summary.issue_count.infos;
151
152    let mut parts = Vec::new();
153    if error_count > 0 {
154        parts.push(format!("{error_count} errors"));
155    }
156    if warning_count > 0 {
157        parts.push(format!("{warning_count} warnings"));
158    }
159    if info_count > 0 {
160        parts.push(format!("{info_count} info"));
161    }
162
163    let header = format!("Issues ({}):", parts.join(", "));
164
165    if colored {
166        writeln!(out, "{}", header.bold()).unwrap();
167    } else {
168        writeln!(out, "{header}").unwrap();
169    }
170
171    for issue in &result.issues {
172        let severity_str = match issue.severity {
173            Severity::Error => {
174                if colored {
175                    "ERROR".red().to_string()
176                } else {
177                    "ERROR".to_string()
178                }
179            }
180            Severity::Warning => {
181                if colored {
182                    "WARN".yellow().to_string()
183                } else {
184                    "WARN".to_string()
185                }
186            }
187            Severity::Info => {
188                if colored {
189                    "INFO".blue().to_string()
190                } else {
191                    "INFO".to_string()
192                }
193            }
194        };
195
196        let location = issue
197            .span
198            .as_ref()
199            .map(|s| format!(" offset {}:", s.start))
200            .unwrap_or_default();
201
202        writeln!(out, "  [{}]{} {}", severity_str, location, issue.message).unwrap();
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use flowscope_core::{analyze, AnalyzeRequest, Dialect};
210
211    #[test]
212    fn test_format_table_basic() {
213        let result = analyze(&AnalyzeRequest {
214            sql: "SELECT * FROM users".to_string(),
215            files: None,
216            dialect: Dialect::Generic,
217            source_name: None,
218            options: None,
219            schema: None,
220            template_config: None,
221        });
222
223        let output = format_table(&result, false, false);
224        assert!(output.contains("FlowScope Analysis"));
225        assert!(output.contains("Summary:"));
226    }
227
228    #[test]
229    fn test_format_table_quiet() {
230        let result = analyze(&AnalyzeRequest {
231            sql: "SELECT * FROM nonexistent_syntax_error@@@".to_string(),
232            files: None,
233            dialect: Dialect::Generic,
234            source_name: None,
235            options: None,
236            schema: None,
237            template_config: None,
238        });
239
240        let output_quiet = format_table(&result, true, false);
241        let output_verbose = format_table(&result, false, false);
242
243        // Quiet mode may have fewer issue lines (but both might have none if parsing succeeds)
244        assert!(output_quiet.len() <= output_verbose.len() || output_quiet == output_verbose);
245    }
246}