flowscope_cli/output/
table.rs1use flowscope_core::{AnalyzeResult, NodeType, Severity};
4use is_terminal::IsTerminal;
5use owo_colors::OwoColorize;
6use std::collections::{HashMap, HashSet};
7use std::fmt::Write;
8
9pub 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 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 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 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 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 assert!(output_quiet.len() <= output_verbose.len() || output_quiet == output_verbose);
250 }
251}