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 {
511 use super::*;
512
513 fn definition(line: usize) -> StepDefinition {
514 StepDefinition {
515 matcher: format!("step {line}"),
516 normalized_matcher: format!("step {line}"),
517 matcher_kind: MatcherKind::CucumberExpression,
518 matcher_flags: String::new(),
519 handler: HandlerFingerprint {
520 exact: String::new(),
521 normalized: String::new(),
522 alpha_normalized: String::new(),
523 structural: String::new(),
524 behavior_signature: Vec::new(),
525 source_snippet: String::new(),
526 comparable: false,
527 trivial: true,
528 },
529 framework: Framework::Unknown,
530 registration: "Given".to_owned(),
531 location: SourceLocation::new("steps.ts", line, 1, line, 10),
532 inline_suppressions: Vec::new(),
533 }
534 }
535
536 fn finding(
537 definitions: &[StepDefinition],
538 left: usize,
539 right: usize,
540 severity: Severity,
541 ) -> Finding {
542 Finding {
543 rule: Rule::DuplicateMatcher,
544 severity,
545 message: "duplicate".to_owned(),
546 primary: definitions[left].location.clone(),
547 related: vec![definitions[right].location.clone()],
548 evidence: FindingEvidence {
549 matcher_similarity: Some(1.0),
550 handler_similarity: None,
551 matcher_difference: String::new(),
552 handler_evidence: String::new(),
553 comparison: None,
554 cluster: None,
555 },
556 suggested_action: "consolidate".to_owned(),
557 suppression: None,
558 }
559 }
560
561 #[test]
562 fn rule_names_are_stable_kebab_case() {
563 for &rule in Rule::ALL {
564 assert_eq!(rule.to_string().parse::<Rule>(), Ok(rule));
565 assert_eq!(
566 rule.documentation_url(),
567 format!(
568 "https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#{}",
569 rule
570 )
571 );
572 }
573 }
574
575 #[test]
576 fn multipart_fingerprint_matches_nul_joined_input_without_join_allocation() {
577 assert_eq!(
578 stable_fingerprint_parts(["duplicate-matcher", "alpha", "bravo"]),
579 stable_fingerprint("duplicate-matcher\0alpha\0bravo")
580 );
581 }
582
583 #[test]
584 fn severity_text_forms_are_complete_and_reject_unknown_values() {
585 assert_eq!(Severity::Off.to_string(), "off");
586 assert_eq!(Severity::Warning.to_string(), "warning");
587 assert_eq!(Severity::Error.to_string(), "error");
588 assert_eq!("off".parse(), Ok(Severity::Off));
589 assert_eq!("warn".parse(), Ok(Severity::Warning));
590 assert_eq!("warning".parse(), Ok(Severity::Warning));
591 assert_eq!("error".parse(), Ok(Severity::Error));
592 assert_eq!(
593 "fatal".parse::<Severity>(),
594 Err("invalid severity `fatal` (expected off, warning, or error)".to_owned())
595 );
596 }
597
598 #[test]
599 fn threshold_rules_exclude_correctness_and_usage_findings() {
600 assert!(Rule::DuplicateMatcher.contributes_to_duplication_threshold());
601 assert!(Rule::ParameterizationCandidate.contributes_to_duplication_threshold());
602 assert!(!Rule::AmbiguousStep.contributes_to_duplication_threshold());
603 assert!(!Rule::UnusedDefinition.contributes_to_duplication_threshold());
604 }
605
606 #[test]
607 fn duplication_threshold_counts_unique_active_error_definitions() {
608 let definitions: Vec<_> = (1..=4).map(definition).collect();
609 let first = finding(&definitions, 0, 1, Severity::Error);
610 let second = finding(&definitions, 0, 2, Severity::Error);
611 let warning = finding(&definitions, 2, 3, Severity::Warning);
612 let mut suppressed = finding(&definitions, 1, 3, Severity::Error);
613 suppressed.suppression = Some(Suppression {
614 reason: "accepted".to_owned(),
615 });
616 let result = AnalysisResult {
617 definitions,
618 feature_steps: Vec::new(),
619 findings: vec![first, second, warning, suppressed],
620 };
621
622 let at_limit = DuplicationThreshold::from_result(&result, 75.0);
623 assert_eq!(at_limit.duplicated_definitions, 3);
624 assert_eq!(at_limit.total_definitions, 4);
625 assert_eq!(at_limit.percentage, 75.0);
626 assert_eq!(at_limit.rules, [Rule::DuplicateMatcher]);
627 assert!(at_limit.passed);
628 assert!(!DuplicationThreshold::from_result(&result, 74.99).passed);
629 }
630
631 #[test]
632 fn empty_analysis_has_zero_percent_duplication() {
633 let result = AnalysisResult::default();
634 let threshold = DuplicationThreshold::from_result(&result, 0.0);
635 assert_eq!(threshold.percentage, 0.0);
636 assert!(threshold.passed);
637 }
638
639 #[test]
640 fn duplication_threshold_counts_multiple_definitions_at_one_location() {
641 let mut definitions = vec![definition(1), definition(2)];
642 definitions[1].location = definitions[0].location.clone();
643 let duplicate = finding(&definitions, 0, 1, Severity::Error);
644 let result = AnalysisResult {
645 definitions,
646 feature_steps: Vec::new(),
647 findings: vec![duplicate],
648 };
649 let threshold = DuplicationThreshold::from_result(&result, 100.0);
650 assert_eq!(threshold.duplicated_definitions, 2);
651 assert_eq!(threshold.percentage, 100.0);
652 }
653
654 #[test]
655 fn fingerprint_is_stable_and_content_sensitive() {
656 assert_eq!(stable_fingerprint("hello"), "a430d84680aabd0b");
657 assert_ne!(stable_fingerprint("hello"), stable_fingerprint("Hello"));
658 }
659
660 #[test]
661 fn displayed_locations_use_portable_path_separators() {
662 let location = SourceLocation::new("steps\\checkout.ts", 3, 4, 3, 10);
663 assert_eq!(location.display(Path::new(".")), "steps/checkout.ts:3:4");
664 }
665}