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.global_lineage.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
89            .global_lineage
90            .nodes
91            .iter()
92            .find(|n| n.id == edge.from);
93        let to_node = result.global_lineage.nodes.iter().find(|n| n.id == edge.to);
94
95        if let (Some(from), Some(to)) = (from_node, to_node) {
96            // Include Output nodes as relation-like targets to show SELECT dependencies
97            if from.node_type.is_relation() && to.node_type.is_relation() {
98                source_tables
99                    .entry(to.label.to_string())
100                    .or_default()
101                    .insert(from.label.to_string());
102            }
103        }
104    }
105
106    if source_tables.is_empty() {
107        // Just list tables/views if no relationships found
108        let tables: Vec<_> = result
109            .global_lineage
110            .nodes
111            .iter()
112            .filter(|n| matches!(n.node_type, NodeType::Table | NodeType::View))
113            .map(|n| n.label.to_string())
114            .collect();
115
116        if !tables.is_empty() {
117            if colored {
118                writeln!(out, "{}", "Tables:".bold()).unwrap();
119            } else {
120                writeln!(out, "Tables:").unwrap();
121            }
122
123            for table in tables {
124                writeln!(out, "  {table}").unwrap();
125            }
126            writeln!(out).unwrap();
127        }
128    } else {
129        if colored {
130            writeln!(out, "{}", "Table Lineage:".bold()).unwrap();
131        } else {
132            writeln!(out, "Table Lineage:").unwrap();
133        }
134
135        for (target, sources) in &source_tables {
136            let source_list: Vec<_> = sources.iter().map(|s| s.as_str()).collect();
137            let arrow = if colored {
138                "→".green().to_string()
139            } else {
140                "→".to_string()
141            };
142            writeln!(out, "  {} {} {}", source_list.join(", "), arrow, target).unwrap();
143        }
144        writeln!(out).unwrap();
145    }
146}
147
148fn write_issues(out: &mut String, result: &AnalyzeResult, colored: bool) {
149    if result.issues.is_empty() {
150        return;
151    }
152
153    let error_count = result.summary.issue_count.errors;
154    let warning_count = result.summary.issue_count.warnings;
155    let info_count = result.summary.issue_count.infos;
156
157    let mut parts = Vec::new();
158    if error_count > 0 {
159        parts.push(format!("{error_count} errors"));
160    }
161    if warning_count > 0 {
162        parts.push(format!("{warning_count} warnings"));
163    }
164    if info_count > 0 {
165        parts.push(format!("{info_count} info"));
166    }
167
168    let header = format!("Issues ({}):", parts.join(", "));
169
170    if colored {
171        writeln!(out, "{}", header.bold()).unwrap();
172    } else {
173        writeln!(out, "{header}").unwrap();
174    }
175
176    for issue in &result.issues {
177        let severity_str = match issue.severity {
178            Severity::Error => {
179                if colored {
180                    "ERROR".red().to_string()
181                } else {
182                    "ERROR".to_string()
183                }
184            }
185            Severity::Warning => {
186                if colored {
187                    "WARN".yellow().to_string()
188                } else {
189                    "WARN".to_string()
190                }
191            }
192            Severity::Info => {
193                if colored {
194                    "INFO".blue().to_string()
195                } else {
196                    "INFO".to_string()
197                }
198            }
199        };
200
201        let location = issue
202            .span
203            .as_ref()
204            .map(|s| format!(" offset {}:", s.start))
205            .unwrap_or_default();
206
207        writeln!(out, "  [{}]{} {}", severity_str, location, issue.message).unwrap();
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use flowscope_core::{analyze, AnalyzeRequest, Dialect};
215
216    #[test]
217    fn test_format_table_basic() {
218        let result = analyze(&AnalyzeRequest {
219            sql: "SELECT * FROM users".to_string(),
220            files: None,
221            dialect: Dialect::Generic,
222            source_name: None,
223            options: None,
224            schema: None,
225            template_config: None,
226        });
227
228        let output = format_table(&result, false, false);
229        assert!(output.contains("FlowScope Analysis"));
230        assert!(output.contains("Summary:"));
231    }
232
233    #[test]
234    fn test_format_table_quiet() {
235        let result = analyze(&AnalyzeRequest {
236            sql: "SELECT * FROM nonexistent_syntax_error@@@".to_string(),
237            files: None,
238            dialect: Dialect::Generic,
239            source_name: None,
240            options: None,
241            schema: None,
242            template_config: None,
243        });
244
245        let output_quiet = format_table(&result, true, false);
246        let output_verbose = format_table(&result, false, false);
247
248        // Quiet mode may have fewer issue lines (but both might have none if parsing succeeds)
249        assert!(output_quiet.len() <= output_verbose.len() || output_quiet == output_verbose);
250    }
251}