cuke-dedup 0.11.0

Static analysis for duplicate and reusable Cucumber step definitions
Documentation
//! Typed behavior events with a lossless legacy-signature boundary.

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BehaviorEvent {
    Method(String),
    Call(String),
    Assertion { deferred: bool, payload: String },
    ControlFlow(ControlFlowOperation),
    Legacy(String),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum ControlFlowOperation {
    If,
    Switch,
    For,
    ForIn,
    While,
    Do,
    Return,
    Throw,
}

impl ControlFlowOperation {
    fn from_legacy(value: &str) -> Option<Self> {
        Some(match value {
            "if_statement" => Self::If,
            "switch_statement" => Self::Switch,
            "for_statement" => Self::For,
            "for_in_statement" => Self::ForIn,
            "while_statement" => Self::While,
            "do_statement" => Self::Do,
            "return_statement" => Self::Return,
            "throw_statement" => Self::Throw,
            _ => return None,
        })
    }

    fn as_legacy(self) -> &'static str {
        match self {
            Self::If => "if_statement",
            Self::Switch => "switch_statement",
            Self::For => "for_statement",
            Self::ForIn => "for_in_statement",
            Self::While => "while_statement",
            Self::Do => "do_statement",
            Self::Return => "return_statement",
            Self::Throw => "throw_statement",
        }
    }
}

impl BehaviorEvent {
    pub(crate) fn assertion(qualifier: &str, matcher: &str, subject: &str, expected: &str) -> Self {
        Self::Assertion {
            deferred: false,
            payload: format!(
                "{qualifier}#{matcher}:{}:{}",
                super::stable_fingerprint(subject),
                super::stable_fingerprint(expected)
            ),
        }
    }

    pub(crate) fn unresolved_assertion(deferred: bool) -> Self {
        Self::Assertion {
            deferred,
            payload: "unresolved".to_owned(),
        }
    }

    pub(crate) fn deferred(self) -> Self {
        match self {
            Self::Assertion { payload, .. } => Self::Assertion {
                deferred: true,
                payload,
            },
            other => other,
        }
    }

    pub(crate) fn is_unresolved_assertion(&self) -> bool {
        matches!(self, Self::Assertion { payload, .. } if payload == "unresolved")
    }

    pub(crate) fn into_legacy(self) -> String {
        match self {
            Self::Method(payload) => format!("method:{payload}"),
            Self::Call(payload) => format!("call:{payload}"),
            Self::Assertion { deferred, payload } => {
                format!(
                    "{}{payload}",
                    if deferred {
                        "deferred-assert:"
                    } else {
                        "assert:"
                    }
                )
            }
            Self::ControlFlow(operation) => operation.as_legacy().to_owned(),
            Self::Legacy(value) => value,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum BehaviorEventRef<'a> {
    Method(&'a str),
    Call(&'a str),
    Assertion { deferred: bool, payload: &'a str },
    ControlFlow(ControlFlowOperation),
    Legacy(&'a str),
}

impl<'a> BehaviorEventRef<'a> {
    pub(crate) fn from_legacy(value: &'a str) -> Self {
        if let Some(payload) = value.strip_prefix("method:") {
            return Self::Method(payload);
        }
        if let Some(payload) = value.strip_prefix("call:") {
            return Self::Call(payload);
        }
        if let Some(payload) = value.strip_prefix("deferred-assert:") {
            return Self::Assertion {
                deferred: true,
                payload,
            };
        }
        if let Some(payload) = value.strip_prefix("assert:") {
            return Self::Assertion {
                deferred: false,
                payload,
            };
        }
        ControlFlowOperation::from_legacy(value)
            .map(Self::ControlFlow)
            .unwrap_or(Self::Legacy(value))
    }

    pub(crate) fn method_semantics(self) -> Option<&'a str> {
        match self {
            Self::Method(payload) => Some(payload),
            _ => None,
        }
    }

    pub(crate) fn is_method(self) -> bool {
        matches!(self, Self::Method(_))
    }

    pub(crate) fn is_deferred_assertion(self) -> bool {
        matches!(self, Self::Assertion { deferred: true, .. })
    }

    #[cfg(test)]
    fn into_legacy(self) -> String {
        match self {
            Self::Method(payload) => format!("method:{payload}"),
            Self::Call(payload) => format!("call:{payload}"),
            Self::Assertion { deferred, payload } => format!(
                "{}{payload}",
                if deferred {
                    "deferred-assert:"
                } else {
                    "assert:"
                }
            ),
            Self::ControlFlow(operation) => operation.as_legacy().to_owned(),
            Self::Legacy(value) => value.to_owned(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{BehaviorEvent, BehaviorEventRef, ControlFlowOperation};
    use crate::model::HandlerFingerprint;

    #[test]
    fn legacy_signatures_round_trip_known_and_unknown_events() {
        let signatures = [
            "method:instance sync",
            "method:ruby:lexical-file",
            "call:v0#click",
            "call:(subscript_expression:dynamic-key)",
            "assert:expect.not#toBe:subject:expected",
            "assert:unresolved",
            "deferred-assert:expect#toEqual:subject:expected",
            "deferred-assert:unresolved",
            "if_statement",
            "throw_statement",
            "deferred-call:opaque",
            "custom:event:with delimiters # and \0",
            "",
        ];

        for signature in signatures {
            assert_eq!(
                BehaviorEventRef::from_legacy(signature).into_legacy(),
                signature,
                "{signature:?}"
            );
        }
    }

    #[test]
    fn typed_classification_keeps_method_execution_and_assertion_anchors_distinct() {
        assert_eq!(
            BehaviorEventRef::from_legacy("method:async").method_semantics(),
            Some("async")
        );
        assert!(BehaviorEventRef::from_legacy("method:").is_method());
        assert!(!BehaviorEventRef::from_legacy("method").is_method());
        assert!(BehaviorEventRef::from_legacy("deferred-assert:unresolved").is_deferred_assertion());
        assert!(!BehaviorEventRef::from_legacy("assert:unresolved").is_deferred_assertion());
        assert_eq!(
            BehaviorEventRef::from_legacy("call:page#click"),
            BehaviorEventRef::Call("page#click")
        );
        assert_eq!(
            BehaviorEventRef::from_legacy("assert:expect#toBe:subject:ready"),
            BehaviorEventRef::Assertion {
                deferred: false,
                payload: "expect#toBe:subject:ready"
            }
        );
        assert_eq!(
            BehaviorEventRef::from_legacy("if_statement"),
            BehaviorEventRef::ControlFlow(ControlFlowOperation::If)
        );
        assert_eq!(
            BehaviorEventRef::from_legacy("if-statements"),
            BehaviorEventRef::Legacy("if-statements")
        );
    }

    #[test]
    fn deferring_and_unresolved_markers_preserve_assertion_payloads() {
        for signature in [
            "assert:expect#toBe:subject:expected",
            "assert:unresolved",
            "assert:",
        ] {
            let BehaviorEventRef::Assertion { payload, .. } =
                BehaviorEventRef::from_legacy(signature)
            else {
                unreachable!("test signatures are assertions");
            };
            let event = BehaviorEvent::Assertion {
                deferred: false,
                payload: payload.to_owned(),
            }
            .deferred();
            assert_eq!(
                event.into_legacy(),
                signature.replacen("assert:", "deferred-assert:", 1)
            );
        }
        assert!(BehaviorEvent::unresolved_assertion(false).is_unresolved_assertion());
        assert!(BehaviorEvent::unresolved_assertion(true).is_unresolved_assertion());
    }

    #[test]
    fn handler_fingerprint_keeps_the_public_string_array_shape() {
        let fingerprint = HandlerFingerprint {
            exact: String::new(),
            normalized: String::new(),
            alpha_normalized: String::new(),
            structural: String::new(),
            behavior_signature: vec![
                "method:instance sync".to_owned(),
                "assert:expect.not#toBe:subject:ready".to_owned(),
            ],
            source_snippet: String::new(),
            comparable: true,
            trivial: false,
        };
        let serialized = serde_json::to_string(&fingerprint).unwrap();
        assert_eq!(
            serialized,
            r#"{"exact":"","normalized":"","alpha_normalized":"","structural":"","behavior_signature":["method:instance sync","assert:expect.not#toBe:subject:ready"],"source_snippet":"","comparable":true,"trivial":false}"#
        );
        assert_eq!(
            serde_json::from_str::<HandlerFingerprint>(&serialized).unwrap(),
            fingerprint
        );
    }
}