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    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        f.write_str(self.as_str())
238    }
239}
240
241impl FromStr for Rule {
242    type Err = String;
243
244    fn from_str(value: &str) -> Result<Self, Self::Err> {
245        match value {
246            "duplicate-matcher" => Ok(Self::DuplicateMatcher),
247            "normalized-matcher" => Ok(Self::NormalizedMatcher),
248            "ambiguous-step" => Ok(Self::AmbiguousStep),
249            "overlapping-matcher" => Ok(Self::OverlappingMatcher),
250            "duplicate-handler" => Ok(Self::DuplicateHandler),
251            "near-duplicate-step" => Ok(Self::NearDuplicateStep),
252            "parameterization-candidate" => Ok(Self::ParameterizationCandidate),
253            "unused-definition" => Ok(Self::UnusedDefinition),
254            _ => Err(format!("unknown rule `{value}`")),
255        }
256    }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
260#[serde(rename_all = "lowercase")]
261/// Configured impact of a finding.
262#[non_exhaustive]
263pub enum Severity {
264    /// Rule evaluation is disabled.
265    Off,
266    /// Finding is reported without causing exit code 1.
267    Warning,
268    /// Active finding causes exit code 1.
269    Error,
270}
271
272impl fmt::Display for Severity {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        match self {
275            Severity::Off => f.write_str("off"),
276            Severity::Warning => f.write_str("warning"),
277            Severity::Error => f.write_str("error"),
278        }
279    }
280}
281
282impl FromStr for Severity {
283    type Err = String;
284
285    fn from_str(value: &str) -> Result<Self, Self::Err> {
286        match value {
287            "off" => Ok(Severity::Off),
288            "warn" | "warning" => Ok(Severity::Warning),
289            "error" => Ok(Severity::Error),
290            _ => Err(format!(
291                "invalid severity `{value}` (expected off, warning, or error)"
292            )),
293        }
294    }
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299/// Human- and machine-readable evidence supporting a finding.
300#[non_exhaustive]
301pub struct FindingEvidence {
302    /// Matcher similarity score from zero to one, when applicable.
303    pub matcher_similarity: Option<f64>,
304    /// Handler similarity score from zero to one, when applicable.
305    pub handler_similarity: Option<f64>,
306    /// Concise matcher comparison for terminal output.
307    pub matcher_difference: String,
308    /// Concise handler comparison for terminal output.
309    pub handler_evidence: String,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    /// Structured definition comparison for rich reports.
312    pub comparison: Option<DefinitionComparison>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    /// Connected definition group when a large set of pair findings is collapsed.
315    pub cluster: Option<DefinitionCluster>,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320/// Stable semantic identity and size information for a clustered finding.
321#[non_exhaustive]
322pub struct DefinitionCluster {
323    /// Total number of definitions represented by the cluster.
324    pub member_count: usize,
325    /// Location-independent fingerprints for every definition in the cluster.
326    ///
327    /// Reporters may retain only a bounded prefix and set [`Self::members_truncated`].
328    pub definition_fingerprints: Vec<String>,
329    /// Number of pair findings represented by the single cluster finding.
330    pub pair_findings_collapsed: usize,
331    /// Whether a reporter omitted members from its serialized representation.
332    pub members_truncated: bool,
333}
334
335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase")]
337/// Side-by-side matcher and handler content for a pair finding.
338#[non_exhaustive]
339pub struct DefinitionComparison {
340    /// Location-independent semantic fingerprint of the left definition.
341    pub left_fingerprint: String,
342    /// Location-independent semantic fingerprint of the right definition.
343    pub right_fingerprint: String,
344    /// Left definition's original matcher.
345    pub left_matcher: String,
346    /// Right definition's original matcher.
347    pub right_matcher: String,
348    /// Left definition's bounded handler source.
349    pub left_handler: String,
350    /// Right definition's bounded handler source.
351    pub right_handler: String,
352    /// Structured common and changed matcher segments.
353    pub matcher_diff: MatcherDiff,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase")]
358/// Common and differing segments of two matcher strings.
359#[non_exhaustive]
360pub struct MatcherDiff {
361    /// Shared matcher prefix.
362    pub prefix: String,
363    /// Segment unique to the left matcher.
364    pub left_change: String,
365    /// Segment unique to the right matcher.
366    pub right_change: String,
367    /// Shared matcher suffix.
368    pub suffix: String,
369}
370
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373/// Reason that a finding is excluded from active totals.
374#[non_exhaustive]
375pub struct Suppression {
376    /// Human-readable suppression justification.
377    pub reason: String,
378}
379
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381/// One rule diagnostic produced by analysis.
382#[non_exhaustive]
383pub struct Finding {
384    /// Rule that produced the finding.
385    pub rule: Rule,
386    /// Configured impact.
387    pub severity: Severity,
388    /// Human-readable diagnostic message.
389    pub message: String,
390    /// Main source span.
391    pub primary: SourceLocation,
392    /// Additional spans involved in the finding.
393    pub related: Vec<SourceLocation>,
394    /// Comparison data supporting the diagnostic.
395    pub evidence: FindingEvidence,
396    /// Recommended remediation.
397    pub suggested_action: String,
398    /// Suppression metadata, when excluded from active totals.
399    pub suppression: Option<Suppression>,
400}
401
402impl Finding {
403    /// Returns whether the finding contributes to output totals and exit status.
404    pub fn is_active(&self) -> bool {
405        self.severity != Severity::Off && self.suppression.is_none()
406    }
407}
408
409#[derive(Debug, Clone, Default, Serialize, Deserialize)]
410/// Extracted inputs and deterministically ordered analyzer findings.
411#[non_exhaustive]
412pub struct AnalysisResult {
413    /// Extracted definition corpus.
414    pub definitions: Vec<StepDefinition>,
415    /// Concrete feature-step corpus.
416    pub feature_steps: Vec<FeatureStep>,
417    /// Findings produced by configured rules.
418    pub findings: Vec<Finding>,
419}
420
421impl AnalysisResult {
422    /// Creates a result from extracted inputs and analyzer findings.
423    pub fn new(
424        definitions: Vec<StepDefinition>,
425        feature_steps: Vec<FeatureStep>,
426        findings: Vec<Finding>,
427    ) -> Self {
428        Self {
429            definitions,
430            feature_steps,
431            findings,
432        }
433    }
434}
435
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase")]
438/// Result of applying a percentage-based tolerance to active duplication errors.
439#[non_exhaustive]
440pub struct DuplicationThreshold {
441    /// Maximum duplicated-definition percentage allowed by configuration.
442    pub threshold: f64,
443    /// Unique definitions involved in active error-level duplication findings.
444    pub duplicated_definitions: usize,
445    /// Total number of definitions discovered for the analysis.
446    pub total_definitions: usize,
447    /// Unrounded duplicated-definition percentage.
448    pub percentage: f64,
449    /// Whether the percentage is less than or equal to the configured threshold.
450    pub passed: bool,
451    /// Rules whose error-level findings contribute to the numerator.
452    pub rules: Vec<Rule>,
453}
454
455impl DuplicationThreshold {
456    /// Calculates the duplication rate after suppressions and analysis modes are applied.
457    pub fn from_result(result: &AnalysisResult, threshold: f64) -> Self {
458        let definition_locations: HashSet<_> = result
459            .definitions
460            .iter()
461            .map(|definition| definition.location.clone())
462            .collect();
463        let mut duplicated_locations = HashSet::new();
464        let mut contributing_rules = BTreeSet::new();
465        for finding in result.findings.iter().filter(|finding| {
466            finding.is_active()
467                && finding.severity == Severity::Error
468                && finding.rule.contributes_to_duplication_threshold()
469        }) {
470            contributing_rules.insert(finding.rule);
471            for location in std::iter::once(&finding.primary).chain(&finding.related) {
472                if definition_locations.contains(location) {
473                    duplicated_locations.insert(location.clone());
474                }
475            }
476        }
477
478        let total_definitions = result.definitions.len();
479        let duplicated_definitions = result
480            .definitions
481            .iter()
482            .filter(|definition| duplicated_locations.contains(&definition.location))
483            .count();
484        let percentage = if total_definitions == 0 {
485            0.0
486        } else {
487            duplicated_definitions as f64 / total_definitions as f64 * 100.0
488        };
489        Self {
490            threshold,
491            duplicated_definitions,
492            total_definitions,
493            percentage,
494            passed: threshold.is_finite() && percentage <= threshold,
495            rules: contributing_rules.into_iter().collect(),
496        }
497    }
498}
499
500const SINGLE_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:single\0";
501const MULTIPART_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:parts\0";
502
503/// Computes the first 128 bits of a domain-separated SHA-256 digest for report and comparison data.
504pub fn stable_fingerprint(value: &str) -> String {
505    let mut hasher = Sha256::new();
506    hasher.update(SINGLE_FINGERPRINT_DOMAIN);
507    hasher.update(value.as_bytes());
508    truncated_sha256(hasher)
509}
510
511pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
512    let mut hasher = Sha256::new();
513    hasher.update(MULTIPART_FINGERPRINT_DOMAIN);
514    for part in parts {
515        let bytes = part.as_bytes();
516        let length = u64::try_from(bytes.len()).expect("fingerprint part length fits in u64");
517        hasher.update(&length.to_be_bytes());
518        hasher.update(bytes);
519    }
520    truncated_sha256(hasher)
521}
522
523fn truncated_sha256(hasher: Sha256) -> String {
524    const HEX: &[u8; 16] = b"0123456789abcdef";
525    let mut fingerprint = String::with_capacity(32);
526    for byte in &hasher.finalize()[..16] {
527        fingerprint.push(HEX[usize::from(byte >> 4)] as char);
528        fingerprint.push(HEX[usize::from(byte & 0x0f)] as char);
529    }
530    fingerprint
531}
532
533#[cfg(test)]
534mod tests;