Skip to main content

cuke_dedup/
model.rs

1//! Shared intermediate representation and diagnostics.
2
3mod sha256;
4
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeSet, HashSet};
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10
11use self::sha256::Sha256;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14/// Source span with one-based starts and one-based, exclusive end coordinates.
15pub struct SourceLocation {
16    /// Absolute or caller-provided source path.
17    pub path: PathBuf,
18    /// One-based start line.
19    pub line: usize,
20    /// One-based start column.
21    pub column: usize,
22    /// One-based line containing the exclusive end position.
23    pub end_line: usize,
24    /// One-based, exclusive end column in the original source text.
25    pub end_column: usize,
26}
27
28impl SourceLocation {
29    /// Creates a source location from a path and one-based span coordinates.
30    pub fn new(
31        path: impl Into<PathBuf>,
32        line: usize,
33        column: usize,
34        end_line: usize,
35        end_column: usize,
36    ) -> Self {
37        Self {
38            path: path.into(),
39            line,
40            column,
41            end_line,
42            end_column,
43        }
44    }
45
46    /// Formats the location as `path:line:column`, relative to `root` when possible.
47    pub fn display(&self, root: &Path) -> String {
48        let path = self.path.strip_prefix(root).unwrap_or(&self.path);
49        let path = path.to_string_lossy().replace('\\', "/");
50        format!("{path}:{}:{}", self.line, self.column)
51    }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56/// Syntax used by a step definition matcher.
57#[non_exhaustive]
58pub enum MatcherKind {
59    /// A Cucumber Expression such as `I have {int} items`.
60    CucumberExpression,
61    /// A JavaScript regular expression.
62    RegularExpression,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "kebab-case")]
67/// Step-definition framework inferred from a source file.
68#[non_exhaustive]
69pub enum Framework {
70    /// Cucumber.js registration APIs.
71    CucumberJs,
72    /// Playwright BDD registration APIs.
73    PlaywrightBdd,
74    /// Cypress step definitions registered through the Badeball Cucumber preprocessor.
75    CypressCucumber,
76    /// No supported framework import could be inferred.
77    Unknown,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81/// Comparable representations of a definition handler.
82pub struct HandlerFingerprint {
83    /// Exact syntax-tree representation.
84    pub exact: String,
85    /// Representation normalized for superficial syntax differences.
86    pub normalized: String,
87    /// Representation with parameter and local identifiers normalized.
88    pub alpha_normalized: String,
89    /// Control-flow and call structure with literals normalized.
90    pub structural: String,
91    /// Ordered behavioral operations observed in the handler.
92    pub behavior_signature: Vec<String>,
93    /// Bounded original source displayed in reports.
94    pub source_snippet: String,
95    /// Whether a concrete handler body was available for comparison.
96    #[serde(default)]
97    pub comparable: bool,
98    #[serde(default)]
99    /// Whether the handler lacks enough behavior for duplication analysis.
100    pub trivial: bool,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105/// Rule-scoped, reason-bearing suppression declared beside a definition.
106pub struct InlineSuppression {
107    /// Rule disabled for findings involving this definition.
108    pub rule: Rule,
109    /// Human-readable justification retained in reports.
110    pub reason: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114/// A step definition extracted into the analyzer's shared representation.
115pub struct StepDefinition {
116    /// Original matcher text without JavaScript delimiters.
117    pub matcher: String,
118    /// Canonical matcher used for equivalence checks.
119    pub normalized_matcher: String,
120    /// Matcher syntax.
121    pub matcher_kind: MatcherKind,
122    #[serde(default, skip_serializing_if = "String::is_empty")]
123    /// JavaScript regular-expression flags, or an empty string otherwise.
124    pub matcher_flags: String,
125    /// Comparable handler representations.
126    pub handler: HandlerFingerprint,
127    /// Framework that registered the definition.
128    pub framework: Framework,
129    /// Registration name resolved from direct, aliased, or namespaced usage.
130    pub registration: String,
131    /// Full registration call location.
132    pub location: SourceLocation,
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    /// Source-local suppression directives attached to this definition.
135    pub inline_suppressions: Vec<InlineSuppression>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139/// One concrete step from a parsed Gherkin scenario.
140pub struct FeatureStep {
141    /// Localized Gherkin keyword.
142    pub keyword: String,
143    /// Concrete step text after Scenario Outline expansion.
144    pub text: String,
145    /// Source location of the originating step.
146    pub location: SourceLocation,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
150#[serde(rename_all = "kebab-case")]
151/// Stable identifier for an analyzer rule.
152#[non_exhaustive]
153pub enum Rule {
154    /// Two definitions have the same effective matcher.
155    DuplicateMatcher,
156    /// Two matchers become equivalent after normalization.
157    NormalizedMatcher,
158    /// A concrete feature step matches multiple definitions.
159    AmbiguousStep,
160    /// Two matchers can accept the same step text, independent of the feature corpus.
161    OverlappingMatcher,
162    /// Distinct matchers share an equivalent implementation.
163    DuplicateHandler,
164    /// Similar wording and handler structure indicate likely duplication.
165    NearDuplicateStep,
166    /// Similar definitions may be replaceable by a parameterized step.
167    ParameterizationCandidate,
168    /// No discovered feature step uses a definition.
169    UnusedDefinition,
170}
171
172impl Rule {
173    const DOCUMENTATION_BASE_URL: &'static str =
174        "https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#";
175
176    /// Every rule in deterministic report order.
177    pub const ALL: &'static [Rule] = &[
178        Rule::DuplicateMatcher,
179        Rule::NormalizedMatcher,
180        Rule::AmbiguousStep,
181        Rule::OverlappingMatcher,
182        Rule::DuplicateHandler,
183        Rule::NearDuplicateStep,
184        Rule::ParameterizationCandidate,
185        Rule::UnusedDefinition,
186    ];
187
188    /// Rules whose active error findings contribute to the duplication threshold.
189    pub const DUPLICATION_THRESHOLD: &'static [Rule] = &[
190        Rule::DuplicateMatcher,
191        Rule::NormalizedMatcher,
192        Rule::DuplicateHandler,
193        Rule::NearDuplicateStep,
194        Rule::ParameterizationCandidate,
195    ];
196
197    /// Returns the stable kebab-case configuration and report name.
198    pub fn as_str(self) -> &'static str {
199        match self {
200            Rule::DuplicateMatcher => "duplicate-matcher",
201            Rule::NormalizedMatcher => "normalized-matcher",
202            Rule::AmbiguousStep => "ambiguous-step",
203            Rule::OverlappingMatcher => "overlapping-matcher",
204            Rule::DuplicateHandler => "duplicate-handler",
205            Rule::NearDuplicateStep => "near-duplicate-step",
206            Rule::ParameterizationCandidate => "parameterization-candidate",
207            Rule::UnusedDefinition => "unused-definition",
208        }
209    }
210
211    /// Returns whether error findings from this rule contribute to the duplication threshold.
212    pub fn contributes_to_duplication_threshold(self) -> bool {
213        Self::DUPLICATION_THRESHOLD.contains(&self)
214    }
215
216    /// Returns the rule-specific documentation URL used by diagnostics integrations.
217    pub fn documentation_url(self) -> String {
218        format!("{}{}", Self::DOCUMENTATION_BASE_URL, self.as_str())
219    }
220}
221
222impl fmt::Display for Rule {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        f.write_str(self.as_str())
225    }
226}
227
228impl FromStr for Rule {
229    type Err = String;
230
231    fn from_str(value: &str) -> Result<Self, Self::Err> {
232        match value {
233            "duplicate-matcher" => Ok(Self::DuplicateMatcher),
234            "normalized-matcher" => Ok(Self::NormalizedMatcher),
235            "ambiguous-step" => Ok(Self::AmbiguousStep),
236            "overlapping-matcher" => Ok(Self::OverlappingMatcher),
237            "duplicate-handler" => Ok(Self::DuplicateHandler),
238            "near-duplicate-step" => Ok(Self::NearDuplicateStep),
239            "parameterization-candidate" => Ok(Self::ParameterizationCandidate),
240            "unused-definition" => Ok(Self::UnusedDefinition),
241            _ => Err(format!("unknown rule `{value}`")),
242        }
243    }
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
247#[serde(rename_all = "lowercase")]
248/// Configured impact of a finding.
249#[non_exhaustive]
250pub enum Severity {
251    /// Rule evaluation is disabled.
252    Off,
253    /// Finding is reported without causing exit code 1.
254    Warning,
255    /// Active finding causes exit code 1.
256    Error,
257}
258
259impl fmt::Display for Severity {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        match self {
262            Severity::Off => f.write_str("off"),
263            Severity::Warning => f.write_str("warning"),
264            Severity::Error => f.write_str("error"),
265        }
266    }
267}
268
269impl FromStr for Severity {
270    type Err = String;
271
272    fn from_str(value: &str) -> Result<Self, Self::Err> {
273        match value {
274            "off" => Ok(Severity::Off),
275            "warn" | "warning" => Ok(Severity::Warning),
276            "error" => Ok(Severity::Error),
277            _ => Err(format!(
278                "invalid severity `{value}` (expected off, warning, or error)"
279            )),
280        }
281    }
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase")]
286/// Human- and machine-readable evidence supporting a finding.
287#[non_exhaustive]
288pub struct FindingEvidence {
289    /// Matcher similarity score from zero to one, when applicable.
290    pub matcher_similarity: Option<f64>,
291    /// Handler similarity score from zero to one, when applicable.
292    pub handler_similarity: Option<f64>,
293    /// Concise matcher comparison for terminal output.
294    pub matcher_difference: String,
295    /// Concise handler comparison for terminal output.
296    pub handler_evidence: String,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    /// Structured definition comparison for rich reports.
299    pub comparison: Option<DefinitionComparison>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    /// Connected definition group when a large set of pair findings is collapsed.
302    pub cluster: Option<DefinitionCluster>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "camelCase")]
307/// Stable semantic identity and size information for a clustered finding.
308#[non_exhaustive]
309pub struct DefinitionCluster {
310    /// Total number of definitions represented by the cluster.
311    pub member_count: usize,
312    /// Location-independent fingerprints for every definition in the cluster.
313    ///
314    /// Reporters may retain only a bounded prefix and set [`Self::members_truncated`].
315    pub definition_fingerprints: Vec<String>,
316    /// Number of pair findings represented by the single cluster finding.
317    pub pair_findings_collapsed: usize,
318    /// Whether a reporter omitted members from its serialized representation.
319    pub members_truncated: bool,
320}
321
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase")]
324/// Side-by-side matcher and handler content for a pair finding.
325#[non_exhaustive]
326pub struct DefinitionComparison {
327    /// Location-independent semantic fingerprint of the left definition.
328    pub left_fingerprint: String,
329    /// Location-independent semantic fingerprint of the right definition.
330    pub right_fingerprint: String,
331    /// Left definition's original matcher.
332    pub left_matcher: String,
333    /// Right definition's original matcher.
334    pub right_matcher: String,
335    /// Left definition's bounded handler source.
336    pub left_handler: String,
337    /// Right definition's bounded handler source.
338    pub right_handler: String,
339    /// Structured common and changed matcher segments.
340    pub matcher_diff: MatcherDiff,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345/// Common and differing segments of two matcher strings.
346#[non_exhaustive]
347pub struct MatcherDiff {
348    /// Shared matcher prefix.
349    pub prefix: String,
350    /// Segment unique to the left matcher.
351    pub left_change: String,
352    /// Segment unique to the right matcher.
353    pub right_change: String,
354    /// Shared matcher suffix.
355    pub suffix: String,
356}
357
358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360/// Reason that a finding is excluded from active totals.
361#[non_exhaustive]
362pub struct Suppression {
363    /// Human-readable suppression justification.
364    pub reason: String,
365}
366
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368/// One rule diagnostic produced by analysis.
369#[non_exhaustive]
370pub struct Finding {
371    /// Rule that produced the finding.
372    pub rule: Rule,
373    /// Configured impact.
374    pub severity: Severity,
375    /// Human-readable diagnostic message.
376    pub message: String,
377    /// Main source span.
378    pub primary: SourceLocation,
379    /// Additional spans involved in the finding.
380    pub related: Vec<SourceLocation>,
381    /// Comparison data supporting the diagnostic.
382    pub evidence: FindingEvidence,
383    /// Recommended remediation.
384    pub suggested_action: String,
385    /// Suppression metadata, when excluded from active totals.
386    pub suppression: Option<Suppression>,
387}
388
389impl Finding {
390    /// Returns whether the finding contributes to output totals and exit status.
391    pub fn is_active(&self) -> bool {
392        self.severity != Severity::Off && self.suppression.is_none()
393    }
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize)]
397/// Extracted inputs and deterministically ordered analyzer findings.
398#[non_exhaustive]
399pub struct AnalysisResult {
400    /// Extracted definition corpus.
401    pub definitions: Vec<StepDefinition>,
402    /// Concrete feature-step corpus.
403    pub feature_steps: Vec<FeatureStep>,
404    /// Findings produced by configured rules.
405    pub findings: Vec<Finding>,
406}
407
408impl AnalysisResult {
409    /// Creates a result from extracted inputs and analyzer findings.
410    pub fn new(
411        definitions: Vec<StepDefinition>,
412        feature_steps: Vec<FeatureStep>,
413        findings: Vec<Finding>,
414    ) -> Self {
415        Self {
416            definitions,
417            feature_steps,
418            findings,
419        }
420    }
421}
422
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424#[serde(rename_all = "camelCase")]
425/// Result of applying a percentage-based tolerance to active duplication errors.
426#[non_exhaustive]
427pub struct DuplicationThreshold {
428    /// Maximum duplicated-definition percentage allowed by configuration.
429    pub threshold: f64,
430    /// Unique definitions involved in active error-level duplication findings.
431    pub duplicated_definitions: usize,
432    /// Total number of definitions discovered for the analysis.
433    pub total_definitions: usize,
434    /// Unrounded duplicated-definition percentage.
435    pub percentage: f64,
436    /// Whether the percentage is less than or equal to the configured threshold.
437    pub passed: bool,
438    /// Rules whose error-level findings contribute to the numerator.
439    pub rules: Vec<Rule>,
440}
441
442impl DuplicationThreshold {
443    /// Calculates the duplication rate after suppressions and analysis modes are applied.
444    pub fn from_result(result: &AnalysisResult, threshold: f64) -> Self {
445        let definition_locations: HashSet<_> = result
446            .definitions
447            .iter()
448            .map(|definition| definition.location.clone())
449            .collect();
450        let mut duplicated_locations = HashSet::new();
451        let mut contributing_rules = BTreeSet::new();
452        for finding in result.findings.iter().filter(|finding| {
453            finding.is_active()
454                && finding.severity == Severity::Error
455                && finding.rule.contributes_to_duplication_threshold()
456        }) {
457            contributing_rules.insert(finding.rule);
458            for location in std::iter::once(&finding.primary).chain(&finding.related) {
459                if definition_locations.contains(location) {
460                    duplicated_locations.insert(location.clone());
461                }
462            }
463        }
464
465        let total_definitions = result.definitions.len();
466        let duplicated_definitions = result
467            .definitions
468            .iter()
469            .filter(|definition| duplicated_locations.contains(&definition.location))
470            .count();
471        let percentage = if total_definitions == 0 {
472            0.0
473        } else {
474            duplicated_definitions as f64 / total_definitions as f64 * 100.0
475        };
476        Self {
477            threshold,
478            duplicated_definitions,
479            total_definitions,
480            percentage,
481            passed: threshold.is_finite() && percentage <= threshold,
482            rules: contributing_rules.into_iter().collect(),
483        }
484    }
485}
486
487const SINGLE_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:single\0";
488const MULTIPART_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:parts\0";
489
490/// Computes the first 128 bits of a domain-separated SHA-256 digest for report and comparison data.
491pub fn stable_fingerprint(value: &str) -> String {
492    let mut hasher = Sha256::new();
493    hasher.update(SINGLE_FINGERPRINT_DOMAIN);
494    hasher.update(value.as_bytes());
495    truncated_sha256(hasher)
496}
497
498pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
499    let mut hasher = Sha256::new();
500    hasher.update(MULTIPART_FINGERPRINT_DOMAIN);
501    for part in parts {
502        let bytes = part.as_bytes();
503        let length = u64::try_from(bytes.len()).expect("fingerprint part length fits in u64");
504        hasher.update(&length.to_be_bytes());
505        hasher.update(bytes);
506    }
507    truncated_sha256(hasher)
508}
509
510fn truncated_sha256(hasher: Sha256) -> String {
511    const HEX: &[u8; 16] = b"0123456789abcdef";
512    let mut fingerprint = String::with_capacity(32);
513    for byte in &hasher.finalize()[..16] {
514        fingerprint.push(HEX[usize::from(byte >> 4)] as char);
515        fingerprint.push(HEX[usize::from(byte & 0x0f)] as char);
516    }
517    fingerprint
518}
519
520#[cfg(test)]
521mod tests;