Skip to main content

howmany/ui/
filters.rs

1use crate::core::stats::aggregation::AggregatedStats;
2use crate::core::stats::basic::ExtensionStats;
3use crate::core::types::FileStats;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7/// Filter options for CLI output
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct FilterOptions {
10    // Size filters
11    pub min_lines: Option<usize>,
12    pub max_lines: Option<usize>,
13    pub min_size_bytes: Option<u64>,
14    pub max_size_bytes: Option<u64>,
15
16    // Complexity filters
17    pub min_complexity: Option<f64>,
18    pub max_complexity: Option<f64>,
19    pub min_functions: Option<usize>,
20    pub max_functions: Option<usize>,
21
22    // Quality filters
23    pub min_quality_score: Option<f64>,
24    pub max_quality_score: Option<f64>,
25    pub min_doc_ratio: Option<f64>,
26    pub max_doc_ratio: Option<f64>,
27
28    // Language/extension filters
29    pub include_languages: Vec<String>,
30    pub exclude_languages: Vec<String>,
31
32    // Output customization
33    pub show_complexity: bool,
34    pub show_quality: bool,
35    pub show_ratios: bool,
36    pub show_size_info: bool,
37    pub compact_output: bool,
38}
39
40/// One file's complexity, taken from the analysis the run already performed.
41///
42/// `--min-complexity` and `--min-functions` need this; nothing else does, so it
43/// is derived from the function details rather than measured a second time.
44#[derive(Debug, Clone, Copy, Default, PartialEq)]
45pub struct FileComplexity {
46    pub function_count: usize,
47    pub max_cyclomatic: f64,
48    pub average_cyclomatic: f64,
49}
50
51impl FileComplexity {
52    /// Group per-function details by the file they were found in.
53    pub fn index(
54        details: &[crate::core::stats::complexity::FunctionComplexityDetail],
55    ) -> BTreeMap<String, FileComplexity> {
56        let mut totals: BTreeMap<String, (usize, f64, f64)> = BTreeMap::new();
57        for detail in details {
58            let entry = totals
59                .entry(detail.file_path.clone())
60                .or_insert((0, 0.0, 0.0));
61            let complexity = detail.cyclomatic_complexity as f64;
62            entry.0 += 1;
63            entry.1 += complexity;
64            entry.2 = entry.2.max(complexity);
65        }
66
67        totals
68            .into_iter()
69            .map(|(path, (count, sum, max))| {
70                (
71                    path,
72                    FileComplexity {
73                        function_count: count,
74                        max_cyclomatic: max,
75                        average_cyclomatic: if count == 0 { 0.0 } else { sum / count as f64 },
76                    },
77                )
78            })
79            .collect()
80    }
81}
82
83/// Filter for individual files
84pub struct FileFilter {
85    options: FilterOptions,
86}
87
88impl FileFilter {
89    pub fn new(options: FilterOptions) -> Self {
90        Self { options }
91    }
92
93    /// True when some option can only be decided per file.
94    ///
95    /// When nothing here is set, every file passes, and the aggregate totals the
96    /// engine already computed are the answer -- retaining and re-summing one
97    /// record per file would be pure overhead. On a 60k-file tree that is the
98    /// difference between allocating 60k records and allocating none.
99    pub fn needs_per_file_stats(options: &FilterOptions) -> bool {
100        options.min_lines.is_some()
101            || options.max_lines.is_some()
102            || options.min_size_bytes.is_some()
103            || options.max_size_bytes.is_some()
104            || options.min_doc_ratio.is_some()
105            || options.max_doc_ratio.is_some()
106            || options.min_quality_score.is_some()
107            || options.max_quality_score.is_some()
108            || !options.include_languages.is_empty()
109            || !options.exclude_languages.is_empty()
110            || Self::needs_complexity(options)
111    }
112
113    /// True when a filter can only be decided from complexity analysis.
114    ///
115    /// Complexity costs a parse per file, so it is computed only when one of
116    /// these options is actually set.
117    pub fn needs_complexity(options: &FilterOptions) -> bool {
118        options.min_complexity.is_some()
119            || options.max_complexity.is_some()
120            || options.min_functions.is_some()
121            || options.max_functions.is_some()
122    }
123
124    /// True when `complexity` satisfies the complexity and function filters.
125    ///
126    /// A file with no analysable functions has no complexity to compare, so it
127    /// fails any lower bound and passes any upper one.
128    pub fn passes_complexity_filter(&self, complexity: Option<&FileComplexity>) -> bool {
129        let measured = complexity.copied().unwrap_or_default();
130
131        let within = |value: f64, min: Option<f64>, max: Option<f64>| {
132            min.is_none_or(|m| value >= m) && max.is_none_or(|m| value <= m)
133        };
134
135        within(
136            measured.max_cyclomatic,
137            self.options.min_complexity,
138            self.options.max_complexity,
139        ) && self
140            .options
141            .min_functions
142            .is_none_or(|m| measured.function_count >= m)
143            && self
144                .options
145                .max_functions
146                .is_none_or(|m| measured.function_count <= m)
147    }
148
149    pub fn passes_filter(&self, file_path: &str, file_stats: &FileStats) -> bool {
150        // Size filters
151        if let Some(min_lines) = self.options.min_lines {
152            if file_stats.total_lines < min_lines {
153                return false;
154            }
155        }
156
157        if let Some(max_lines) = self.options.max_lines {
158            if file_stats.total_lines > max_lines {
159                return false;
160            }
161        }
162
163        if let Some(min_size) = self.options.min_size_bytes {
164            if file_stats.file_size < min_size {
165                return false;
166            }
167        }
168
169        if let Some(max_size) = self.options.max_size_bytes {
170            if file_stats.file_size > max_size {
171                return false;
172            }
173        }
174
175        // Language/extension filters
176        let extension = std::path::Path::new(file_path)
177            .extension()
178            .and_then(|ext| ext.to_str())
179            .unwrap_or("no_ext")
180            .to_lowercase();
181
182        if !self.options.include_languages.is_empty()
183            && !self
184                .options
185                .include_languages
186                .iter()
187                .any(|lang| lang.to_lowercase() == extension)
188        {
189            return false;
190        }
191
192        if !self.options.exclude_languages.is_empty()
193            && self
194                .options
195                .exclude_languages
196                .iter()
197                .any(|lang| lang.to_lowercase() == extension)
198        {
199            return false;
200        }
201
202        // Documentation ratio filter
203        if let Some(min_doc_ratio) = self.options.min_doc_ratio {
204            let doc_ratio = if file_stats.total_lines > 0 {
205                file_stats.doc_lines as f64 / file_stats.total_lines as f64
206            } else {
207                0.0
208            };
209            if doc_ratio < min_doc_ratio {
210                return false;
211            }
212        }
213
214        if let Some(max_doc_ratio) = self.options.max_doc_ratio {
215            let doc_ratio = if file_stats.total_lines > 0 {
216                file_stats.doc_lines as f64 / file_stats.total_lines as f64
217            } else {
218                0.0
219            };
220            if doc_ratio > max_doc_ratio {
221                return false;
222            }
223        }
224
225        // `--min-quality` / `--max-quality` used to be accepted and then
226        // ignored. The score is the same one the project report shows, computed
227        // for this file alone: pure arithmetic over line counts, no extra IO.
228        if self.options.min_quality_score.is_some() || self.options.max_quality_score.is_some() {
229            let score = Self::quality_score(file_stats);
230            if self
231                .options
232                .min_quality_score
233                .is_some_and(|min| score < min)
234                || self
235                    .options
236                    .max_quality_score
237                    .is_some_and(|max| score > max)
238            {
239                return false;
240            }
241        }
242
243        true
244    }
245
246    /// The overall quality score for a single file, on the report's 0-100 scale.
247    fn quality_score(file_stats: &FileStats) -> f64 {
248        crate::core::stats::ratios::RatioStatsCalculator::new()
249            .calculate_ratio_stats(file_stats)
250            .map(|ratios| ratios.quality_metrics.overall_quality_score)
251            .unwrap_or(0.0)
252    }
253}
254
255/// Project-level filter for aggregated stats
256pub struct ProjectFilter {
257    options: FilterOptions,
258}
259
260impl ProjectFilter {
261    pub fn new(options: FilterOptions) -> Self {
262        Self { options }
263    }
264
265    /// Filter extensions based on criteria
266    pub fn filter_extensions(
267        &self,
268        stats_by_extension: &BTreeMap<String, ExtensionStats>,
269    ) -> BTreeMap<String, ExtensionStats> {
270        let mut filtered = BTreeMap::new();
271
272        for (ext, stats) in stats_by_extension {
273            // Language filters
274            if !self.options.include_languages.is_empty()
275                && !self
276                    .options
277                    .include_languages
278                    .iter()
279                    .any(|lang| lang.to_lowercase() == ext.to_lowercase())
280            {
281                continue;
282            }
283
284            if !self.options.exclude_languages.is_empty()
285                && self
286                    .options
287                    .exclude_languages
288                    .iter()
289                    .any(|lang| lang.to_lowercase() == ext.to_lowercase())
290            {
291                continue;
292            }
293
294            // Size filters
295            if let Some(min_lines) = self.options.min_lines {
296                if stats.total_lines < min_lines {
297                    continue;
298                }
299            }
300
301            if let Some(max_lines) = self.options.max_lines {
302                if stats.total_lines > max_lines {
303                    continue;
304                }
305            }
306
307            if let Some(min_size) = self.options.min_size_bytes {
308                if stats.total_size < min_size {
309                    continue;
310                }
311            }
312
313            if let Some(max_size) = self.options.max_size_bytes {
314                if stats.total_size > max_size {
315                    continue;
316                }
317            }
318
319            filtered.insert(ext.clone(), stats.clone());
320        }
321
322        filtered
323    }
324}
325
326/// Utility functions for filter parsing
327pub struct FilterParser;
328
329impl FilterParser {
330    /// Parse a size such as `1024`, `1KB`, `500mb` or `2GB` into bytes.
331    ///
332    /// A value that is not a size returns `None` so the caller can report it.
333    /// Negative and non-finite inputs are rejected rather than saturating to
334    /// zero, which silently turned `--min-size -5` into "no minimum".
335    pub fn parse_size(size_str: &str) -> Option<u64> {
336        const UNITS: [(&str, u64); 4] = [
337            ("KB", 1024),
338            ("MB", 1024 * 1024),
339            ("GB", 1024 * 1024 * 1024),
340            ("B", 1),
341        ];
342
343        let upper = size_str.trim().to_uppercase();
344        let (number, multiplier) = UNITS
345            .iter()
346            .find_map(|(suffix, factor)| Some((upper.strip_suffix(suffix)?, *factor)))
347            .unwrap_or((upper.as_str(), 1));
348
349        let value: f64 = number.trim().parse().ok()?;
350        (value.is_finite() && value >= 0.0).then_some((value * multiplier as f64) as u64)
351    }
352
353    /// Parse a comma-separated list of languages into lowercase keys.
354    ///
355    /// Filtering compares against lowercase file extensions, so `--languages
356    /// Rust,Python` has to arrive here in the same case or it matches nothing.
357    pub fn parse_languages(lang_str: &str) -> Vec<String> {
358        lang_str
359            .split(',')
360            .map(|s| s.trim().to_lowercase())
361            .filter(|s| !s.is_empty())
362            .collect()
363    }
364}
365
366/// Format filtered output with additional information
367pub struct FilteredOutputFormatter;
368
369impl FilteredOutputFormatter {
370    pub fn format_enhanced_cli_output(
371        aggregated_stats: &AggregatedStats,
372        options: &FilterOptions,
373    ) -> String {
374        let mut output = String::new();
375
376        // Basic counts
377        output.push_str(&format!(
378            "{} files, {} lines",
379            aggregated_stats.basic.total_files, aggregated_stats.basic.total_lines
380        ));
381
382        if options.show_size_info {
383            let size_mb = aggregated_stats.basic.total_size as f64 / (1024.0 * 1024.0);
384            output.push_str(&format!(", {:.1} MB", size_mb));
385        }
386
387        if options.show_complexity && aggregated_stats.complexity.function_count > 0 {
388            output.push_str(&format!(
389                ", {:.1} avg complexity",
390                aggregated_stats.complexity.cyclomatic_complexity
391            ));
392        }
393
394        if options.show_quality {
395            output.push_str(&format!(
396                ", {:.1}/100 quality",
397                aggregated_stats
398                    .ratios
399                    .quality_metrics
400                    .overall_quality_score
401            ));
402        }
403
404        if options.show_ratios {
405            output.push_str(&format!(
406                ", {:.1}% code",
407                aggregated_stats.ratios.code_ratio * 100.0
408            ));
409            if aggregated_stats.ratios.comment_ratio > 0.0 {
410                output.push_str(&format!(
411                    ", {:.1}% comments",
412                    aggregated_stats.ratios.comment_ratio * 100.0
413                ));
414            }
415        }
416
417        output
418    }
419}