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.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 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 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 assert!(output_quiet.len() <= output_verbose.len() || output_quiet == output_verbose);
245 }
246}