Skip to main content

rustquty_core/collector/
mod.rs

1//! Collector framework and trait definition.
2
3pub mod audit;
4pub mod clippy;
5pub mod complexity;
6pub mod coverage;
7pub mod deny;
8pub mod duplicates;
9pub mod fmt;
10pub mod hack;
11pub mod loc;
12pub mod mutants;
13pub mod size;
14pub mod tests;
15
16use crate::context::Context;
17use crate::schema::{
18    AuditResult, ClippyLint, ClippyResult, CollectorStatus, ComplexityResult, CoverageResult,
19    DenyResult, DuplicatesResult, FmtResult, HackResult, LocResult, MetricsSummary, MutantsResult,
20    ProjectInfo, SizeResult, TestResult,
21};
22
23pub trait Collector: Send + Sync {
24    fn name(&self) -> &'static str;
25    fn is_available(&self) -> bool;
26    fn collect(&self, ctx: &Context) -> Result<CollectorOutput, CollectorError>;
27}
28
29#[derive(Debug, Clone)]
30pub struct CollectorOutput {
31    pub status: CollectorStatus,
32    pub duration_ms: u64,
33    pub stdout: String,
34    pub stderr: String,
35}
36
37#[derive(Debug, thiserror::Error)]
38pub enum CollectorError {
39    #[error("collector not available: {0}")]
40    NotAvailable(String),
41    #[error("execution failed: {0}")]
42    ExecutionFailed(String),
43    #[error("parse error: {0}")]
44    ParseError(String),
45    #[error("I/O error: {0}")]
46    IoError(String),
47}
48
49pub struct MockCollector {
50    pub name_val: &'static str,
51    pub available: bool,
52    pub output: CollectorOutput,
53}
54
55impl Collector for MockCollector {
56    fn name(&self) -> &'static str {
57        self.name_val
58    }
59
60    fn is_available(&self) -> bool {
61        self.available
62    }
63
64    fn collect(&self, _ctx: &Context) -> Result<CollectorOutput, CollectorError> {
65        Ok(self.output.clone())
66    }
67}
68
69/// Execute collectors and return raw results.
70///
71/// Respects `ctx.disabled_collectors`. Skips unavailable collectors.
72/// Runs in parallel if `parallel` is true.
73pub fn execute_collectors<'a>(
74    collectors: &'a [Box<dyn Collector>],
75    ctx: &Context,
76    parallel: bool,
77) -> Vec<(&'a str, CollectorOutput)> {
78    if parallel {
79        use rayon::prelude::*;
80        collectors
81            .par_iter()
82            .filter(|col| {
83                let name_lower = col.name().to_lowercase();
84                !ctx.disabled_collectors
85                    .iter()
86                    .any(|c| c.to_string() == name_lower)
87            })
88            .filter(|col| col.is_available())
89            .flat_map(|col| match col.collect(ctx) {
90                Ok(o) => vec![(col.name(), o)],
91                Err(e) => {
92                    let output = CollectorOutput {
93                        status: CollectorStatus::Error,
94                        duration_ms: 0,
95                        stdout: String::new(),
96                        stderr: format!("{:?}", e),
97                    };
98                    vec![(col.name(), output)]
99                }
100            })
101            .collect()
102    } else {
103        let mut results = Vec::new();
104        for col in collectors {
105            let name_lower = col.name().to_lowercase();
106            if ctx
107                .disabled_collectors
108                .iter()
109                .any(|c| c.to_string() == name_lower)
110            {
111                continue;
112            }
113            if !col.is_available() {
114                continue;
115            }
116            match col.collect(ctx) {
117                Ok(o) => results.push((col.name(), o)),
118                Err(e) => {
119                    let output = CollectorOutput {
120                        status: CollectorStatus::Error,
121                        duration_ms: 0,
122                        stdout: String::new(),
123                        stderr: format!("{:?}", e),
124                    };
125                    results.push((col.name(), output));
126                }
127            }
128        }
129        results
130    }
131}
132
133/// Assemble collector results into a MetricsSummary.
134///
135/// Parses the JSON stdout of each collector to populate detailed metrics.
136pub fn assemble_results(
137    results: &[(&str, CollectorOutput)],
138    project_name: &str,
139    rust_edition: &str,
140    workspace_root: &str,
141) -> MetricsSummary {
142    let mut fmt_result = FmtResult {
143        status: CollectorStatus::Skipped,
144        details: Default::default(),
145    };
146    let mut clippy_result = ClippyResult {
147        status: CollectorStatus::Skipped,
148        warning_count: 0,
149        details: vec![],
150    };
151    let mut test_result = TestResult {
152        status: CollectorStatus::Skipped,
153        passed: 0,
154        failed: 0,
155        ignored: 0,
156        runner: None,
157    };
158    let mut coverage_result = CoverageResult {
159        status: CollectorStatus::Skipped,
160        line_percent: 0.0,
161    };
162    let mut deny_result = DenyResult {
163        status: CollectorStatus::Skipped,
164        banned_count: 0,
165        license_violations: 0,
166    };
167    let mut audit_result = AuditResult {
168        status: CollectorStatus::Skipped,
169        vulnerability_count: 0,
170        critical_count: 0,
171    };
172    let mut hack_result = HackResult {
173        status: CollectorStatus::Skipped,
174        feature_combinations_tested: 0,
175    };
176    let mut mutants_result = MutantsResult {
177        status: CollectorStatus::Skipped,
178        mutation_score: 0.0,
179        caught: 0,
180        missed: 0,
181    };
182    let mut duplicates_result = DuplicatesResult {
183        status: CollectorStatus::Skipped,
184        total_lines: 0,
185        duplicate_lines: 0,
186        files_with_duplicates: 0,
187        duplicate_files: vec![],
188    };
189    let mut loc_result = LocResult {
190        status: CollectorStatus::Skipped,
191        total_lines: 0,
192        code_lines: 0,
193        comment_lines: 0,
194        blank_lines: 0,
195        long_lines: 0,
196        max_line_length_found: 0,
197        max_line_length_allowed: 0,
198        files: 0,
199        files_with_long_lines: 0,
200        long_line_files: vec![],
201    };
202    let mut size_result = SizeResult {
203        status: CollectorStatus::Skipped,
204        files: 0,
205        max_lines_per_file: 0,
206        max_code_lines_per_file: 0,
207        max_lines_per_function: 0,
208        max_parameters_per_function: 0,
209        violations: vec![],
210    };
211    let mut complexity_result = ComplexityResult {
212        status: CollectorStatus::Skipped,
213        functions: 0,
214        max_cyclomatic_complexity: 0,
215        max_nesting_depth: 0,
216        complex_functions: 0,
217        violations: vec![],
218    };
219
220    for (name, output) in results {
221        let details = serde_json::from_str::<serde_json::Value>(&output.stdout).ok();
222        match *name {
223            "fmt" => fmt_result.status.clone_from(&output.status),
224            "clippy" => {
225                if let Some(ref d) = details {
226                    clippy_result.warning_count = d["warningCount"].as_u64().unwrap_or(0) as u32;
227                    if let Some(arr) = d["details"].as_array() {
228                        clippy_result.details = arr
229                            .iter()
230                            .map(|v| ClippyLint {
231                                code: v["code"].as_str().unwrap_or("").to_string(),
232                                message: v["message"].as_str().unwrap_or("").to_string(),
233                                file: v["file"].as_str().map(String::from),
234                                line: v["line"].as_u64().map(|v| v as u32),
235                            })
236                            .collect();
237                    }
238                }
239                clippy_result.status.clone_from(&output.status);
240            }
241            "tests" => {
242                if let Some(ref d) = details {
243                    test_result.passed = d["passed"].as_u64().unwrap_or(0) as u32;
244                    test_result.failed = d["failed"].as_u64().unwrap_or(0) as u32;
245                    test_result.ignored = d["ignored"].as_u64().unwrap_or(0) as u32;
246                    test_result.runner = d["runner"].as_str().map(String::from);
247                }
248                test_result.status.clone_from(&output.status);
249            }
250            "coverage" => {
251                if let Some(ref d) = details {
252                    coverage_result.line_percent = d["linePercent"].as_f64().unwrap_or(0.0);
253                }
254                coverage_result.status.clone_from(&output.status);
255            }
256            "deny" => {
257                if let Some(ref d) = details {
258                    deny_result.banned_count = d["bannedCount"].as_u64().unwrap_or(0) as u32;
259                    deny_result.license_violations =
260                        d["licenseViolations"].as_u64().unwrap_or(0) as u32;
261                }
262                deny_result.status.clone_from(&output.status);
263            }
264            "audit" => {
265                if let Some(ref d) = details {
266                    audit_result.vulnerability_count =
267                        d["vulnerabilityCount"].as_u64().unwrap_or(0) as u32;
268                    audit_result.critical_count =
269                        d["criticalCount"].as_u64().unwrap_or(0) as u32;
270                }
271                audit_result.status.clone_from(&output.status);
272            }
273            "hack" => {
274                if let Some(ref d) = details {
275                    hack_result.feature_combinations_tested =
276                        d["featureCombinationsTested"].as_u64().unwrap_or(0) as u32;
277                }
278                hack_result.status.clone_from(&output.status);
279            }
280            "mutants" => mutants_result.status.clone_from(&output.status),
281            "duplicates" => {
282                if let Some(ref d) = details {
283                    duplicates_result.total_lines = d["totalLines"].as_u64().unwrap_or(0) as u32;
284                    duplicates_result.duplicate_lines =
285                        d["duplicateLines"].as_u64().unwrap_or(0) as u32;
286                    duplicates_result.files_with_duplicates =
287                        d["filesWithDuplicates"].as_u64().unwrap_or(0) as u32;
288                }
289                duplicates_result.status.clone_from(&output.status);
290            }
291            "loc" => {
292                if let Some(ref d) = details {
293                    loc_result.total_lines = d["totalLines"].as_u64().unwrap_or(0) as u32;
294                    loc_result.code_lines = d["codeLines"].as_u64().unwrap_or(0) as u32;
295                    loc_result.comment_lines = d["commentLines"].as_u64().unwrap_or(0) as u32;
296                    loc_result.blank_lines = d["blankLines"].as_u64().unwrap_or(0) as u32;
297                    loc_result.long_lines = d["longLines"].as_u64().unwrap_or(0) as u32;
298                    loc_result.max_line_length_found =
299                        d["maxLineLengthFound"].as_u64().unwrap_or(0) as usize;
300                    loc_result.files = d["files"].as_u64().unwrap_or(0) as u32;
301                }
302                loc_result.status.clone_from(&output.status);
303            }
304            "size" => {
305                if let Some(ref d) = details {
306                    size_result.files = d["files"].as_u64().unwrap_or(0) as u32;
307                    size_result.max_lines_per_file =
308                        d["maxLinesPerFile"].as_u64().unwrap_or(0) as u32;
309                    size_result.max_code_lines_per_file =
310                        d["maxCodeLinesPerFile"].as_u64().unwrap_or(0) as u32;
311                    size_result.max_lines_per_function =
312                        d["maxLinesPerFunction"].as_u64().unwrap_or(0) as u32;
313                    size_result.max_parameters_per_function =
314                        d["maxParametersPerFunction"].as_u64().unwrap_or(0) as u32;
315                    if let Some(arr) = d["violations"].as_array() {
316                        size_result.violations = arr
317                            .iter()
318                            .map(|v| crate::schema::SizeViolation {
319                                rule_id: v["ruleId"].as_str().unwrap_or("").to_string(),
320                                file: v["file"].as_str().unwrap_or("").to_string(),
321                                line: v["line"].as_u64().unwrap_or(0) as u32,
322                                function: v["function"].as_str().map(String::from),
323                                message: v["message"].as_str().unwrap_or("").to_string(),
324                                actual: v["actual"].as_u64().unwrap_or(0) as u32,
325                                threshold: v["threshold"].as_u64().unwrap_or(0) as u32,
326                                severity: v["severity"].as_str().unwrap_or("").to_string(),
327                            })
328                            .collect();
329                    }
330                }
331                size_result.status.clone_from(&output.status);
332            }
333            "complexity" => {
334                if let Some(ref d) = details {
335                    complexity_result.functions = d["functions"].as_u64().unwrap_or(0) as u32;
336                    complexity_result.max_cyclomatic_complexity =
337                        d["maxCyclomaticComplexity"].as_u64().unwrap_or(0) as u32;
338                    complexity_result.max_nesting_depth =
339                        d["maxNestingDepth"].as_u64().unwrap_or(0) as u32;
340                    complexity_result.complex_functions =
341                        d["complexFunctions"].as_u64().unwrap_or(0) as u32;
342                    if let Some(arr) = d["violations"].as_array() {
343                        complexity_result.violations = arr
344                            .iter()
345                            .map(|v| crate::schema::ComplexityViolation {
346                                rule_id: v["ruleId"].as_str().unwrap_or("").to_string(),
347                                file: v["file"].as_str().unwrap_or("").to_string(),
348                                line: v["line"].as_u64().unwrap_or(0) as u32,
349                                function: v["function"].as_str().map(String::from),
350                                message: v["message"].as_str().unwrap_or("").to_string(),
351                                actual: v["actual"].as_u64().unwrap_or(0) as u32,
352                                threshold: v["threshold"].as_u64().unwrap_or(0) as u32,
353                                severity: v["severity"].as_str().unwrap_or("").to_string(),
354                            })
355                            .collect();
356                    }
357                }
358                complexity_result.status.clone_from(&output.status);
359            }
360            _ => {}
361        }
362    }
363
364    MetricsSummary {
365        schema_version: "1".to_string(),
366        generated_at: crate::util::chrono_now(),
367        rustquty_version: env!("CARGO_PKG_VERSION").to_string(),
368        project: ProjectInfo {
369            name: project_name.to_string(),
370            rust_edition: rust_edition.to_string(),
371            workspace_root: workspace_root.to_string(),
372        },
373        collectors: crate::schema::CollectorsSummary {
374            fmt: fmt_result,
375            clippy: clippy_result,
376            tests: test_result,
377            coverage: coverage_result,
378            deny: deny_result,
379            audit: audit_result,
380            hack: hack_result,
381            mutants: mutants_result,
382            duplicates: duplicates_result,
383            loc: loc_result,
384            size: size_result,
385            complexity: complexity_result,
386        },
387    }
388}
389
390/// Execute collectors and assemble results into a MetricsSummary.
391///
392/// Convenience function that calls [`execute_collectors`] then [`assemble_results`].
393pub fn run_collectors(
394    collectors: &[Box<dyn Collector>],
395    ctx: &Context,
396    parallel: bool,
397) -> MetricsSummary {
398    let results = execute_collectors(collectors, ctx, parallel);
399    let project_name = ctx
400        .workspace_root
401        .file_name()
402        .map(|s| s.to_string_lossy().to_string())
403        .unwrap_or_else(|| "unknown".to_string());
404    assemble_results(
405        &results,
406        &project_name,
407        "2021",
408        &ctx.workspace_root.to_string_lossy(),
409    )
410}
411
412#[cfg(test)]
413mod collector_tests {
414    use super::*;
415
416    #[test]
417    fn test_mock_collector() {
418        let mock = MockCollector {
419            name_val: "test",
420            available: true,
421            output: CollectorOutput {
422                status: CollectorStatus::Pass,
423                duration_ms: 10,
424                stdout: String::new(),
425                stderr: String::new(),
426            },
427        };
428        assert_eq!(mock.name(), "test");
429        assert!(mock.is_available());
430    }
431}