cuke-dedup 0.10.0

Static analysis for duplicate and reusable Cucumber step definitions
Documentation
use crate::model::{
    stable_fingerprint_parts, DefinitionComparison, MatcherDiff, MatcherKind, StepDefinition,
};

pub(super) fn matcher_difference(left: &StepDefinition, right: &StepDefinition) -> String {
    format!("`{}` ↔ `{}`", left.matcher, right.matcher)
}

pub(super) fn definition_comparison(
    left: &StepDefinition,
    right: &StepDefinition,
) -> DefinitionComparison {
    DefinitionComparison {
        left_fingerprint: definition_semantic_fingerprint(left),
        right_fingerprint: definition_semantic_fingerprint(right),
        left_matcher: left.matcher.clone(),
        right_matcher: right.matcher.clone(),
        left_handler: left.handler.source_snippet.clone(),
        right_handler: right.handler.source_snippet.clone(),
        matcher_diff: matcher_diff(&left.matcher, &right.matcher),
    }
}

fn definition_semantic_fingerprint(definition: &StepDefinition) -> String {
    let matcher_kind = match definition.matcher_kind {
        MatcherKind::Literal => "literal",
        MatcherKind::CucumberExpression => "cucumber-expression",
        MatcherKind::RegularExpression => "regular-expression",
    };
    stable_fingerprint_parts([
        "definition-semantic",
        matcher_kind,
        &definition.normalized_matcher,
        &definition.matcher_flags,
        &definition.handler.alpha_normalized,
    ])
}

fn matcher_diff(left: &str, right: &str) -> MatcherDiff {
    let left_chars: Vec<_> = left.chars().collect();
    let right_chars: Vec<_> = right.chars().collect();
    let prefix_len = left_chars
        .iter()
        .zip(&right_chars)
        .take_while(|(left, right)| left == right)
        .count();
    let max_suffix = left_chars
        .len()
        .saturating_sub(prefix_len)
        .min(right_chars.len().saturating_sub(prefix_len));
    let suffix_len = left_chars
        .iter()
        .rev()
        .zip(right_chars.iter().rev())
        .take(max_suffix)
        .take_while(|(left, right)| left == right)
        .count();

    MatcherDiff {
        prefix: left_chars[..prefix_len].iter().collect(),
        left_change: left_chars[prefix_len..left_chars.len() - suffix_len]
            .iter()
            .collect(),
        right_change: right_chars[prefix_len..right_chars.len() - suffix_len]
            .iter()
            .collect(),
        suffix: left_chars[left_chars.len() - suffix_len..].iter().collect(),
    }
}

pub(super) fn handler_evidence(left: &StepDefinition, right: &StepDefinition) -> String {
    if left.handler.exact == right.handler.exact {
        "Handlers have the same exact syntax fingerprint".to_owned()
    } else if left.handler.normalized == right.handler.normalized {
        "Handlers differ only in comments or formatting".to_owned()
    } else if left.handler.alpha_normalized == right.handler.alpha_normalized {
        "Handlers differ only in parameter or local-variable names".to_owned()
    } else if left.handler.structural == right.handler.structural {
        "Handlers share the same structure after literal normalization".to_owned()
    } else {
        "Handler behavior signatures differ".to_owned()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::discovery::{SourceFile, SourceLanguage};
    use std::path::PathBuf;

    fn definitions(source: &str) -> Vec<StepDefinition> {
        crate::typescript::extract(
            source,
            &SourceFile {
                path: PathBuf::from("steps.ts"),
                language: SourceLanguage::TypeScript,
            },
        )
        .unwrap()
    }

    #[test]
    fn matcher_diffs_cover_empty_identical_disjoint_and_unicode_inputs() {
        let cases = [
            ("empty", "", "", ("", "", "", "")),
            ("identical", "same", "same", ("same", "", "", "")),
            ("disjoint", "abc", "xyz", ("", "abc", "xyz", "")),
            (
                "shared suffix",
                "account enabled",
                "user enabled",
                ("", "account", "user", " enabled"),
            ),
            (
                "unicode scalar boundaries",
                "再生ボタン",
                "停止ボタン",
                ("", "再生", "停止", "ボタン"),
            ),
        ];

        for (name, left, right, expected) in cases {
            let diff = matcher_diff(left, right);
            assert_eq!(
                (
                    diff.prefix.as_str(),
                    diff.left_change.as_str(),
                    diff.right_change.as_str(),
                    diff.suffix.as_str(),
                ),
                expected,
                "{name}"
            );
        }
    }

    #[test]
    fn semantic_fingerprints_preserve_component_boundaries() {
        let mut accepted = definitions("Given('accepted', () => action());").remove(0);
        accepted.normalized_matcher = "a\0b".to_owned();
        let equivalent = accepted.clone();
        let mut unrelated = accepted.clone();
        unrelated.normalized_matcher = "a".to_owned();
        unrelated.matcher_flags = "b\0".to_owned();

        assert_eq!(
            definition_semantic_fingerprint(&accepted),
            definition_semantic_fingerprint(&equivalent)
        );
        assert_ne!(
            definition_semantic_fingerprint(&accepted),
            definition_semantic_fingerprint(&unrelated)
        );
    }

    #[test]
    fn pair_evidence_contract_includes_matchers_fingerprints_and_handler_classification() {
        let definitions = definitions(
            "Given('left matcher', () => first()); Given('right matcher', () => second());",
        );
        assert_eq!(
            matcher_difference(&definitions[0], &definitions[1]),
            "`left matcher` ↔ `right matcher`"
        );
        let comparison = definition_comparison(&definitions[0], &definitions[1]);
        assert_eq!(comparison.left_matcher, "left matcher");
        assert_eq!(comparison.right_matcher, "right matcher");
        assert_ne!(comparison.left_fingerprint, comparison.right_fingerprint);
        for fingerprint in [comparison.left_fingerprint, comparison.right_fingerprint] {
            assert_eq!(fingerprint.len(), 32);
            assert!(fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()));
        }

        let mut pair = definitions;
        let cases = [
            (
                "exact",
                "same",
                "same",
                "same",
                "same",
                "same",
                "same",
                "Handlers have the same exact syntax fingerprint",
            ),
            (
                "normalized",
                "left",
                "right",
                "same",
                "same",
                "same",
                "same",
                "Handlers differ only in comments or formatting",
            ),
            (
                "alpha",
                "left",
                "right",
                "left",
                "right",
                "same",
                "same",
                "Handlers differ only in parameter or local-variable names",
            ),
            (
                "structural",
                "left",
                "right",
                "left",
                "right",
                "left",
                "right",
                "Handlers share the same structure after literal normalization",
            ),
            (
                "different",
                "left",
                "right",
                "left",
                "right",
                "left",
                "right",
                "Handler behavior signatures differ",
            ),
        ];
        for (
            name,
            left_exact,
            right_exact,
            left_normalized,
            right_normalized,
            left_alpha,
            right_alpha,
            expected,
        ) in cases
        {
            pair[0].handler.exact = left_exact.to_owned();
            pair[1].handler.exact = right_exact.to_owned();
            pair[0].handler.normalized = left_normalized.to_owned();
            pair[1].handler.normalized = right_normalized.to_owned();
            pair[0].handler.alpha_normalized = left_alpha.to_owned();
            pair[1].handler.alpha_normalized = right_alpha.to_owned();
            pair[0].handler.structural =
                if name == "structural" { "same" } else { "left" }.to_owned();
            pair[1].handler.structural = if name == "structural" {
                "same"
            } else {
                "right"
            }
            .to_owned();
            assert_eq!(handler_evidence(&pair[0], &pair[1]), expected, "{name}");
        }
    }

    #[test]
    fn semantic_fingerprint_distinguishes_matcher_syntax() {
        let mut pair = definitions(
            "Given('same matcher', () => work()); Given(/same matcher/, () => work());",
        );
        pair[1].normalized_matcher = pair[0].normalized_matcher.clone();
        assert_ne!(
            definition_semantic_fingerprint(&pair[0]),
            definition_semantic_fingerprint(&pair[1])
        );
    }
}