1use 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)]
10pub struct SourceLocation {
12 pub path: PathBuf,
14 pub line: usize,
16 pub column: usize,
18 pub end_line: usize,
20 pub end_column: usize,
22}
23
24impl SourceLocation {
25 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 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#[non_exhaustive]
54pub enum MatcherKind {
55 CucumberExpression,
57 RegularExpression,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63#[non_exhaustive]
65pub enum Framework {
66 CucumberJs,
68 PlaywrightBdd,
70 CypressCucumber,
72 Unknown,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct HandlerFingerprint {
79 pub exact: String,
81 pub normalized: String,
83 pub alpha_normalized: String,
85 pub structural: String,
87 pub behavior_signature: Vec<String>,
89 pub source_snippet: String,
91 #[serde(default)]
93 pub comparable: bool,
94 #[serde(default)]
95 pub trivial: bool,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct InlineSuppression {
103 pub rule: Rule,
105 pub reason: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct StepDefinition {
112 pub matcher: String,
114 pub normalized_matcher: String,
116 pub matcher_kind: MatcherKind,
118 #[serde(default, skip_serializing_if = "String::is_empty")]
119 pub matcher_flags: String,
121 pub handler: HandlerFingerprint,
123 pub framework: Framework,
125 pub registration: String,
127 pub location: SourceLocation,
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub inline_suppressions: Vec<InlineSuppression>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct FeatureStep {
137 pub keyword: String,
139 pub text: String,
141 pub location: SourceLocation,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
146#[serde(rename_all = "kebab-case")]
147#[non_exhaustive]
149pub enum Rule {
150 DuplicateMatcher,
152 NormalizedMatcher,
154 AmbiguousStep,
156 OverlappingMatcher,
158 DuplicateHandler,
160 NearDuplicateStep,
162 ParameterizationCandidate,
164 UnusedDefinition,
166}
167
168impl Rule {
169 const DOCUMENTATION_BASE_URL: &'static str =
170 "https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#";
171
172 pub const ALL: &'static [Rule] = &[
174 Rule::DuplicateMatcher,
175 Rule::NormalizedMatcher,
176 Rule::AmbiguousStep,
177 Rule::OverlappingMatcher,
178 Rule::DuplicateHandler,
179 Rule::NearDuplicateStep,
180 Rule::ParameterizationCandidate,
181 Rule::UnusedDefinition,
182 ];
183
184 pub const DUPLICATION_THRESHOLD: &'static [Rule] = &[
186 Rule::DuplicateMatcher,
187 Rule::NormalizedMatcher,
188 Rule::DuplicateHandler,
189 Rule::NearDuplicateStep,
190 Rule::ParameterizationCandidate,
191 ];
192
193 pub fn as_str(self) -> &'static str {
195 match self {
196 Rule::DuplicateMatcher => "duplicate-matcher",
197 Rule::NormalizedMatcher => "normalized-matcher",
198 Rule::AmbiguousStep => "ambiguous-step",
199 Rule::OverlappingMatcher => "overlapping-matcher",
200 Rule::DuplicateHandler => "duplicate-handler",
201 Rule::NearDuplicateStep => "near-duplicate-step",
202 Rule::ParameterizationCandidate => "parameterization-candidate",
203 Rule::UnusedDefinition => "unused-definition",
204 }
205 }
206
207 pub fn contributes_to_duplication_threshold(self) -> bool {
209 Self::DUPLICATION_THRESHOLD.contains(&self)
210 }
211
212 pub fn documentation_url(self) -> String {
214 format!("{}{}", Self::DOCUMENTATION_BASE_URL, self.as_str())
215 }
216}
217
218impl fmt::Display for Rule {
219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220 f.write_str(self.as_str())
221 }
222}
223
224impl FromStr for Rule {
225 type Err = String;
226
227 fn from_str(value: &str) -> Result<Self, Self::Err> {
228 match value {
229 "duplicate-matcher" => Ok(Self::DuplicateMatcher),
230 "normalized-matcher" => Ok(Self::NormalizedMatcher),
231 "ambiguous-step" => Ok(Self::AmbiguousStep),
232 "overlapping-matcher" => Ok(Self::OverlappingMatcher),
233 "duplicate-handler" => Ok(Self::DuplicateHandler),
234 "near-duplicate-step" => Ok(Self::NearDuplicateStep),
235 "parameterization-candidate" => Ok(Self::ParameterizationCandidate),
236 "unused-definition" => Ok(Self::UnusedDefinition),
237 _ => Err(format!("unknown rule `{value}`")),
238 }
239 }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
243#[serde(rename_all = "lowercase")]
244#[non_exhaustive]
246pub enum Severity {
247 Off,
249 Warning,
251 Error,
253}
254
255impl fmt::Display for Severity {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 match self {
258 Severity::Off => f.write_str("off"),
259 Severity::Warning => f.write_str("warning"),
260 Severity::Error => f.write_str("error"),
261 }
262 }
263}
264
265impl FromStr for Severity {
266 type Err = String;
267
268 fn from_str(value: &str) -> Result<Self, Self::Err> {
269 match value {
270 "off" => Ok(Severity::Off),
271 "warn" | "warning" => Ok(Severity::Warning),
272 "error" => Ok(Severity::Error),
273 _ => Err(format!(
274 "invalid severity `{value}` (expected off, warning, or error)"
275 )),
276 }
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282#[non_exhaustive]
284pub struct FindingEvidence {
285 pub matcher_similarity: Option<f64>,
287 pub handler_similarity: Option<f64>,
289 pub matcher_difference: String,
291 pub handler_evidence: String,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub comparison: Option<DefinitionComparison>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub cluster: Option<DefinitionCluster>,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase")]
303#[non_exhaustive]
305pub struct DefinitionCluster {
306 pub member_count: usize,
308 pub definition_fingerprints: Vec<String>,
312 pub pair_findings_collapsed: usize,
314 pub members_truncated: bool,
316}
317
318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320#[non_exhaustive]
322pub struct DefinitionComparison {
323 pub left_fingerprint: String,
325 pub right_fingerprint: String,
327 pub left_matcher: String,
329 pub right_matcher: String,
331 pub left_handler: String,
333 pub right_handler: String,
335 pub matcher_diff: MatcherDiff,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "camelCase")]
341#[non_exhaustive]
343pub struct MatcherDiff {
344 pub prefix: String,
346 pub left_change: String,
348 pub right_change: String,
350 pub suffix: String,
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase")]
356#[non_exhaustive]
358pub struct Suppression {
359 pub reason: String,
361}
362
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
364#[non_exhaustive]
366pub struct Finding {
367 pub rule: Rule,
369 pub severity: Severity,
371 pub message: String,
373 pub primary: SourceLocation,
375 pub related: Vec<SourceLocation>,
377 pub evidence: FindingEvidence,
379 pub suggested_action: String,
381 pub suppression: Option<Suppression>,
383}
384
385impl Finding {
386 pub fn is_active(&self) -> bool {
388 self.severity != Severity::Off && self.suppression.is_none()
389 }
390}
391
392#[derive(Debug, Clone, Default, Serialize, Deserialize)]
393#[non_exhaustive]
395pub struct AnalysisResult {
396 pub definitions: Vec<StepDefinition>,
398 pub feature_steps: Vec<FeatureStep>,
400 pub findings: Vec<Finding>,
402}
403
404impl AnalysisResult {
405 pub fn new(
407 definitions: Vec<StepDefinition>,
408 feature_steps: Vec<FeatureStep>,
409 findings: Vec<Finding>,
410 ) -> Self {
411 Self {
412 definitions,
413 feature_steps,
414 findings,
415 }
416 }
417}
418
419#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421#[non_exhaustive]
423pub struct DuplicationThreshold {
424 pub threshold: f64,
426 pub duplicated_definitions: usize,
428 pub total_definitions: usize,
430 pub percentage: f64,
432 pub passed: bool,
434 pub rules: Vec<Rule>,
436}
437
438impl DuplicationThreshold {
439 pub fn from_result(result: &AnalysisResult, threshold: f64) -> Self {
441 let definition_locations: HashSet<_> = result
442 .definitions
443 .iter()
444 .map(|definition| definition.location.clone())
445 .collect();
446 let mut duplicated_locations = HashSet::new();
447 let mut contributing_rules = BTreeSet::new();
448 for finding in result.findings.iter().filter(|finding| {
449 finding.is_active()
450 && finding.severity == Severity::Error
451 && finding.rule.contributes_to_duplication_threshold()
452 }) {
453 contributing_rules.insert(finding.rule);
454 for location in std::iter::once(&finding.primary).chain(&finding.related) {
455 if definition_locations.contains(location) {
456 duplicated_locations.insert(location.clone());
457 }
458 }
459 }
460
461 let total_definitions = result.definitions.len();
462 let duplicated_definitions = result
463 .definitions
464 .iter()
465 .filter(|definition| duplicated_locations.contains(&definition.location))
466 .count();
467 let percentage = if total_definitions == 0 {
468 0.0
469 } else {
470 duplicated_definitions as f64 / total_definitions as f64 * 100.0
471 };
472 Self {
473 threshold,
474 duplicated_definitions,
475 total_definitions,
476 percentage,
477 passed: threshold.is_finite() && percentage <= threshold,
478 rules: contributing_rules.into_iter().collect(),
479 }
480 }
481}
482
483pub fn stable_fingerprint(value: &str) -> String {
485 let mut hash = 0xcbf29ce484222325_u64;
487 for byte in value.as_bytes() {
488 hash ^= u64::from(*byte);
489 hash = hash.wrapping_mul(0x100000001b3);
490 }
491 format!("{hash:016x}")
492}
493
494pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
495 let mut hash = 0xcbf29ce484222325_u64;
496 for (index, part) in parts.into_iter().enumerate() {
497 if index > 0 {
498 hash ^= 0;
499 hash = hash.wrapping_mul(0x100000001b3);
500 }
501 for byte in part.as_bytes() {
502 hash ^= u64::from(*byte);
503 hash = hash.wrapping_mul(0x100000001b3);
504 }
505 }
506 format!("{hash:016x}")
507}
508
509#[cfg(test)]
510mod tests;