1mod 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)]
14pub struct SourceLocation {
16 pub path: PathBuf,
18 pub line: usize,
20 pub column: usize,
22 pub end_line: usize,
24 pub end_column: usize,
26}
27
28impl SourceLocation {
29 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 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#[non_exhaustive]
58pub enum MatcherKind {
59 Literal,
61 CucumberExpression,
63 RegularExpression,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case")]
69#[non_exhaustive]
71pub enum Framework {
72 CucumberJs,
74 PlaywrightBdd,
76 CypressCucumber,
78 JestCucumber,
80 VitestCucumber,
82 Unknown,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct HandlerFingerprint {
89 pub exact: String,
91 pub normalized: String,
93 pub alpha_normalized: String,
95 pub structural: String,
97 pub behavior_signature: Vec<String>,
99 pub source_snippet: String,
101 #[serde(default)]
103 pub comparable: bool,
104 #[serde(default)]
105 pub trivial: bool,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct InlineSuppression {
113 pub rule: Rule,
115 pub reason: String,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct StepDefinition {
122 pub matcher: String,
124 pub normalized_matcher: String,
126 pub matcher_kind: MatcherKind,
128 #[serde(default, skip_serializing_if = "String::is_empty")]
129 pub matcher_flags: String,
131 pub handler: HandlerFingerprint,
133 pub framework: Framework,
135 pub registration: String,
137 pub location: SourceLocation,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub inline_suppressions: Vec<InlineSuppression>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct FeatureStep {
147 pub keyword: String,
149 pub text: String,
151 pub location: SourceLocation,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
156#[serde(rename_all = "kebab-case")]
157#[non_exhaustive]
159pub enum Rule {
160 DuplicateMatcher,
162 NormalizedMatcher,
164 AmbiguousStep,
166 OverlappingMatcher,
168 DuplicateHandler,
170 NearDuplicateStep,
172 ParameterizationCandidate,
174 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 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 pub const DUPLICATION_THRESHOLD: &'static [Rule] = &[
196 Rule::DuplicateMatcher,
197 Rule::NormalizedMatcher,
198 Rule::DuplicateHandler,
199 Rule::NearDuplicateStep,
200 Rule::ParameterizationCandidate,
201 ];
202
203 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 pub fn contributes_to_duplication_threshold(self) -> bool {
219 Self::DUPLICATION_THRESHOLD.contains(&self)
220 }
221
222 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#[non_exhaustive]
256pub enum Severity {
257 Off,
259 Warning,
261 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#[non_exhaustive]
294pub struct FindingEvidence {
295 pub matcher_similarity: Option<f64>,
297 pub handler_similarity: Option<f64>,
299 pub matcher_difference: String,
301 pub handler_evidence: String,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub comparison: Option<DefinitionComparison>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub cluster: Option<DefinitionCluster>,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313#[non_exhaustive]
315pub struct DefinitionCluster {
316 pub member_count: usize,
318 pub definition_fingerprints: Vec<String>,
322 pub pair_findings_collapsed: usize,
324 pub members_truncated: bool,
326}
327
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330#[non_exhaustive]
332pub struct DefinitionComparison {
333 pub left_fingerprint: String,
335 pub right_fingerprint: String,
337 pub left_matcher: String,
339 pub right_matcher: String,
341 pub left_handler: String,
343 pub right_handler: String,
345 pub matcher_diff: MatcherDiff,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351#[non_exhaustive]
353pub struct MatcherDiff {
354 pub prefix: String,
356 pub left_change: String,
358 pub right_change: String,
360 pub suffix: String,
362}
363
364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366#[non_exhaustive]
368pub struct Suppression {
369 pub reason: String,
371}
372
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374#[non_exhaustive]
376pub struct Finding {
377 pub rule: Rule,
379 pub severity: Severity,
381 pub message: String,
383 pub primary: SourceLocation,
385 pub related: Vec<SourceLocation>,
387 pub evidence: FindingEvidence,
389 pub suggested_action: String,
391 pub suppression: Option<Suppression>,
393}
394
395impl Finding {
396 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#[non_exhaustive]
405pub struct AnalysisResult {
406 pub definitions: Vec<StepDefinition>,
408 pub feature_steps: Vec<FeatureStep>,
410 pub findings: Vec<Finding>,
412}
413
414impl AnalysisResult {
415 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#[non_exhaustive]
433pub struct DuplicationThreshold {
434 pub threshold: f64,
436 pub duplicated_definitions: usize,
438 pub total_definitions: usize,
440 pub percentage: f64,
442 pub passed: bool,
444 pub rules: Vec<Rule>,
446}
447
448impl DuplicationThreshold {
449 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
496pub 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;