Skip to main content

cuke_dedup/
analysis.rs

1//! Rule evaluation over the shared step-definition representation.
2
3mod evidence;
4mod pairs;
5mod similarity;
6mod suppression;
7mod usage;
8
9use crate::config::Config;
10use crate::model::{AnalysisResult, FeatureStep, StepDefinition};
11use anyhow::{bail, Result};
12use serde::Serialize;
13use std::collections::BTreeMap;
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub(crate) struct CandidateSourceCensus {
18    pub(crate) evaluated: usize,
19    pub(crate) skipped: u64,
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub(crate) struct AnalysisCensus {
25    pub(crate) truncated: bool,
26    pub(crate) candidate_comparisons_evaluated: usize,
27    pub(crate) skipped_candidate_comparisons: u64,
28    pub(crate) truncated_structural_classes: usize,
29    pub(crate) candidate_sources: BTreeMap<String, CandidateSourceCensus>,
30}
31
32#[derive(Debug)]
33/// Analysis data plus diagnostics describing how complete the result is.
34///
35/// Callers that need to render partial results before failing should use
36/// [`analyze_with_diagnostics`]. Callers that only accept complete analysis can use [`analyze`].
37#[non_exhaustive]
38pub struct AnalysisOutcome {
39    /// Findings and analyzed inputs available before completeness handling.
40    pub result: AnalysisResult,
41    /// Diagnostics describing analysis that was bounded before it could finish.
42    ///
43    /// A non-empty list means findings are a subset of what a complete run would report, so the
44    /// absence of a finding proves nothing. Findings that are present remain valid.
45    pub incomplete: Vec<String>,
46}
47
48/// Evaluates every configured rule against extracted definitions and feature steps.
49pub fn analyze(
50    definitions: Vec<StepDefinition>,
51    feature_steps: Vec<FeatureStep>,
52    config: &Config,
53) -> Result<AnalysisResult> {
54    let outcome = analyze_with_diagnostics(definitions, feature_steps, config)?;
55    if !outcome.incomplete.is_empty() {
56        bail!(outcome.incomplete.join("; "));
57    }
58    Ok(outcome.result)
59}
60
61/// Runs analysis while preserving partial results when bounded work cannot complete.
62///
63/// A non-empty [`AnalysisOutcome::incomplete`] means findings are a subset of a complete run, so
64/// the absence of a finding proves nothing. This form lets CLI and library consumers write
65/// diagnostic reports and decide their own severity. Candidate-generation truncation and matcher
66/// compilation limits are both surfaced through `incomplete`.
67pub fn analyze_with_diagnostics(
68    definitions: Vec<StepDefinition>,
69    feature_steps: Vec<FeatureStep>,
70    config: &Config,
71) -> Result<AnalysisOutcome> {
72    Ok(analyze_internal(definitions, feature_steps, config, false)?.0)
73}
74
75pub(crate) fn analyze_for_cli(
76    definitions: Vec<StepDefinition>,
77    feature_steps: Vec<FeatureStep>,
78    config: &Config,
79) -> Result<(
80    AnalysisOutcome,
81    AnalysisCensus,
82    suppression::UnmatchedSuppressionOutcome,
83)> {
84    analyze_internal(definitions, feature_steps, config, true)
85}
86
87fn analyze_internal(
88    definitions: Vec<StepDefinition>,
89    feature_steps: Vec<FeatureStep>,
90    config: &Config,
91    include_unmatched_suppressions: bool,
92) -> Result<(
93    AnalysisOutcome,
94    AnalysisCensus,
95    suppression::UnmatchedSuppressionOutcome,
96)> {
97    config.validate_analysis_limits()?;
98    let mut findings = Vec::new();
99    let suppressions = suppression::SuppressionIndex::new(config, &definitions);
100    let pair_analysis =
101        pairs::analyze_definition_pairs(&definitions, config, &suppressions, &mut findings);
102    let overlap_budget = config
103        .max_candidate_comparisons
104        .saturating_sub(pair_analysis.census.candidate_comparisons_evaluated);
105    let usage = usage::analyze_feature_usage(
106        &definitions,
107        &feature_steps,
108        config,
109        &suppressions,
110        &mut findings,
111        overlap_budget,
112    );
113    usage::analyze_unused(
114        &definitions,
115        &usage.used,
116        config,
117        &suppressions,
118        &mut findings,
119    );
120    let unmatched_suppressions = if include_unmatched_suppressions {
121        suppressions.unmatched()
122    } else {
123        suppression::UnmatchedSuppressionOutcome::default()
124    };
125    findings.sort_by(|left, right| {
126        left.primary
127            .path
128            .cmp(&right.primary.path)
129            .then(left.primary.line.cmp(&right.primary.line))
130            .then(left.primary.column.cmp(&right.primary.column))
131            .then(left.rule.cmp(&right.rule))
132    });
133    let mut census = pair_analysis.census;
134    census.candidate_comparisons_evaluated = census
135        .candidate_comparisons_evaluated
136        .saturating_add(usage.overlap_census.evaluated);
137    census.skipped_candidate_comparisons = census
138        .skipped_candidate_comparisons
139        .saturating_add(usage.overlap_census.skipped);
140    census.truncated |= usage.overlap_census.skipped > 0;
141    census
142        .candidate_sources
143        .insert("matcherOverlap".to_owned(), usage.overlap_census);
144    let mut incomplete = usage.incomplete;
145    incomplete.extend(pair_analysis.incomplete);
146    Ok((
147        AnalysisOutcome {
148            result: AnalysisResult {
149                definitions,
150                feature_steps,
151                findings,
152            },
153            incomplete,
154        },
155        census,
156        unmatched_suppressions,
157    ))
158}
159
160#[cfg(test)]
161mod tests;