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