use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashSet};
use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SourceLocation {
pub path: PathBuf,
pub line: usize,
pub column: usize,
pub end_line: usize,
pub end_column: usize,
}
impl SourceLocation {
pub fn new(
path: impl Into<PathBuf>,
line: usize,
column: usize,
end_line: usize,
end_column: usize,
) -> Self {
Self {
path: path.into(),
line,
column,
end_line,
end_column,
}
}
pub fn display(&self, root: &Path) -> String {
let path = self.path.strip_prefix(root).unwrap_or(&self.path);
let path = path.to_string_lossy().replace('\\', "/");
format!("{path}:{}:{}", self.line, self.column)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum MatcherKind {
CucumberExpression,
RegularExpression,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Framework {
CucumberJs,
PlaywrightBdd,
CypressCucumber,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HandlerFingerprint {
pub exact: String,
pub normalized: String,
pub alpha_normalized: String,
pub structural: String,
pub behavior_signature: Vec<String>,
pub source_snippet: String,
#[serde(default)]
pub comparable: bool,
#[serde(default)]
pub trivial: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InlineSuppression {
pub rule: Rule,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StepDefinition {
pub matcher: String,
pub normalized_matcher: String,
pub matcher_kind: MatcherKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub matcher_flags: String,
pub handler: HandlerFingerprint,
pub framework: Framework,
pub registration: String,
pub location: SourceLocation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inline_suppressions: Vec<InlineSuppression>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeatureStep {
pub keyword: String,
pub text: String,
pub location: SourceLocation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Rule {
DuplicateMatcher,
NormalizedMatcher,
AmbiguousStep,
OverlappingMatcher,
DuplicateHandler,
NearDuplicateStep,
ParameterizationCandidate,
UnusedDefinition,
}
impl Rule {
const DOCUMENTATION_BASE_URL: &'static str =
"https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#";
pub const ALL: &'static [Rule] = &[
Rule::DuplicateMatcher,
Rule::NormalizedMatcher,
Rule::AmbiguousStep,
Rule::OverlappingMatcher,
Rule::DuplicateHandler,
Rule::NearDuplicateStep,
Rule::ParameterizationCandidate,
Rule::UnusedDefinition,
];
pub const DUPLICATION_THRESHOLD: &'static [Rule] = &[
Rule::DuplicateMatcher,
Rule::NormalizedMatcher,
Rule::DuplicateHandler,
Rule::NearDuplicateStep,
Rule::ParameterizationCandidate,
];
pub fn as_str(self) -> &'static str {
match self {
Rule::DuplicateMatcher => "duplicate-matcher",
Rule::NormalizedMatcher => "normalized-matcher",
Rule::AmbiguousStep => "ambiguous-step",
Rule::OverlappingMatcher => "overlapping-matcher",
Rule::DuplicateHandler => "duplicate-handler",
Rule::NearDuplicateStep => "near-duplicate-step",
Rule::ParameterizationCandidate => "parameterization-candidate",
Rule::UnusedDefinition => "unused-definition",
}
}
pub fn contributes_to_duplication_threshold(self) -> bool {
Self::DUPLICATION_THRESHOLD.contains(&self)
}
pub fn documentation_url(self) -> String {
format!("{}{}", Self::DOCUMENTATION_BASE_URL, self.as_str())
}
}
impl fmt::Display for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Rule {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"duplicate-matcher" => Ok(Self::DuplicateMatcher),
"normalized-matcher" => Ok(Self::NormalizedMatcher),
"ambiguous-step" => Ok(Self::AmbiguousStep),
"overlapping-matcher" => Ok(Self::OverlappingMatcher),
"duplicate-handler" => Ok(Self::DuplicateHandler),
"near-duplicate-step" => Ok(Self::NearDuplicateStep),
"parameterization-candidate" => Ok(Self::ParameterizationCandidate),
"unused-definition" => Ok(Self::UnusedDefinition),
_ => Err(format!("unknown rule `{value}`")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Severity {
Off,
Warning,
Error,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Severity::Off => f.write_str("off"),
Severity::Warning => f.write_str("warning"),
Severity::Error => f.write_str("error"),
}
}
}
impl FromStr for Severity {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"off" => Ok(Severity::Off),
"warn" | "warning" => Ok(Severity::Warning),
"error" => Ok(Severity::Error),
_ => Err(format!(
"invalid severity `{value}` (expected off, warning, or error)"
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct FindingEvidence {
pub matcher_similarity: Option<f64>,
pub handler_similarity: Option<f64>,
pub matcher_difference: String,
pub handler_evidence: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comparison: Option<DefinitionComparison>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cluster: Option<DefinitionCluster>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DefinitionCluster {
pub member_count: usize,
pub definition_fingerprints: Vec<String>,
pub pair_findings_collapsed: usize,
pub members_truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DefinitionComparison {
pub left_fingerprint: String,
pub right_fingerprint: String,
pub left_matcher: String,
pub right_matcher: String,
pub left_handler: String,
pub right_handler: String,
pub matcher_diff: MatcherDiff,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct MatcherDiff {
pub prefix: String,
pub left_change: String,
pub right_change: String,
pub suffix: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Suppression {
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Finding {
pub rule: Rule,
pub severity: Severity,
pub message: String,
pub primary: SourceLocation,
pub related: Vec<SourceLocation>,
pub evidence: FindingEvidence,
pub suggested_action: String,
pub suppression: Option<Suppression>,
}
impl Finding {
pub fn is_active(&self) -> bool {
self.severity != Severity::Off && self.suppression.is_none()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AnalysisResult {
pub definitions: Vec<StepDefinition>,
pub feature_steps: Vec<FeatureStep>,
pub findings: Vec<Finding>,
}
impl AnalysisResult {
pub fn new(
definitions: Vec<StepDefinition>,
feature_steps: Vec<FeatureStep>,
findings: Vec<Finding>,
) -> Self {
Self {
definitions,
feature_steps,
findings,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DuplicationThreshold {
pub threshold: f64,
pub duplicated_definitions: usize,
pub total_definitions: usize,
pub percentage: f64,
pub passed: bool,
pub rules: Vec<Rule>,
}
impl DuplicationThreshold {
pub fn from_result(result: &AnalysisResult, threshold: f64) -> Self {
let definition_locations: HashSet<_> = result
.definitions
.iter()
.map(|definition| definition.location.clone())
.collect();
let mut duplicated_locations = HashSet::new();
let mut contributing_rules = BTreeSet::new();
for finding in result.findings.iter().filter(|finding| {
finding.is_active()
&& finding.severity == Severity::Error
&& finding.rule.contributes_to_duplication_threshold()
}) {
contributing_rules.insert(finding.rule);
for location in std::iter::once(&finding.primary).chain(&finding.related) {
if definition_locations.contains(location) {
duplicated_locations.insert(location.clone());
}
}
}
let total_definitions = result.definitions.len();
let duplicated_definitions = result
.definitions
.iter()
.filter(|definition| duplicated_locations.contains(&definition.location))
.count();
let percentage = if total_definitions == 0 {
0.0
} else {
duplicated_definitions as f64 / total_definitions as f64 * 100.0
};
Self {
threshold,
duplicated_definitions,
total_definitions,
percentage,
passed: threshold.is_finite() && percentage <= threshold,
rules: contributing_rules.into_iter().collect(),
}
}
}
pub fn stable_fingerprint(value: &str) -> String {
let mut hash = 0xcbf29ce484222325_u64;
for byte in value.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
let mut hash = 0xcbf29ce484222325_u64;
for (index, part) in parts.into_iter().enumerate() {
if index > 0 {
hash ^= 0;
hash = hash.wrapping_mul(0x100000001b3);
}
for byte in part.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
}
format!("{hash:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
fn definition(line: usize) -> StepDefinition {
StepDefinition {
matcher: format!("step {line}"),
normalized_matcher: format!("step {line}"),
matcher_kind: MatcherKind::CucumberExpression,
matcher_flags: String::new(),
handler: HandlerFingerprint {
exact: String::new(),
normalized: String::new(),
alpha_normalized: String::new(),
structural: String::new(),
behavior_signature: Vec::new(),
source_snippet: String::new(),
comparable: false,
trivial: true,
},
framework: Framework::Unknown,
registration: "Given".to_owned(),
location: SourceLocation::new("steps.ts", line, 1, line, 10),
inline_suppressions: Vec::new(),
}
}
fn finding(
definitions: &[StepDefinition],
left: usize,
right: usize,
severity: Severity,
) -> Finding {
Finding {
rule: Rule::DuplicateMatcher,
severity,
message: "duplicate".to_owned(),
primary: definitions[left].location.clone(),
related: vec![definitions[right].location.clone()],
evidence: FindingEvidence {
matcher_similarity: Some(1.0),
handler_similarity: None,
matcher_difference: String::new(),
handler_evidence: String::new(),
comparison: None,
cluster: None,
},
suggested_action: "consolidate".to_owned(),
suppression: None,
}
}
#[test]
fn rule_names_are_stable_kebab_case() {
for &rule in Rule::ALL {
assert_eq!(rule.to_string().parse::<Rule>(), Ok(rule));
assert_eq!(
rule.documentation_url(),
format!(
"https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#{}",
rule
)
);
}
}
#[test]
fn multipart_fingerprint_matches_nul_joined_input_without_join_allocation() {
assert_eq!(
stable_fingerprint_parts(["duplicate-matcher", "alpha", "bravo"]),
stable_fingerprint("duplicate-matcher\0alpha\0bravo")
);
}
#[test]
fn severity_text_forms_are_complete_and_reject_unknown_values() {
assert_eq!(Severity::Off.to_string(), "off");
assert_eq!(Severity::Warning.to_string(), "warning");
assert_eq!(Severity::Error.to_string(), "error");
assert_eq!("off".parse(), Ok(Severity::Off));
assert_eq!("warn".parse(), Ok(Severity::Warning));
assert_eq!("warning".parse(), Ok(Severity::Warning));
assert_eq!("error".parse(), Ok(Severity::Error));
assert_eq!(
"fatal".parse::<Severity>(),
Err("invalid severity `fatal` (expected off, warning, or error)".to_owned())
);
}
#[test]
fn threshold_rules_exclude_correctness_and_usage_findings() {
assert!(Rule::DuplicateMatcher.contributes_to_duplication_threshold());
assert!(Rule::ParameterizationCandidate.contributes_to_duplication_threshold());
assert!(!Rule::AmbiguousStep.contributes_to_duplication_threshold());
assert!(!Rule::UnusedDefinition.contributes_to_duplication_threshold());
}
#[test]
fn duplication_threshold_counts_unique_active_error_definitions() {
let definitions: Vec<_> = (1..=4).map(definition).collect();
let first = finding(&definitions, 0, 1, Severity::Error);
let second = finding(&definitions, 0, 2, Severity::Error);
let warning = finding(&definitions, 2, 3, Severity::Warning);
let mut suppressed = finding(&definitions, 1, 3, Severity::Error);
suppressed.suppression = Some(Suppression {
reason: "accepted".to_owned(),
});
let result = AnalysisResult {
definitions,
feature_steps: Vec::new(),
findings: vec![first, second, warning, suppressed],
};
let at_limit = DuplicationThreshold::from_result(&result, 75.0);
assert_eq!(at_limit.duplicated_definitions, 3);
assert_eq!(at_limit.total_definitions, 4);
assert_eq!(at_limit.percentage, 75.0);
assert_eq!(at_limit.rules, [Rule::DuplicateMatcher]);
assert!(at_limit.passed);
assert!(!DuplicationThreshold::from_result(&result, 74.99).passed);
}
#[test]
fn empty_analysis_has_zero_percent_duplication() {
let result = AnalysisResult::default();
let threshold = DuplicationThreshold::from_result(&result, 0.0);
assert_eq!(threshold.percentage, 0.0);
assert!(threshold.passed);
}
#[test]
fn duplication_threshold_counts_multiple_definitions_at_one_location() {
let mut definitions = vec![definition(1), definition(2)];
definitions[1].location = definitions[0].location.clone();
let duplicate = finding(&definitions, 0, 1, Severity::Error);
let result = AnalysisResult {
definitions,
feature_steps: Vec::new(),
findings: vec![duplicate],
};
let threshold = DuplicationThreshold::from_result(&result, 100.0);
assert_eq!(threshold.duplicated_definitions, 2);
assert_eq!(threshold.percentage, 100.0);
}
#[test]
fn fingerprint_is_stable_and_content_sensitive() {
assert_eq!(stable_fingerprint("hello"), "a430d84680aabd0b");
assert_ne!(stable_fingerprint("hello"), stable_fingerprint("Hello"));
}
#[test]
fn displayed_locations_use_portable_path_separators() {
let location = SourceLocation::new("steps\\checkout.ts", 3, 4, 3, 10);
assert_eq!(location.display(Path::new(".")), "steps/checkout.ts:3:4");
}
}