1mod behavior;
4mod handler;
5pub(crate) use handler::{bounded_source_snippet, HandlerSemantics};
6mod sha256;
7
8pub(crate) use behavior::{BehaviorEvent, BehaviorEventRef, ControlFlowOperation};
9
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeSet, HashSet};
12use std::fmt;
13use std::path::{Path, PathBuf};
14use std::str::FromStr;
15
16use self::sha256::Sha256;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct SourceLocation {
21 pub path: PathBuf,
23 pub line: usize,
25 pub column: usize,
27 pub end_line: usize,
29 pub end_column: usize,
31}
32
33impl SourceLocation {
34 pub fn new(
36 path: impl Into<PathBuf>,
37 line: usize,
38 column: usize,
39 end_line: usize,
40 end_column: usize,
41 ) -> Self {
42 Self {
43 path: path.into(),
44 line,
45 column,
46 end_line,
47 end_column,
48 }
49 }
50
51 pub fn display(&self, root: &Path) -> String {
53 let path = self.path.strip_prefix(root).unwrap_or(&self.path);
54 let path = path.to_string_lossy().replace('\\', "/");
55 format!("{path}:{}:{}", self.line, self.column)
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61#[non_exhaustive]
63pub enum MatcherKind {
64 Literal,
66 CucumberExpression,
68 RegularExpression,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
73#[serde(rename_all = "kebab-case")]
74#[non_exhaustive]
76pub enum Framework {
77 CucumberRuby,
79 CucumberJs,
81 PlaywrightBdd,
83 CypressCucumber,
85 JestCucumber,
87 VitestCucumber,
89 Unknown,
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct HandlerFingerprint {
96 pub exact: String,
98 pub normalized: String,
100 pub alpha_normalized: String,
102 pub structural: String,
104 pub behavior_signature: Vec<String>,
106 pub source_snippet: String,
108 #[serde(default)]
110 pub comparable: bool,
111 #[serde(default)]
112 pub trivial: bool,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct InlineSuppression {
120 pub rule: Rule,
122 pub reason: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct StepDefinition {
129 pub matcher: String,
131 pub normalized_matcher: String,
133 pub matcher_kind: MatcherKind,
135 #[serde(default, skip_serializing_if = "String::is_empty")]
136 pub matcher_flags: String,
138 pub handler: HandlerFingerprint,
140 pub framework: Framework,
142 pub registration: String,
144 pub location: SourceLocation,
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub inline_suppressions: Vec<InlineSuppression>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct FeatureStep {
154 pub keyword: String,
156 pub text: String,
158 pub location: SourceLocation,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
163#[serde(rename_all = "kebab-case")]
164#[non_exhaustive]
166pub enum Rule {
167 DuplicateMatcher,
169 NormalizedMatcher,
171 AmbiguousStep,
173 OverlappingMatcher,
175 DuplicateHandler,
177 NearDuplicateStep,
179 ParameterizationCandidate,
181 UnusedDefinition,
183}
184
185impl Rule {
186 const DOCUMENTATION_BASE_URL: &'static str =
187 "https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#";
188
189 pub const ALL: &'static [Rule] = &[
191 Rule::DuplicateMatcher,
192 Rule::NormalizedMatcher,
193 Rule::AmbiguousStep,
194 Rule::OverlappingMatcher,
195 Rule::DuplicateHandler,
196 Rule::NearDuplicateStep,
197 Rule::ParameterizationCandidate,
198 Rule::UnusedDefinition,
199 ];
200
201 pub const DUPLICATION_THRESHOLD: &'static [Rule] = &[
203 Rule::DuplicateMatcher,
204 Rule::NormalizedMatcher,
205 Rule::DuplicateHandler,
206 Rule::NearDuplicateStep,
207 Rule::ParameterizationCandidate,
208 ];
209
210 pub fn as_str(self) -> &'static str {
212 match self {
213 Rule::DuplicateMatcher => "duplicate-matcher",
214 Rule::NormalizedMatcher => "normalized-matcher",
215 Rule::AmbiguousStep => "ambiguous-step",
216 Rule::OverlappingMatcher => "overlapping-matcher",
217 Rule::DuplicateHandler => "duplicate-handler",
218 Rule::NearDuplicateStep => "near-duplicate-step",
219 Rule::ParameterizationCandidate => "parameterization-candidate",
220 Rule::UnusedDefinition => "unused-definition",
221 }
222 }
223
224 pub fn contributes_to_duplication_threshold(self) -> bool {
226 Self::DUPLICATION_THRESHOLD.contains(&self)
227 }
228
229 pub fn documentation_url(self) -> String {
231 format!("{}{}", Self::DOCUMENTATION_BASE_URL, self.as_str())
232 }
233}
234
235impl fmt::Display for Rule {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 f.write_str(self.as_str())
238 }
239}
240
241impl FromStr for Rule {
242 type Err = String;
243
244 fn from_str(value: &str) -> Result<Self, Self::Err> {
245 match value {
246 "duplicate-matcher" => Ok(Self::DuplicateMatcher),
247 "normalized-matcher" => Ok(Self::NormalizedMatcher),
248 "ambiguous-step" => Ok(Self::AmbiguousStep),
249 "overlapping-matcher" => Ok(Self::OverlappingMatcher),
250 "duplicate-handler" => Ok(Self::DuplicateHandler),
251 "near-duplicate-step" => Ok(Self::NearDuplicateStep),
252 "parameterization-candidate" => Ok(Self::ParameterizationCandidate),
253 "unused-definition" => Ok(Self::UnusedDefinition),
254 _ => Err(format!("unknown rule `{value}`")),
255 }
256 }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
260#[serde(rename_all = "lowercase")]
261#[non_exhaustive]
263pub enum Severity {
264 Off,
266 Warning,
268 Error,
270}
271
272impl fmt::Display for Severity {
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 match self {
275 Severity::Off => f.write_str("off"),
276 Severity::Warning => f.write_str("warning"),
277 Severity::Error => f.write_str("error"),
278 }
279 }
280}
281
282impl FromStr for Severity {
283 type Err = String;
284
285 fn from_str(value: &str) -> Result<Self, Self::Err> {
286 match value {
287 "off" => Ok(Severity::Off),
288 "warn" | "warning" => Ok(Severity::Warning),
289 "error" => Ok(Severity::Error),
290 _ => Err(format!(
291 "invalid severity `{value}` (expected off, warning, or error)"
292 )),
293 }
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299#[non_exhaustive]
301pub struct FindingEvidence {
302 pub matcher_similarity: Option<f64>,
304 pub handler_similarity: Option<f64>,
306 pub matcher_difference: String,
308 pub handler_evidence: String,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub comparison: Option<DefinitionComparison>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub cluster: Option<DefinitionCluster>,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320#[non_exhaustive]
322pub struct DefinitionCluster {
323 pub member_count: usize,
325 pub definition_fingerprints: Vec<String>,
329 pub pair_findings_collapsed: usize,
331 pub members_truncated: bool,
333}
334
335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase")]
337#[non_exhaustive]
339pub struct DefinitionComparison {
340 pub left_fingerprint: String,
342 pub right_fingerprint: String,
344 pub left_matcher: String,
346 pub right_matcher: String,
348 pub left_handler: String,
350 pub right_handler: String,
352 pub matcher_diff: MatcherDiff,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase")]
358#[non_exhaustive]
360pub struct MatcherDiff {
361 pub prefix: String,
363 pub left_change: String,
365 pub right_change: String,
367 pub suffix: String,
369}
370
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373#[non_exhaustive]
375pub struct Suppression {
376 pub reason: String,
378}
379
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381#[non_exhaustive]
383pub struct Finding {
384 pub rule: Rule,
386 pub severity: Severity,
388 pub message: String,
390 pub primary: SourceLocation,
392 pub related: Vec<SourceLocation>,
394 pub evidence: FindingEvidence,
396 pub suggested_action: String,
398 pub suppression: Option<Suppression>,
400}
401
402impl Finding {
403 pub fn is_active(&self) -> bool {
405 self.severity != Severity::Off && self.suppression.is_none()
406 }
407}
408
409#[derive(Debug, Clone, Default, Serialize, Deserialize)]
410#[non_exhaustive]
412pub struct AnalysisResult {
413 pub definitions: Vec<StepDefinition>,
415 pub feature_steps: Vec<FeatureStep>,
417 pub findings: Vec<Finding>,
419}
420
421impl AnalysisResult {
422 pub fn new(
424 definitions: Vec<StepDefinition>,
425 feature_steps: Vec<FeatureStep>,
426 findings: Vec<Finding>,
427 ) -> Self {
428 Self {
429 definitions,
430 feature_steps,
431 findings,
432 }
433 }
434}
435
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase")]
438#[non_exhaustive]
440pub struct DuplicationThreshold {
441 pub threshold: f64,
443 pub duplicated_definitions: usize,
445 pub total_definitions: usize,
447 pub percentage: f64,
449 pub passed: bool,
451 pub rules: Vec<Rule>,
453}
454
455impl DuplicationThreshold {
456 pub fn from_result(result: &AnalysisResult, threshold: f64) -> Self {
458 let definition_locations: HashSet<_> = result
459 .definitions
460 .iter()
461 .map(|definition| definition.location.clone())
462 .collect();
463 let mut duplicated_locations = HashSet::new();
464 let mut contributing_rules = BTreeSet::new();
465 for finding in result.findings.iter().filter(|finding| {
466 finding.is_active()
467 && finding.severity == Severity::Error
468 && finding.rule.contributes_to_duplication_threshold()
469 }) {
470 contributing_rules.insert(finding.rule);
471 for location in std::iter::once(&finding.primary).chain(&finding.related) {
472 if definition_locations.contains(location) {
473 duplicated_locations.insert(location.clone());
474 }
475 }
476 }
477
478 let total_definitions = result.definitions.len();
479 let duplicated_definitions = result
480 .definitions
481 .iter()
482 .filter(|definition| duplicated_locations.contains(&definition.location))
483 .count();
484 let percentage = if total_definitions == 0 {
485 0.0
486 } else {
487 duplicated_definitions as f64 / total_definitions as f64 * 100.0
488 };
489 Self {
490 threshold,
491 duplicated_definitions,
492 total_definitions,
493 percentage,
494 passed: threshold.is_finite() && percentage <= threshold,
495 rules: contributing_rules.into_iter().collect(),
496 }
497 }
498}
499
500const SINGLE_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:single\0";
501const MULTIPART_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:parts\0";
502
503pub fn stable_fingerprint(value: &str) -> String {
505 let mut hasher = Sha256::new();
506 hasher.update(SINGLE_FINGERPRINT_DOMAIN);
507 hasher.update(value.as_bytes());
508 truncated_sha256(hasher)
509}
510
511pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
512 let mut hasher = Sha256::new();
513 hasher.update(MULTIPART_FINGERPRINT_DOMAIN);
514 for part in parts {
515 let bytes = part.as_bytes();
516 let length = u64::try_from(bytes.len()).expect("fingerprint part length fits in u64");
517 hasher.update(&length.to_be_bytes());
518 hasher.update(bytes);
519 }
520 truncated_sha256(hasher)
521}
522
523fn truncated_sha256(hasher: Sha256) -> String {
524 const HEX: &[u8; 16] = b"0123456789abcdef";
525 let mut fingerprint = String::with_capacity(32);
526 for byte in &hasher.finalize()[..16] {
527 fingerprint.push(HEX[usize::from(byte >> 4)] as char);
528 fingerprint.push(HEX[usize::from(byte & 0x0f)] as char);
529 }
530 fingerprint
531}
532
533#[cfg(test)]
534mod tests;