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 CucumberExpression,
61 RegularExpression,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "kebab-case")]
67#[non_exhaustive]
69pub enum Framework {
70 CucumberJs,
72 PlaywrightBdd,
74 CypressCucumber,
76 Unknown,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct HandlerFingerprint {
83 pub exact: String,
85 pub normalized: String,
87 pub alpha_normalized: String,
89 pub structural: String,
91 pub behavior_signature: Vec<String>,
93 pub source_snippet: String,
95 #[serde(default)]
97 pub comparable: bool,
98 #[serde(default)]
99 pub trivial: bool,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct InlineSuppression {
107 pub rule: Rule,
109 pub reason: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct StepDefinition {
116 pub matcher: String,
118 pub normalized_matcher: String,
120 pub matcher_kind: MatcherKind,
122 #[serde(default, skip_serializing_if = "String::is_empty")]
123 pub matcher_flags: String,
125 pub handler: HandlerFingerprint,
127 pub framework: Framework,
129 pub registration: String,
131 pub location: SourceLocation,
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub inline_suppressions: Vec<InlineSuppression>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct FeatureStep {
141 pub keyword: String,
143 pub text: String,
145 pub location: SourceLocation,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
150#[serde(rename_all = "kebab-case")]
151#[non_exhaustive]
153pub enum Rule {
154 DuplicateMatcher,
156 NormalizedMatcher,
158 AmbiguousStep,
160 OverlappingMatcher,
162 DuplicateHandler,
164 NearDuplicateStep,
166 ParameterizationCandidate,
168 UnusedDefinition,
170}
171
172impl Rule {
173 const DOCUMENTATION_BASE_URL: &'static str =
174 "https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#";
175
176 pub const ALL: &'static [Rule] = &[
178 Rule::DuplicateMatcher,
179 Rule::NormalizedMatcher,
180 Rule::AmbiguousStep,
181 Rule::OverlappingMatcher,
182 Rule::DuplicateHandler,
183 Rule::NearDuplicateStep,
184 Rule::ParameterizationCandidate,
185 Rule::UnusedDefinition,
186 ];
187
188 pub const DUPLICATION_THRESHOLD: &'static [Rule] = &[
190 Rule::DuplicateMatcher,
191 Rule::NormalizedMatcher,
192 Rule::DuplicateHandler,
193 Rule::NearDuplicateStep,
194 Rule::ParameterizationCandidate,
195 ];
196
197 pub fn as_str(self) -> &'static str {
199 match self {
200 Rule::DuplicateMatcher => "duplicate-matcher",
201 Rule::NormalizedMatcher => "normalized-matcher",
202 Rule::AmbiguousStep => "ambiguous-step",
203 Rule::OverlappingMatcher => "overlapping-matcher",
204 Rule::DuplicateHandler => "duplicate-handler",
205 Rule::NearDuplicateStep => "near-duplicate-step",
206 Rule::ParameterizationCandidate => "parameterization-candidate",
207 Rule::UnusedDefinition => "unused-definition",
208 }
209 }
210
211 pub fn contributes_to_duplication_threshold(self) -> bool {
213 Self::DUPLICATION_THRESHOLD.contains(&self)
214 }
215
216 pub fn documentation_url(self) -> String {
218 format!("{}{}", Self::DOCUMENTATION_BASE_URL, self.as_str())
219 }
220}
221
222impl fmt::Display for Rule {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 f.write_str(self.as_str())
225 }
226}
227
228impl FromStr for Rule {
229 type Err = String;
230
231 fn from_str(value: &str) -> Result<Self, Self::Err> {
232 match value {
233 "duplicate-matcher" => Ok(Self::DuplicateMatcher),
234 "normalized-matcher" => Ok(Self::NormalizedMatcher),
235 "ambiguous-step" => Ok(Self::AmbiguousStep),
236 "overlapping-matcher" => Ok(Self::OverlappingMatcher),
237 "duplicate-handler" => Ok(Self::DuplicateHandler),
238 "near-duplicate-step" => Ok(Self::NearDuplicateStep),
239 "parameterization-candidate" => Ok(Self::ParameterizationCandidate),
240 "unused-definition" => Ok(Self::UnusedDefinition),
241 _ => Err(format!("unknown rule `{value}`")),
242 }
243 }
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
247#[serde(rename_all = "lowercase")]
248#[non_exhaustive]
250pub enum Severity {
251 Off,
253 Warning,
255 Error,
257}
258
259impl fmt::Display for Severity {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 match self {
262 Severity::Off => f.write_str("off"),
263 Severity::Warning => f.write_str("warning"),
264 Severity::Error => f.write_str("error"),
265 }
266 }
267}
268
269impl FromStr for Severity {
270 type Err = String;
271
272 fn from_str(value: &str) -> Result<Self, Self::Err> {
273 match value {
274 "off" => Ok(Severity::Off),
275 "warn" | "warning" => Ok(Severity::Warning),
276 "error" => Ok(Severity::Error),
277 _ => Err(format!(
278 "invalid severity `{value}` (expected off, warning, or error)"
279 )),
280 }
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase")]
286#[non_exhaustive]
288pub struct FindingEvidence {
289 pub matcher_similarity: Option<f64>,
291 pub handler_similarity: Option<f64>,
293 pub matcher_difference: String,
295 pub handler_evidence: String,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub comparison: Option<DefinitionComparison>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub cluster: Option<DefinitionCluster>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "camelCase")]
307#[non_exhaustive]
309pub struct DefinitionCluster {
310 pub member_count: usize,
312 pub definition_fingerprints: Vec<String>,
316 pub pair_findings_collapsed: usize,
318 pub members_truncated: bool,
320}
321
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase")]
324#[non_exhaustive]
326pub struct DefinitionComparison {
327 pub left_fingerprint: String,
329 pub right_fingerprint: String,
331 pub left_matcher: String,
333 pub right_matcher: String,
335 pub left_handler: String,
337 pub right_handler: String,
339 pub matcher_diff: MatcherDiff,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345#[non_exhaustive]
347pub struct MatcherDiff {
348 pub prefix: String,
350 pub left_change: String,
352 pub right_change: String,
354 pub suffix: String,
356}
357
358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360#[non_exhaustive]
362pub struct Suppression {
363 pub reason: String,
365}
366
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368#[non_exhaustive]
370pub struct Finding {
371 pub rule: Rule,
373 pub severity: Severity,
375 pub message: String,
377 pub primary: SourceLocation,
379 pub related: Vec<SourceLocation>,
381 pub evidence: FindingEvidence,
383 pub suggested_action: String,
385 pub suppression: Option<Suppression>,
387}
388
389impl Finding {
390 pub fn is_active(&self) -> bool {
392 self.severity != Severity::Off && self.suppression.is_none()
393 }
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize)]
397#[non_exhaustive]
399pub struct AnalysisResult {
400 pub definitions: Vec<StepDefinition>,
402 pub feature_steps: Vec<FeatureStep>,
404 pub findings: Vec<Finding>,
406}
407
408impl AnalysisResult {
409 pub fn new(
411 definitions: Vec<StepDefinition>,
412 feature_steps: Vec<FeatureStep>,
413 findings: Vec<Finding>,
414 ) -> Self {
415 Self {
416 definitions,
417 feature_steps,
418 findings,
419 }
420 }
421}
422
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424#[serde(rename_all = "camelCase")]
425#[non_exhaustive]
427pub struct DuplicationThreshold {
428 pub threshold: f64,
430 pub duplicated_definitions: usize,
432 pub total_definitions: usize,
434 pub percentage: f64,
436 pub passed: bool,
438 pub rules: Vec<Rule>,
440}
441
442impl DuplicationThreshold {
443 pub fn from_result(result: &AnalysisResult, threshold: f64) -> Self {
445 let definition_locations: HashSet<_> = result
446 .definitions
447 .iter()
448 .map(|definition| definition.location.clone())
449 .collect();
450 let mut duplicated_locations = HashSet::new();
451 let mut contributing_rules = BTreeSet::new();
452 for finding in result.findings.iter().filter(|finding| {
453 finding.is_active()
454 && finding.severity == Severity::Error
455 && finding.rule.contributes_to_duplication_threshold()
456 }) {
457 contributing_rules.insert(finding.rule);
458 for location in std::iter::once(&finding.primary).chain(&finding.related) {
459 if definition_locations.contains(location) {
460 duplicated_locations.insert(location.clone());
461 }
462 }
463 }
464
465 let total_definitions = result.definitions.len();
466 let duplicated_definitions = result
467 .definitions
468 .iter()
469 .filter(|definition| duplicated_locations.contains(&definition.location))
470 .count();
471 let percentage = if total_definitions == 0 {
472 0.0
473 } else {
474 duplicated_definitions as f64 / total_definitions as f64 * 100.0
475 };
476 Self {
477 threshold,
478 duplicated_definitions,
479 total_definitions,
480 percentage,
481 passed: threshold.is_finite() && percentage <= threshold,
482 rules: contributing_rules.into_iter().collect(),
483 }
484 }
485}
486
487const SINGLE_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:single\0";
488const MULTIPART_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:parts\0";
489
490pub fn stable_fingerprint(value: &str) -> String {
492 let mut hasher = Sha256::new();
493 hasher.update(SINGLE_FINGERPRINT_DOMAIN);
494 hasher.update(value.as_bytes());
495 truncated_sha256(hasher)
496}
497
498pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
499 let mut hasher = Sha256::new();
500 hasher.update(MULTIPART_FINGERPRINT_DOMAIN);
501 for part in parts {
502 let bytes = part.as_bytes();
503 let length = u64::try_from(bytes.len()).expect("fingerprint part length fits in u64");
504 hasher.update(&length.to_be_bytes());
505 hasher.update(bytes);
506 }
507 truncated_sha256(hasher)
508}
509
510fn truncated_sha256(hasher: Sha256) -> String {
511 const HEX: &[u8; 16] = b"0123456789abcdef";
512 let mut fingerprint = String::with_capacity(32);
513 for byte in &hasher.finalize()[..16] {
514 fingerprint.push(HEX[usize::from(byte >> 4)] as char);
515 fingerprint.push(HEX[usize::from(byte & 0x0f)] as char);
516 }
517 fingerprint
518}
519
520#[cfg(test)]
521mod tests;