Skip to main content

cuke_dedup/
model.rs

1//! Shared intermediate representation and diagnostics.
2
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeSet, HashSet};
5use std::fmt;
6use std::path::{Path, PathBuf};
7use std::str::FromStr;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10/// Source span with one-based starts and one-based, exclusive end coordinates.
11pub struct SourceLocation {
12    /// Absolute or caller-provided source path.
13    pub path: PathBuf,
14    /// One-based start line.
15    pub line: usize,
16    /// One-based start column.
17    pub column: usize,
18    /// One-based line containing the exclusive end position.
19    pub end_line: usize,
20    /// One-based, exclusive end column in the original source text.
21    pub end_column: usize,
22}
23
24impl SourceLocation {
25    /// Creates a source location from a path and one-based span coordinates.
26    pub fn new(
27        path: impl Into<PathBuf>,
28        line: usize,
29        column: usize,
30        end_line: usize,
31        end_column: usize,
32    ) -> Self {
33        Self {
34            path: path.into(),
35            line,
36            column,
37            end_line,
38            end_column,
39        }
40    }
41
42    /// Formats the location as `path:line:column`, relative to `root` when possible.
43    pub fn display(&self, root: &Path) -> String {
44        let path = self.path.strip_prefix(root).unwrap_or(&self.path);
45        let path = path.to_string_lossy().replace('\\', "/");
46        format!("{path}:{}:{}", self.line, self.column)
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52/// Syntax used by a step definition matcher.
53#[non_exhaustive]
54pub enum MatcherKind {
55    /// A Cucumber Expression such as `I have {int} items`.
56    CucumberExpression,
57    /// A JavaScript regular expression.
58    RegularExpression,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63/// Step-definition framework inferred from a source file.
64#[non_exhaustive]
65pub enum Framework {
66    /// Cucumber.js registration APIs.
67    CucumberJs,
68    /// Playwright BDD registration APIs.
69    PlaywrightBdd,
70    /// Cypress step definitions registered through the Badeball Cucumber preprocessor.
71    CypressCucumber,
72    /// No supported framework import could be inferred.
73    Unknown,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77/// Comparable representations of a definition handler.
78pub struct HandlerFingerprint {
79    /// Exact syntax-tree representation.
80    pub exact: String,
81    /// Representation normalized for superficial syntax differences.
82    pub normalized: String,
83    /// Representation with parameter and local identifiers normalized.
84    pub alpha_normalized: String,
85    /// Control-flow and call structure with literals normalized.
86    pub structural: String,
87    /// Ordered behavioral operations observed in the handler.
88    pub behavior_signature: Vec<String>,
89    /// Bounded original source displayed in reports.
90    pub source_snippet: String,
91    /// Whether a concrete handler body was available for comparison.
92    #[serde(default)]
93    pub comparable: bool,
94    #[serde(default)]
95    /// Whether the handler lacks enough behavior for duplication analysis.
96    pub trivial: bool,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101/// Rule-scoped, reason-bearing suppression declared beside a definition.
102pub struct InlineSuppression {
103    /// Rule disabled for findings involving this definition.
104    pub rule: Rule,
105    /// Human-readable justification retained in reports.
106    pub reason: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110/// A step definition extracted into the analyzer's shared representation.
111pub struct StepDefinition {
112    /// Original matcher text without JavaScript delimiters.
113    pub matcher: String,
114    /// Canonical matcher used for equivalence checks.
115    pub normalized_matcher: String,
116    /// Matcher syntax.
117    pub matcher_kind: MatcherKind,
118    #[serde(default, skip_serializing_if = "String::is_empty")]
119    /// JavaScript regular-expression flags, or an empty string otherwise.
120    pub matcher_flags: String,
121    /// Comparable handler representations.
122    pub handler: HandlerFingerprint,
123    /// Framework that registered the definition.
124    pub framework: Framework,
125    /// Registration name resolved from direct, aliased, or namespaced usage.
126    pub registration: String,
127    /// Full registration call location.
128    pub location: SourceLocation,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    /// Source-local suppression directives attached to this definition.
131    pub inline_suppressions: Vec<InlineSuppression>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135/// One concrete step from a parsed Gherkin scenario.
136pub struct FeatureStep {
137    /// Localized Gherkin keyword.
138    pub keyword: String,
139    /// Concrete step text after Scenario Outline expansion.
140    pub text: String,
141    /// Source location of the originating step.
142    pub location: SourceLocation,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
146#[serde(rename_all = "kebab-case")]
147/// Stable identifier for an analyzer rule.
148#[non_exhaustive]
149pub enum Rule {
150    /// Two definitions have the same effective matcher.
151    DuplicateMatcher,
152    /// Two matchers become equivalent after normalization.
153    NormalizedMatcher,
154    /// A concrete feature step matches multiple definitions.
155    AmbiguousStep,
156    /// Two matchers can accept the same step text, independent of the feature corpus.
157    OverlappingMatcher,
158    /// Distinct matchers share an equivalent implementation.
159    DuplicateHandler,
160    /// Similar wording and handler structure indicate likely duplication.
161    NearDuplicateStep,
162    /// Similar definitions may be replaceable by a parameterized step.
163    ParameterizationCandidate,
164    /// No discovered feature step uses a definition.
165    UnusedDefinition,
166}
167
168impl Rule {
169    const DOCUMENTATION_BASE_URL: &'static str =
170        "https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#";
171
172    /// Every rule in deterministic report order.
173    pub const ALL: &'static [Rule] = &[
174        Rule::DuplicateMatcher,
175        Rule::NormalizedMatcher,
176        Rule::AmbiguousStep,
177        Rule::OverlappingMatcher,
178        Rule::DuplicateHandler,
179        Rule::NearDuplicateStep,
180        Rule::ParameterizationCandidate,
181        Rule::UnusedDefinition,
182    ];
183
184    /// Rules whose active error findings contribute to the duplication threshold.
185    pub const DUPLICATION_THRESHOLD: &'static [Rule] = &[
186        Rule::DuplicateMatcher,
187        Rule::NormalizedMatcher,
188        Rule::DuplicateHandler,
189        Rule::NearDuplicateStep,
190        Rule::ParameterizationCandidate,
191    ];
192
193    /// Returns the stable kebab-case configuration and report name.
194    pub fn as_str(self) -> &'static str {
195        match self {
196            Rule::DuplicateMatcher => "duplicate-matcher",
197            Rule::NormalizedMatcher => "normalized-matcher",
198            Rule::AmbiguousStep => "ambiguous-step",
199            Rule::OverlappingMatcher => "overlapping-matcher",
200            Rule::DuplicateHandler => "duplicate-handler",
201            Rule::NearDuplicateStep => "near-duplicate-step",
202            Rule::ParameterizationCandidate => "parameterization-candidate",
203            Rule::UnusedDefinition => "unused-definition",
204        }
205    }
206
207    /// Returns whether error findings from this rule contribute to the duplication threshold.
208    pub fn contributes_to_duplication_threshold(self) -> bool {
209        Self::DUPLICATION_THRESHOLD.contains(&self)
210    }
211
212    /// Returns the rule-specific documentation URL used by diagnostics integrations.
213    pub fn documentation_url(self) -> String {
214        format!("{}{}", Self::DOCUMENTATION_BASE_URL, self.as_str())
215    }
216}
217
218impl fmt::Display for Rule {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        f.write_str(self.as_str())
221    }
222}
223
224impl FromStr for Rule {
225    type Err = String;
226
227    fn from_str(value: &str) -> Result<Self, Self::Err> {
228        match value {
229            "duplicate-matcher" => Ok(Self::DuplicateMatcher),
230            "normalized-matcher" => Ok(Self::NormalizedMatcher),
231            "ambiguous-step" => Ok(Self::AmbiguousStep),
232            "overlapping-matcher" => Ok(Self::OverlappingMatcher),
233            "duplicate-handler" => Ok(Self::DuplicateHandler),
234            "near-duplicate-step" => Ok(Self::NearDuplicateStep),
235            "parameterization-candidate" => Ok(Self::ParameterizationCandidate),
236            "unused-definition" => Ok(Self::UnusedDefinition),
237            _ => Err(format!("unknown rule `{value}`")),
238        }
239    }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
243#[serde(rename_all = "lowercase")]
244/// Configured impact of a finding.
245#[non_exhaustive]
246pub enum Severity {
247    /// Rule evaluation is disabled.
248    Off,
249    /// Finding is reported without causing exit code 1.
250    Warning,
251    /// Active finding causes exit code 1.
252    Error,
253}
254
255impl fmt::Display for Severity {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        match self {
258            Severity::Off => f.write_str("off"),
259            Severity::Warning => f.write_str("warning"),
260            Severity::Error => f.write_str("error"),
261        }
262    }
263}
264
265impl FromStr for Severity {
266    type Err = String;
267
268    fn from_str(value: &str) -> Result<Self, Self::Err> {
269        match value {
270            "off" => Ok(Severity::Off),
271            "warn" | "warning" => Ok(Severity::Warning),
272            "error" => Ok(Severity::Error),
273            _ => Err(format!(
274                "invalid severity `{value}` (expected off, warning, or error)"
275            )),
276        }
277    }
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282/// Human- and machine-readable evidence supporting a finding.
283#[non_exhaustive]
284pub struct FindingEvidence {
285    /// Matcher similarity score from zero to one, when applicable.
286    pub matcher_similarity: Option<f64>,
287    /// Handler similarity score from zero to one, when applicable.
288    pub handler_similarity: Option<f64>,
289    /// Concise matcher comparison for terminal output.
290    pub matcher_difference: String,
291    /// Concise handler comparison for terminal output.
292    pub handler_evidence: String,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    /// Structured definition comparison for rich reports.
295    pub comparison: Option<DefinitionComparison>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    /// Connected definition group when a large set of pair findings is collapsed.
298    pub cluster: Option<DefinitionCluster>,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase")]
303/// Stable semantic identity and size information for a clustered finding.
304#[non_exhaustive]
305pub struct DefinitionCluster {
306    /// Total number of definitions represented by the cluster.
307    pub member_count: usize,
308    /// Location-independent fingerprints for every definition in the cluster.
309    ///
310    /// Reporters may retain only a bounded prefix and set [`Self::members_truncated`].
311    pub definition_fingerprints: Vec<String>,
312    /// Number of pair findings represented by the single cluster finding.
313    pub pair_findings_collapsed: usize,
314    /// Whether a reporter omitted members from its serialized representation.
315    pub members_truncated: bool,
316}
317
318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320/// Side-by-side matcher and handler content for a pair finding.
321#[non_exhaustive]
322pub struct DefinitionComparison {
323    /// Location-independent semantic fingerprint of the left definition.
324    pub left_fingerprint: String,
325    /// Location-independent semantic fingerprint of the right definition.
326    pub right_fingerprint: String,
327    /// Left definition's original matcher.
328    pub left_matcher: String,
329    /// Right definition's original matcher.
330    pub right_matcher: String,
331    /// Left definition's bounded handler source.
332    pub left_handler: String,
333    /// Right definition's bounded handler source.
334    pub right_handler: String,
335    /// Structured common and changed matcher segments.
336    pub matcher_diff: MatcherDiff,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "camelCase")]
341/// Common and differing segments of two matcher strings.
342#[non_exhaustive]
343pub struct MatcherDiff {
344    /// Shared matcher prefix.
345    pub prefix: String,
346    /// Segment unique to the left matcher.
347    pub left_change: String,
348    /// Segment unique to the right matcher.
349    pub right_change: String,
350    /// Shared matcher suffix.
351    pub suffix: String,
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase")]
356/// Reason that a finding is excluded from active totals.
357#[non_exhaustive]
358pub struct Suppression {
359    /// Human-readable suppression justification.
360    pub reason: String,
361}
362
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
364/// One rule diagnostic produced by analysis.
365#[non_exhaustive]
366pub struct Finding {
367    /// Rule that produced the finding.
368    pub rule: Rule,
369    /// Configured impact.
370    pub severity: Severity,
371    /// Human-readable diagnostic message.
372    pub message: String,
373    /// Main source span.
374    pub primary: SourceLocation,
375    /// Additional spans involved in the finding.
376    pub related: Vec<SourceLocation>,
377    /// Comparison data supporting the diagnostic.
378    pub evidence: FindingEvidence,
379    /// Recommended remediation.
380    pub suggested_action: String,
381    /// Suppression metadata, when excluded from active totals.
382    pub suppression: Option<Suppression>,
383}
384
385impl Finding {
386    /// Returns whether the finding contributes to output totals and exit status.
387    pub fn is_active(&self) -> bool {
388        self.severity != Severity::Off && self.suppression.is_none()
389    }
390}
391
392#[derive(Debug, Clone, Default, Serialize, Deserialize)]
393/// Extracted inputs and deterministically ordered analyzer findings.
394#[non_exhaustive]
395pub struct AnalysisResult {
396    /// Extracted definition corpus.
397    pub definitions: Vec<StepDefinition>,
398    /// Concrete feature-step corpus.
399    pub feature_steps: Vec<FeatureStep>,
400    /// Findings produced by configured rules.
401    pub findings: Vec<Finding>,
402}
403
404impl AnalysisResult {
405    /// Creates a result from extracted inputs and analyzer findings.
406    pub fn new(
407        definitions: Vec<StepDefinition>,
408        feature_steps: Vec<FeatureStep>,
409        findings: Vec<Finding>,
410    ) -> Self {
411        Self {
412            definitions,
413            feature_steps,
414            findings,
415        }
416    }
417}
418
419#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421/// Result of applying a percentage-based tolerance to active duplication errors.
422#[non_exhaustive]
423pub struct DuplicationThreshold {
424    /// Maximum duplicated-definition percentage allowed by configuration.
425    pub threshold: f64,
426    /// Unique definitions involved in active error-level duplication findings.
427    pub duplicated_definitions: usize,
428    /// Total number of definitions discovered for the analysis.
429    pub total_definitions: usize,
430    /// Unrounded duplicated-definition percentage.
431    pub percentage: f64,
432    /// Whether the percentage is less than or equal to the configured threshold.
433    pub passed: bool,
434    /// Rules whose error-level findings contribute to the numerator.
435    pub rules: Vec<Rule>,
436}
437
438impl DuplicationThreshold {
439    /// Calculates the duplication rate after suppressions and analysis modes are applied.
440    pub fn from_result(result: &AnalysisResult, threshold: f64) -> Self {
441        let definition_locations: HashSet<_> = result
442            .definitions
443            .iter()
444            .map(|definition| definition.location.clone())
445            .collect();
446        let mut duplicated_locations = HashSet::new();
447        let mut contributing_rules = BTreeSet::new();
448        for finding in result.findings.iter().filter(|finding| {
449            finding.is_active()
450                && finding.severity == Severity::Error
451                && finding.rule.contributes_to_duplication_threshold()
452        }) {
453            contributing_rules.insert(finding.rule);
454            for location in std::iter::once(&finding.primary).chain(&finding.related) {
455                if definition_locations.contains(location) {
456                    duplicated_locations.insert(location.clone());
457                }
458            }
459        }
460
461        let total_definitions = result.definitions.len();
462        let duplicated_definitions = result
463            .definitions
464            .iter()
465            .filter(|definition| duplicated_locations.contains(&definition.location))
466            .count();
467        let percentage = if total_definitions == 0 {
468            0.0
469        } else {
470            duplicated_definitions as f64 / total_definitions as f64 * 100.0
471        };
472        Self {
473            threshold,
474            duplicated_definitions,
475            total_definitions,
476            percentage,
477            passed: threshold.is_finite() && percentage <= threshold,
478            rules: contributing_rules.into_iter().collect(),
479        }
480    }
481}
482
483/// Computes a deterministic FNV-1a fingerprint for report and comparison data.
484pub fn stable_fingerprint(value: &str) -> String {
485    // FNV-1a is deliberately simple and deterministic across platforms and Rust versions.
486    let mut hash = 0xcbf29ce484222325_u64;
487    for byte in value.as_bytes() {
488        hash ^= u64::from(*byte);
489        hash = hash.wrapping_mul(0x100000001b3);
490    }
491    format!("{hash:016x}")
492}
493
494pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
495    let mut hash = 0xcbf29ce484222325_u64;
496    for (index, part) in parts.into_iter().enumerate() {
497        if index > 0 {
498            hash ^= 0;
499            hash = hash.wrapping_mul(0x100000001b3);
500        }
501        for byte in part.as_bytes() {
502            hash ^= u64::from(*byte);
503            hash = hash.wrapping_mul(0x100000001b3);
504        }
505    }
506    format!("{hash:016x}")
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    fn definition(line: usize) -> StepDefinition {
514        StepDefinition {
515            matcher: format!("step {line}"),
516            normalized_matcher: format!("step {line}"),
517            matcher_kind: MatcherKind::CucumberExpression,
518            matcher_flags: String::new(),
519            handler: HandlerFingerprint {
520                exact: String::new(),
521                normalized: String::new(),
522                alpha_normalized: String::new(),
523                structural: String::new(),
524                behavior_signature: Vec::new(),
525                source_snippet: String::new(),
526                comparable: false,
527                trivial: true,
528            },
529            framework: Framework::Unknown,
530            registration: "Given".to_owned(),
531            location: SourceLocation::new("steps.ts", line, 1, line, 10),
532            inline_suppressions: Vec::new(),
533        }
534    }
535
536    fn finding(
537        definitions: &[StepDefinition],
538        left: usize,
539        right: usize,
540        severity: Severity,
541    ) -> Finding {
542        Finding {
543            rule: Rule::DuplicateMatcher,
544            severity,
545            message: "duplicate".to_owned(),
546            primary: definitions[left].location.clone(),
547            related: vec![definitions[right].location.clone()],
548            evidence: FindingEvidence {
549                matcher_similarity: Some(1.0),
550                handler_similarity: None,
551                matcher_difference: String::new(),
552                handler_evidence: String::new(),
553                comparison: None,
554                cluster: None,
555            },
556            suggested_action: "consolidate".to_owned(),
557            suppression: None,
558        }
559    }
560
561    #[test]
562    fn rule_names_are_stable_kebab_case() {
563        for &rule in Rule::ALL {
564            assert_eq!(rule.to_string().parse::<Rule>(), Ok(rule));
565            assert_eq!(
566                rule.documentation_url(),
567                format!(
568                    "https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#{}",
569                    rule
570                )
571            );
572        }
573    }
574
575    #[test]
576    fn multipart_fingerprint_matches_nul_joined_input_without_join_allocation() {
577        assert_eq!(
578            stable_fingerprint_parts(["duplicate-matcher", "alpha", "bravo"]),
579            stable_fingerprint("duplicate-matcher\0alpha\0bravo")
580        );
581    }
582
583    #[test]
584    fn severity_text_forms_are_complete_and_reject_unknown_values() {
585        assert_eq!(Severity::Off.to_string(), "off");
586        assert_eq!(Severity::Warning.to_string(), "warning");
587        assert_eq!(Severity::Error.to_string(), "error");
588        assert_eq!("off".parse(), Ok(Severity::Off));
589        assert_eq!("warn".parse(), Ok(Severity::Warning));
590        assert_eq!("warning".parse(), Ok(Severity::Warning));
591        assert_eq!("error".parse(), Ok(Severity::Error));
592        assert_eq!(
593            "fatal".parse::<Severity>(),
594            Err("invalid severity `fatal` (expected off, warning, or error)".to_owned())
595        );
596    }
597
598    #[test]
599    fn threshold_rules_exclude_correctness_and_usage_findings() {
600        assert!(Rule::DuplicateMatcher.contributes_to_duplication_threshold());
601        assert!(Rule::ParameterizationCandidate.contributes_to_duplication_threshold());
602        assert!(!Rule::AmbiguousStep.contributes_to_duplication_threshold());
603        assert!(!Rule::UnusedDefinition.contributes_to_duplication_threshold());
604    }
605
606    #[test]
607    fn duplication_threshold_counts_unique_active_error_definitions() {
608        let definitions: Vec<_> = (1..=4).map(definition).collect();
609        let first = finding(&definitions, 0, 1, Severity::Error);
610        let second = finding(&definitions, 0, 2, Severity::Error);
611        let warning = finding(&definitions, 2, 3, Severity::Warning);
612        let mut suppressed = finding(&definitions, 1, 3, Severity::Error);
613        suppressed.suppression = Some(Suppression {
614            reason: "accepted".to_owned(),
615        });
616        let result = AnalysisResult {
617            definitions,
618            feature_steps: Vec::new(),
619            findings: vec![first, second, warning, suppressed],
620        };
621
622        let at_limit = DuplicationThreshold::from_result(&result, 75.0);
623        assert_eq!(at_limit.duplicated_definitions, 3);
624        assert_eq!(at_limit.total_definitions, 4);
625        assert_eq!(at_limit.percentage, 75.0);
626        assert_eq!(at_limit.rules, [Rule::DuplicateMatcher]);
627        assert!(at_limit.passed);
628        assert!(!DuplicationThreshold::from_result(&result, 74.99).passed);
629    }
630
631    #[test]
632    fn empty_analysis_has_zero_percent_duplication() {
633        let result = AnalysisResult::default();
634        let threshold = DuplicationThreshold::from_result(&result, 0.0);
635        assert_eq!(threshold.percentage, 0.0);
636        assert!(threshold.passed);
637    }
638
639    #[test]
640    fn duplication_threshold_counts_multiple_definitions_at_one_location() {
641        let mut definitions = vec![definition(1), definition(2)];
642        definitions[1].location = definitions[0].location.clone();
643        let duplicate = finding(&definitions, 0, 1, Severity::Error);
644        let result = AnalysisResult {
645            definitions,
646            feature_steps: Vec::new(),
647            findings: vec![duplicate],
648        };
649        let threshold = DuplicationThreshold::from_result(&result, 100.0);
650        assert_eq!(threshold.duplicated_definitions, 2);
651        assert_eq!(threshold.percentage, 100.0);
652    }
653
654    #[test]
655    fn fingerprint_is_stable_and_content_sensitive() {
656        assert_eq!(stable_fingerprint("hello"), "a430d84680aabd0b");
657        assert_ne!(stable_fingerprint("hello"), stable_fingerprint("Hello"));
658    }
659
660    #[test]
661    fn displayed_locations_use_portable_path_separators() {
662        let location = SourceLocation::new("steps\\checkout.ts", 3, 4, 3, 10);
663        assert_eq!(location.display(Path::new(".")), "steps/checkout.ts:3:4");
664    }
665}