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 {
238 f.write_str(self.as_str())
239 }
240}
241
242impl FromStr for Rule {
243 type Err = String;
244
245 fn from_str(value: &str) -> Result<Self, Self::Err> {
248 match value {
249 "duplicate-matcher" => Ok(Self::DuplicateMatcher),
250 "normalized-matcher" => Ok(Self::NormalizedMatcher),
251 "ambiguous-step" => Ok(Self::AmbiguousStep),
252 "overlapping-matcher" => Ok(Self::OverlappingMatcher),
253 "duplicate-handler" => Ok(Self::DuplicateHandler),
254 "near-duplicate-step" => Ok(Self::NearDuplicateStep),
255 "parameterization-candidate" => Ok(Self::ParameterizationCandidate),
256 "unused-definition" => Ok(Self::UnusedDefinition),
257 _ => Err(format!("unknown rule `{value}`")),
258 }
259 }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
263#[serde(rename_all = "lowercase")]
264#[non_exhaustive]
266pub enum Severity {
267 Off,
269 Warning,
271 Error,
273}
274
275impl fmt::Display for Severity {
276 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278 match self {
279 Severity::Off => f.write_str("off"),
280 Severity::Warning => f.write_str("warning"),
281 Severity::Error => f.write_str("error"),
282 }
283 }
284}
285
286impl FromStr for Severity {
287 type Err = String;
288
289 fn from_str(value: &str) -> Result<Self, Self::Err> {
292 match value {
293 "off" => Ok(Severity::Off),
294 "warn" | "warning" => Ok(Severity::Warning),
295 "error" => Ok(Severity::Error),
296 _ => Err(format!(
297 "invalid severity `{value}` (expected off, warning, or error)"
298 )),
299 }
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305#[non_exhaustive]
307pub struct FindingEvidence {
308 pub matcher_similarity: Option<f64>,
310 pub handler_similarity: Option<f64>,
312 pub matcher_difference: String,
314 pub handler_evidence: String,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub comparison: Option<DefinitionComparison>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub cluster: Option<DefinitionCluster>,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "camelCase")]
326#[non_exhaustive]
328pub struct DefinitionCluster {
329 pub member_count: usize,
331 pub definition_fingerprints: Vec<String>,
335 pub pair_findings_collapsed: usize,
337 pub members_truncated: bool,
339}
340
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343#[non_exhaustive]
345pub struct DefinitionComparison {
346 pub left_fingerprint: String,
348 pub right_fingerprint: String,
350 pub left_matcher: String,
352 pub right_matcher: String,
354 pub left_handler: String,
356 pub right_handler: String,
358 pub matcher_diff: MatcherDiff,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase")]
364#[non_exhaustive]
366pub struct MatcherDiff {
367 pub prefix: String,
369 pub left_change: String,
371 pub right_change: String,
373 pub suffix: String,
375}
376
377#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379#[non_exhaustive]
381pub struct Suppression {
382 pub reason: String,
384}
385
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387#[non_exhaustive]
389pub struct Finding {
390 pub rule: Rule,
392 pub severity: Severity,
394 pub message: String,
396 pub primary: SourceLocation,
398 pub related: Vec<SourceLocation>,
400 pub evidence: FindingEvidence,
402 pub suggested_action: String,
404 pub suppression: Option<Suppression>,
406}
407
408impl Finding {
409 pub fn is_active(&self) -> bool {
411 self.severity != Severity::Off && self.suppression.is_none()
412 }
413}
414
415#[derive(Debug, Clone, Default, Serialize, Deserialize)]
416#[non_exhaustive]
418pub struct AnalysisResult {
419 pub definitions: Vec<StepDefinition>,
421 pub feature_steps: Vec<FeatureStep>,
423 pub findings: Vec<Finding>,
425}
426
427impl AnalysisResult {
428 pub fn new(
430 definitions: Vec<StepDefinition>,
431 feature_steps: Vec<FeatureStep>,
432 findings: Vec<Finding>,
433 ) -> Self {
434 Self {
435 definitions,
436 feature_steps,
437 findings,
438 }
439 }
440}
441
442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
443#[serde(rename_all = "camelCase")]
444#[non_exhaustive]
446pub struct DuplicationThreshold {
447 pub threshold: f64,
449 pub duplicated_definitions: usize,
451 pub total_definitions: usize,
453 pub percentage: f64,
455 pub passed: bool,
457 pub rules: Vec<Rule>,
459}
460
461impl DuplicationThreshold {
462 pub fn from_result(result: &AnalysisResult, threshold: f64) -> Self {
464 let definition_locations: HashSet<_> = result
465 .definitions
466 .iter()
467 .map(|definition| definition.location.clone())
468 .collect();
469 let mut duplicated_locations = HashSet::new();
470 let mut contributing_rules = BTreeSet::new();
471 for finding in result.findings.iter().filter(|finding| {
472 finding.is_active()
473 && finding.severity == Severity::Error
474 && finding.rule.contributes_to_duplication_threshold()
475 }) {
476 contributing_rules.insert(finding.rule);
477 for location in std::iter::once(&finding.primary).chain(&finding.related) {
478 if definition_locations.contains(location) {
479 duplicated_locations.insert(location.clone());
480 }
481 }
482 }
483
484 let total_definitions = result.definitions.len();
485 let duplicated_definitions = result
486 .definitions
487 .iter()
488 .filter(|definition| duplicated_locations.contains(&definition.location))
489 .count();
490 let percentage = if total_definitions == 0 {
491 0.0
492 } else {
493 duplicated_definitions as f64 / total_definitions as f64 * 100.0
494 };
495 Self {
496 threshold,
497 duplicated_definitions,
498 total_definitions,
499 percentage,
500 passed: threshold.is_finite() && percentage <= threshold,
501 rules: contributing_rules.into_iter().collect(),
502 }
503 }
504}
505
506const SINGLE_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:single\0";
507const MULTIPART_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:parts\0";
508
509pub fn stable_fingerprint(value: &str) -> String {
511 let mut hasher = Sha256::new();
512 hasher.update(SINGLE_FINGERPRINT_DOMAIN);
513 hasher.update(value.as_bytes());
514 truncated_sha256(hasher)
515}
516
517pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
520 let mut hasher = Sha256::new();
521 hasher.update(MULTIPART_FINGERPRINT_DOMAIN);
522 for part in parts {
523 let bytes = part.as_bytes();
524 let length = u64::try_from(bytes.len()).expect("fingerprint part length fits in u64");
525 hasher.update(&length.to_be_bytes());
526 hasher.update(bytes);
527 }
528 truncated_sha256(hasher)
529}
530
531fn truncated_sha256(hasher: Sha256) -> String {
533 const HEX: &[u8; 16] = b"0123456789abcdef";
534 let mut fingerprint = String::with_capacity(32);
535 for byte in &hasher.finalize()[..16] {
536 fingerprint.push(HEX[usize::from(byte >> 4)] as char);
537 fingerprint.push(HEX[usize::from(byte & 0x0f)] as char);
538 }
539 fingerprint
540}
541
542#[cfg(test)]
543mod tests;