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")]
52pub enum MatcherKind {
54 CucumberExpression,
56 RegularExpression,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "kebab-case")]
62pub enum Framework {
64 CucumberJs,
66 PlaywrightBdd,
68 CypressCucumber,
70 Unknown,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct HandlerFingerprint {
77 pub exact: String,
79 pub normalized: String,
81 pub alpha_normalized: String,
83 pub structural: String,
85 pub behavior_signature: Vec<String>,
87 pub source_snippet: String,
89 #[serde(default)]
91 pub comparable: bool,
92 #[serde(default)]
93 pub trivial: bool,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct InlineSuppression {
101 pub rule: Rule,
103 pub reason: String,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108pub struct StepDefinition {
110 pub matcher: String,
112 pub normalized_matcher: String,
114 pub matcher_kind: MatcherKind,
116 #[serde(default, skip_serializing_if = "String::is_empty")]
117 pub matcher_flags: String,
119 pub handler: HandlerFingerprint,
121 pub framework: Framework,
123 pub registration: String,
125 pub location: SourceLocation,
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub inline_suppressions: Vec<InlineSuppression>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct FeatureStep {
135 pub keyword: String,
137 pub text: String,
139 pub location: SourceLocation,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
144#[serde(rename_all = "kebab-case")]
145pub enum Rule {
147 DuplicateMatcher,
149 NormalizedMatcher,
151 AmbiguousStep,
153 DuplicateHandler,
155 NearDuplicateStep,
157 ParameterizationCandidate,
159 UnusedDefinition,
161}
162
163impl Rule {
164 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 pub const DUPLICATION_THRESHOLD: [Rule; 5] = [
177 Rule::DuplicateMatcher,
178 Rule::NormalizedMatcher,
179 Rule::DuplicateHandler,
180 Rule::NearDuplicateStep,
181 Rule::ParameterizationCandidate,
182 ];
183
184 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 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")]
228pub enum Severity {
230 Off,
232 Warning,
234 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")]
265pub struct FindingEvidence {
267 pub matcher_similarity: Option<f64>,
269 pub handler_similarity: Option<f64>,
271 pub matcher_difference: String,
273 pub handler_evidence: String,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub comparison: Option<DefinitionComparison>,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct DefinitionComparison {
284 pub left_fingerprint: String,
286 pub right_fingerprint: String,
288 pub left_matcher: String,
290 pub right_matcher: String,
292 pub left_handler: String,
294 pub right_handler: String,
296 pub matcher_diff: MatcherDiff,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct MatcherDiff {
304 pub prefix: String,
306 pub left_change: String,
308 pub right_change: String,
310 pub suffix: String,
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct Suppression {
318 pub reason: String,
320}
321
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323pub struct Finding {
325 pub rule: Rule,
327 pub severity: Severity,
329 pub message: String,
331 pub primary: SourceLocation,
333 pub related: Vec<SourceLocation>,
335 pub evidence: FindingEvidence,
337 pub suggested_action: String,
339 pub suppression: Option<Suppression>,
341}
342
343impl Finding {
344 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)]
351pub struct AnalysisResult {
353 pub definitions: Vec<StepDefinition>,
355 pub feature_steps: Vec<FeatureStep>,
357 pub findings: Vec<Finding>,
359}
360
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct DuplicationThreshold {
365 pub threshold: f64,
367 pub duplicated_definitions: usize,
369 pub total_definitions: usize,
371 pub percentage: f64,
373 pub passed: bool,
375 pub rules: Vec<Rule>,
377}
378
379impl DuplicationThreshold {
380 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
424pub fn stable_fingerprint(value: &str) -> String {
426 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}