Skip to main content

mdql_core/
projector.rs

1//! Format query/inspect results for output.
2
3use crate::model::{Row, Value};
4
5pub fn format_results(
6    rows: &[Row],
7    columns: Option<&[String]>,
8    output_format: &str,
9    truncate: usize,
10) -> String {
11    if rows.is_empty() {
12        return "No results.".to_string();
13    }
14
15    let cols: Vec<String> = match columns {
16        Some(c) => c.to_vec(),
17        None => {
18            let mut seen: Vec<String> = Vec::new();
19            let mut set = std::collections::HashSet::new();
20            for r in rows {
21                for k in r.keys() {
22                    if set.insert(k.clone()) {
23                        seen.push(k.clone());
24                    }
25                }
26            }
27            seen
28        }
29    };
30
31    match output_format {
32        "json" => format_json(rows, &cols),
33        "csv" => format_csv(rows, &cols),
34        _ => format_table(rows, &cols, truncate),
35    }
36}
37
38fn format_json(rows: &[Row], columns: &[String]) -> String {
39    let projected: Vec<serde_json::Value> = rows
40        .iter()
41        .map(|r| {
42            let mut obj = serde_json::Map::new();
43            for c in columns {
44                let val = r.get(c).unwrap_or(&Value::Null);
45                obj.insert(c.clone(), value_to_json(val));
46            }
47            serde_json::Value::Object(obj)
48        })
49        .collect();
50
51    serde_json::to_string_pretty(&projected).unwrap_or_default()
52}
53
54fn value_to_json(val: &Value) -> serde_json::Value {
55    match val {
56        Value::Null => serde_json::Value::Null,
57        Value::String(s) => serde_json::Value::String(s.clone()),
58        Value::Int(n) => serde_json::json!(*n),
59        Value::Float(f) => serde_json::json!(*f),
60        Value::Bool(b) => serde_json::Value::Bool(*b),
61        Value::Date(d) => serde_json::Value::String(d.format("%Y-%m-%d").to_string()),
62        Value::DateTime(dt) => serde_json::Value::String(dt.format("%Y-%m-%dT%H:%M:%S").to_string()),
63        Value::List(items) => {
64            serde_json::Value::Array(items.iter().map(|s| serde_json::Value::String(s.clone())).collect())
65        }
66        Value::Dict(map) => {
67            let obj: serde_json::Map<String, serde_json::Value> = map.iter()
68                .map(|(k, v)| (k.clone(), value_to_json(v)))
69                .collect();
70            serde_json::Value::Object(obj)
71        }
72    }
73}
74
75fn format_csv(rows: &[Row], columns: &[String]) -> String {
76    let mut out = String::new();
77    // Header
78    out.push_str(&columns.join(","));
79    out.push('\n');
80    // Rows
81    for r in rows {
82        let vals: Vec<String> = columns
83            .iter()
84            .map(|c| {
85                let val = r.get(c).unwrap_or(&Value::Null);
86                csv_value(val)
87            })
88            .collect();
89        out.push_str(&vals.join(","));
90        out.push('\n');
91    }
92    out
93}
94
95fn csv_value(val: &Value) -> String {
96    match val {
97        Value::Null => String::new(),
98        Value::List(items) => items.join(";"),
99        Value::Date(d) => d.format("%Y-%m-%d").to_string(),
100        Value::DateTime(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
101        Value::Dict(map) => {
102            let obj: serde_json::Map<String, serde_json::Value> = map.iter()
103                .map(|(k, v)| (k.clone(), value_to_json(v)))
104                .collect();
105            serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()
106        }
107        other => other.to_display_string(),
108    }
109}
110
111fn format_table(rows: &[Row], columns: &[String], truncate: usize) -> String {
112    let ncols = columns.len();
113    if ncols == 0 {
114        return "No results.".to_string();
115    }
116
117    // First pass: collect raw (newline-flattened, trimmed) cell strings and natural widths
118    let mut natural_widths: Vec<usize> = columns.iter().map(|c| c.chars().count()).collect();
119
120    let raw_cells: Vec<Vec<String>> = rows
121        .iter()
122        .map(|r| {
123            columns
124                .iter()
125                .enumerate()
126                .map(|(i, c)| {
127                    let val = r.get(c).unwrap_or(&Value::Null);
128                    let s = val.to_display_string().replace('\n', " ");
129                    let s = s.trim().to_string();
130                    let char_len = s.chars().count();
131                    if char_len > natural_widths[i] {
132                        natural_widths[i] = char_len;
133                    }
134                    s
135                })
136                .collect()
137        })
138        .collect();
139
140    // Determine effective max width per column
141    let gap = 2; // spaces between columns
142    let total_gap = gap * (ncols.saturating_sub(1));
143
144    let effective_widths = if truncate > 0 {
145        // Explicit truncate: cap each column at truncate
146        natural_widths.iter().map(|&w| w.min(truncate)).collect::<Vec<_>>()
147    } else {
148        // Auto-fit to terminal width
149        let term_width = terminal_width().unwrap_or(120);
150        fit_columns_to_width(&natural_widths, term_width.saturating_sub(total_gap))
151    };
152
153    // Second pass: truncate cells AND headers to effective widths
154    let cell_data: Vec<Vec<String>> = raw_cells
155        .iter()
156        .map(|row_cells| {
157            row_cells
158                .iter()
159                .enumerate()
160                .map(|(i, s)| truncate_str(s, effective_widths[i]))
161                .collect()
162        })
163        .collect();
164
165    let truncated_headers: Vec<String> = columns
166        .iter()
167        .enumerate()
168        .map(|(i, c)| truncate_str(c, effective_widths[i]))
169        .collect();
170
171    // Display widths = max of (truncated header, truncated cells) — guaranteed <= effective_widths
172    let mut display_widths: Vec<usize> = truncated_headers.iter().map(|h| h.chars().count()).collect();
173    for row_cells in &cell_data {
174        for (i, s) in row_cells.iter().enumerate() {
175            let w = s.chars().count();
176            if w > display_widths[i] {
177                display_widths[i] = w;
178            }
179        }
180    }
181
182    let mut out = String::new();
183
184    // Header
185    let header: Vec<String> = truncated_headers
186        .iter()
187        .enumerate()
188        .map(|(i, h)| format!("{:<width$}", h, width = display_widths[i]))
189        .collect();
190    out.push_str(&header.join("  "));
191    out.push('\n');
192
193    // Separator
194    let sep: Vec<String> = display_widths.iter().map(|w| "-".repeat(*w)).collect();
195    out.push_str(&sep.join("  "));
196    out.push('\n');
197
198    // Data rows
199    for cells in &cell_data {
200        let line: Vec<String> = cells
201            .iter()
202            .enumerate()
203            .map(|(i, c)| {
204                // Right-align numbers
205                if rows
206                    .first()
207                    .and_then(|r| r.get(&columns[i]))
208                    .map_or(false, |v| matches!(v, Value::Int(_) | Value::Float(_)))
209                {
210                    format!("{:>width$}", c, width = display_widths[i])
211                } else {
212                    format!("{:<width$}", c, width = display_widths[i])
213                }
214            })
215            .collect();
216        out.push_str(&line.join("  "));
217        out.push('\n');
218    }
219
220    // Remove trailing newline to match Python tabulate behavior
221    out.trim_end_matches('\n').to_string()
222}
223
224/// Get terminal width, if available.
225fn terminal_width() -> Option<usize> {
226    // Try COLUMNS env var first (set by many shells)
227    if let Ok(cols) = std::env::var("COLUMNS") {
228        if let Ok(w) = cols.parse::<usize>() {
229            if w > 0 {
230                return Some(w);
231            }
232        }
233    }
234
235    // Try ioctl on stderr (fd 2), then stdout (fd 1), then stdin (fd 0)
236    // stderr is often still connected to the terminal even when stdout is piped
237    #[cfg(unix)]
238    {
239        use std::mem::zeroed;
240        for fd in [2, 1, 0] {
241            unsafe {
242                let mut ws: libc::winsize = zeroed();
243                if libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 {
244                    return Some(ws.ws_col as usize);
245                }
246            }
247        }
248    }
249
250    None
251}
252
253/// Distribute available width across columns to fit within budget.
254fn fit_columns_to_width(natural: &[usize], available: usize) -> Vec<usize> {
255    let total_natural: usize = natural.iter().sum();
256    if total_natural <= available {
257        return natural.to_vec();
258    }
259
260    // Simple proportional: each column gets (natural / total_natural) * available
261    // Floor at 4 chars minimum
262    let min_col = 4;
263    let mut widths: Vec<usize> = natural
264        .iter()
265        .map(|&w| {
266            let share = ((w as f64 / total_natural as f64) * available as f64) as usize;
267            share.max(min_col)
268        })
269        .collect();
270
271    // If rounding pushed us over, trim from the widest
272    let mut total: usize = widths.iter().sum();
273    while total > available {
274        if let Some(i) = widths.iter().enumerate()
275            .filter(|(_, &w)| w > min_col)
276            .max_by_key(|(_, &w)| w)
277            .map(|(i, _)| i)
278        {
279            widths[i] -= 1;
280            total -= 1;
281        } else {
282            break;
283        }
284    }
285
286    widths
287}
288
289fn truncate_str(s: &str, max_len: usize) -> String {
290    let s = s.replace('\n', " ");
291    let s = s.trim();
292    if s.chars().count() > max_len {
293        let truncated: String = s.chars().take(max_len - 3).collect();
294        format!("{}...", truncated)
295    } else {
296        s.to_string()
297    }
298}