Skip to main content

cuke_dedup/
model.rs

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