sql-cli 1.69.0

SQL query tool for CSV/JSON with both interactive TUI and non-interactive CLI modes - perfect for exploration and automation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
use crate::api_client::QueryResponse;
use crate::buffer::{Buffer, BufferAPI};
use std::collections::HashMap;

/// Manages all data operations, separated from UI concerns
/// This is the data layer that the enhanced_tui should delegate to
pub struct DataManager {
    /// Cache of column widths for each buffer
    column_widths_cache: HashMap<String, Vec<u16>>,
    
    /// Filter state for each buffer
    filter_cache: HashMap<String, FilterState>,
    
    /// Search state for each buffer
    search_cache: HashMap<String, SearchState>,
}

#[derive(Clone, Debug)]
pub struct FilterState {
    pub active: bool,
    pub filter_text: String,
    pub filtered_indices: Vec<usize>,
    pub case_insensitive: bool,
}

#[derive(Clone, Debug)]
pub struct SearchState {
    pub active: bool,
    pub search_text: String,
    pub matches: Vec<(usize, usize)>, // (row, col) positions
    pub current_match: usize,
}

impl DataManager {
    pub fn new() -> Self {
        Self {
            column_widths_cache: HashMap::new(),
            filter_cache: HashMap::new(),
            search_cache: HashMap::new(),
        }
    }
    
    // ========== Column Width Calculations ==========
    
    /// Calculate optimal column widths for display
    pub fn calculate_column_widths(
        &mut self,
        buffer_id: &str,
        results: &QueryResponse,
        max_width: u16,
    ) -> Vec<u16> {
        // Check cache first
        if let Some(cached) = self.column_widths_cache.get(buffer_id) {
            return cached.clone();
        }
        
        if results.columns.is_empty() {
            return vec![];
        }
        
        let mut widths = vec![0u16; results.columns.len()];
        
        // Start with column header widths
        for (i, col) in results.columns.iter().enumerate() {
            widths[i] = col.len() as u16;
        }
        
        // Sample first 100 rows for width calculation
        let sample_size = results.rows.len().min(100);
        for row in results.rows.iter().take(sample_size) {
            for (i, cell) in row.iter().enumerate() {
                if i < widths.len() {
                    let cell_width = self.measure_cell_width(cell);
                    widths[i] = widths[i].max(cell_width);
                }
            }
        }
        
        // Apply constraints
        let total_width: u16 = widths.iter().sum();
        if total_width > max_width {
            self.distribute_width_proportionally(&mut widths, max_width);
        }
        
        // Enforce minimum and maximum widths
        for width in &mut widths {
            *width = (*width).max(4).min(50);
        }
        
        // Cache the result
        self.column_widths_cache.insert(buffer_id.to_string(), widths.clone());
        
        widths
    }
    
    fn measure_cell_width(&self, cell: &str) -> u16 {
        // Handle multi-line cells
        cell.lines()
            .map(|line| line.chars().count())
            .max()
            .unwrap_or(0) as u16
    }
    
    fn distribute_width_proportionally(&self, widths: &mut [u16], max_total: u16) {
        let total: u16 = widths.iter().sum();
        if total == 0 {
            return;
        }
        
        let scale = max_total as f32 / total as f32;
        for width in widths.iter_mut() {
            *width = ((*width as f32 * scale).floor() as u16).max(4);
        }
    }
    
    // ========== Filtering Operations ==========
    
    /// Apply filter to results
    pub fn apply_filter(
        &mut self,
        buffer_id: &str,
        results: &QueryResponse,
        filter_text: &str,
        case_insensitive: bool,
    ) -> Vec<usize> {
        if filter_text.is_empty() {
            self.filter_cache.remove(buffer_id);
            return (0..results.rows.len()).collect();
        }
        
        let filter = if case_insensitive {
            filter_text.to_lowercase()
        } else {
            filter_text.to_string()
        };
        
        let mut filtered_indices = Vec::new();
        
        for (idx, row) in results.rows.iter().enumerate() {
            let row_text = row.join(" ");
            let compare_text = if case_insensitive {
                row_text.to_lowercase()
            } else {
                row_text
            };
            
            if compare_text.contains(&filter) {
                filtered_indices.push(idx);
            }
        }
        
        // Cache the filter state
        self.filter_cache.insert(
            buffer_id.to_string(),
            FilterState {
                active: true,
                filter_text: filter_text.to_string(),
                filtered_indices: filtered_indices.clone(),
                case_insensitive,
            },
        );
        
        filtered_indices
    }
    
    /// Clear filter for a buffer
    pub fn clear_filter(&mut self, buffer_id: &str) {
        self.filter_cache.remove(buffer_id);
    }
    
    /// Get current filter state
    pub fn get_filter_state(&self, buffer_id: &str) -> Option<&FilterState> {
        self.filter_cache.get(buffer_id)
    }
    
    // ========== Search Operations ==========
    
    /// Search for text in results
    pub fn search_in_results(
        &mut self,
        buffer_id: &str,
        results: &QueryResponse,
        search_text: &str,
        case_insensitive: bool,
    ) -> Vec<(usize, usize)> {
        if search_text.is_empty() {
            self.search_cache.remove(buffer_id);
            return vec![];
        }
        
        let search = if case_insensitive {
            search_text.to_lowercase()
        } else {
            search_text.to_string()
        };
        
        let mut matches = Vec::new();
        
        for (row_idx, row) in results.rows.iter().enumerate() {
            for (col_idx, cell) in row.iter().enumerate() {
                let compare_text = if case_insensitive {
                    cell.to_lowercase()
                } else {
                    cell.to_string()
                };
                
                if compare_text.contains(&search) {
                    matches.push((row_idx, col_idx));
                }
            }
        }
        
        // Cache the search state
        self.search_cache.insert(
            buffer_id.to_string(),
            SearchState {
                active: true,
                search_text: search_text.to_string(),
                matches: matches.clone(),
                current_match: 0,
            },
        );
        
        matches
    }
    
    /// Navigate to next search match
    pub fn next_search_match(&mut self, buffer_id: &str) -> Option<(usize, usize)> {
        if let Some(state) = self.search_cache.get_mut(buffer_id) {
            if !state.matches.is_empty() {
                state.current_match = (state.current_match + 1) % state.matches.len();
                return Some(state.matches[state.current_match]);
            }
        }
        None
    }
    
    /// Navigate to previous search match
    pub fn prev_search_match(&mut self, buffer_id: &str) -> Option<(usize, usize)> {
        if let Some(state) = self.search_cache.get_mut(buffer_id) {
            if !state.matches.is_empty() {
                state.current_match = if state.current_match == 0 {
                    state.matches.len() - 1
                } else {
                    state.current_match - 1
                };
                return Some(state.matches[state.current_match]);
            }
        }
        None
    }
    
    /// Clear search for a buffer
    pub fn clear_search(&mut self, buffer_id: &str) {
        self.search_cache.remove(buffer_id);
    }
    
    /// Get current search state
    pub fn get_search_state(&self, buffer_id: &str) -> Option<&SearchState> {
        self.search_cache.get(buffer_id)
    }
    
    // ========== Data Transformation ==========
    
    /// Truncate string for display
    pub fn truncate_for_display(text: &str, max_width: usize) -> String {
        if text.len() <= max_width {
            return text.to_string();
        }
        
        if max_width <= 3 {
            return ".".repeat(max_width.min(text.len()));
        }
        
        let truncated = &text[..max_width - 3];
        format!("{}...", truncated)
    }
    
    /// Format cell value for display
    pub fn format_cell_value(value: &str, width: usize, align_right: bool) -> String {
        let truncated = Self::truncate_for_display(value, width);
        
        if align_right {
            format!("{:>width$}", truncated, width = width)
        } else {
            format!("{:<width$}", truncated, width = width)
        }
    }
    
    /// Check if column contains numeric data
    pub fn is_numeric_column(results: &QueryResponse, col_idx: usize) -> bool {
        if col_idx >= results.columns.len() || results.rows.is_empty() {
            return false;
        }
        
        // Sample first 10 non-empty values
        let mut numeric_count = 0;
        let mut sample_count = 0;
        
        for row in results.rows.iter().take(20) {
            if col_idx < row.len() && !row[col_idx].trim().is_empty() {
                if row[col_idx].parse::<f64>().is_ok() {
                    numeric_count += 1;
                }
                sample_count += 1;
                if sample_count >= 10 {
                    break;
                }
            }
        }
        
        // Consider numeric if >70% of samples are numbers
        sample_count > 0 && (numeric_count as f32 / sample_count as f32) > 0.7
    }
    
    // ========== Statistics ==========
    
    /// Calculate basic statistics for results
    pub fn calculate_stats(results: &QueryResponse) -> DataStats {
        DataStats {
            total_rows: results.rows.len(),
            total_columns: results.columns.len(),
            memory_size: Self::estimate_memory_size(results),
        }
    }
    
    fn estimate_memory_size(results: &QueryResponse) -> usize {
        let mut size = 0;
        
        // Column headers
        for col in &results.columns {
            size += col.len();
        }
        
        // Row data
        for row in &results.rows {
            for cell in row {
                size += cell.len();
            }
        }
        
        size
    }
}

#[derive(Debug, Clone)]
pub struct DataStats {
    pub total_rows: usize,
    pub total_columns: usize,
    pub memory_size: usize,
}

#[cfg(test)]
mod tests {
    use super::*;
    
    fn create_test_results() -> QueryResponse {
        QueryResponse {
            columns: vec!["id".to_string(), "name".to_string(), "value".to_string()],
            rows: vec![
                vec!["1".to_string(), "Alice".to_string(), "100".to_string()],
                vec!["2".to_string(), "Bob".to_string(), "200".to_string()],
                vec!["3".to_string(), "Charlie".to_string(), "300".to_string()],
            ],
        }
    }
    
    #[test]
    fn test_column_width_calculation() {
        let mut dm = DataManager::new();
        let results = create_test_results();
        
        let widths = dm.calculate_column_widths("test", &results, 100);
        
        assert_eq!(widths.len(), 3);
        assert!(widths[0] >= 2); // "id"
        assert!(widths[1] >= 7); // "Charlie"
        assert!(widths[2] >= 5); // "value"
    }
    
    #[test]
    fn test_filtering() {
        let mut dm = DataManager::new();
        let results = create_test_results();
        
        // Filter for "Alice"
        let indices = dm.apply_filter("test", &results, "Alice", false);
        assert_eq!(indices, vec![0]);
        
        // Case insensitive filter
        let indices = dm.apply_filter("test", &results, "alice", true);
        assert_eq!(indices, vec![0]);
        
        // Filter for "0" (appears in multiple rows)
        let indices = dm.apply_filter("test", &results, "0", false);
        assert_eq!(indices, vec![0, 1, 2]);
    }
    
    #[test]
    fn test_search() {
        let mut dm = DataManager::new();
        let results = create_test_results();
        
        // Search for "Bob"
        let matches = dm.search_in_results("test", &results, "Bob", false);
        assert_eq!(matches, vec![(1, 1)]);
        
        // Search for "00" (appears in values)
        let matches = dm.search_in_results("test", &results, "00", false);
        assert_eq!(matches, vec![(0, 2), (1, 2), (2, 2)]);
        
        // Test navigation
        dm.next_search_match("test");
        let state = dm.get_search_state("test").unwrap();
        assert_eq!(state.current_match, 1);
    }
    
    #[test]
    fn test_numeric_detection() {
        let results = create_test_results();
        
        assert!(!DataManager::is_numeric_column(&results, 1)); // "name" column
        assert!(DataManager::is_numeric_column(&results, 2)); // "value" column
    }
}