mod behavior;
mod handler;
pub(crate) use handler::{bounded_source_snippet, HandlerSemantics};
mod sha256;
pub(crate) use behavior::{BehaviorEvent, BehaviorEventRef, ControlFlowOperation};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashSet};
use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use self::sha256::Sha256;
#[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 {
Literal,
CucumberExpression,
RegularExpression,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Framework {
CucumberRuby,
CucumberJs,
PlaywrightBdd,
CypressCucumber,
JestCucumber,
VitestCucumber,
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(),
}
}
}
const SINGLE_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:single\0";
const MULTIPART_FINGERPRINT_DOMAIN: &[u8] = b"cuke-dedup:fingerprint:v3:parts\0";
pub fn stable_fingerprint(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(SINGLE_FINGERPRINT_DOMAIN);
hasher.update(value.as_bytes());
truncated_sha256(hasher)
}
pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
let mut hasher = Sha256::new();
hasher.update(MULTIPART_FINGERPRINT_DOMAIN);
for part in parts {
let bytes = part.as_bytes();
let length = u64::try_from(bytes.len()).expect("fingerprint part length fits in u64");
hasher.update(&length.to_be_bytes());
hasher.update(bytes);
}
truncated_sha256(hasher)
}
fn truncated_sha256(hasher: Sha256) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut fingerprint = String::with_capacity(32);
for byte in &hasher.finalize()[..16] {
fingerprint.push(HEX[usize::from(byte >> 4)] as char);
fingerprint.push(HEX[usize::from(byte & 0x0f)] as char);
}
fingerprint
}
#[cfg(test)]
mod tests;