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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct FilterOptions {
10 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 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 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 pub include_languages: Vec<String>,
30 pub exclude_languages: Vec<String>,
31
32 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#[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 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
83pub struct FileFilter {
85 options: FilterOptions,
86}
87
88impl FileFilter {
89 pub fn new(options: FilterOptions) -> Self {
90 Self { options }
91 }
92
93 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 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 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 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 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 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 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 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
255pub struct ProjectFilter {
257 options: FilterOptions,
258}
259
260impl ProjectFilter {
261 pub fn new(options: FilterOptions) -> Self {
262 Self { options }
263 }
264
265 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 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 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
326pub struct FilterParser;
328
329impl FilterParser {
330 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 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
366pub 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 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}