Skip to main content

cuke_dedup/
model.rs

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