Skip to main content

cuke_dedup/
modes.rs

1//! Changed-files and existing-findings baseline modes for incremental CI adoption.
2
3use crate::config::normalize_platform_path;
4use crate::model::{
5    stable_fingerprint, stable_fingerprint_parts, DefinitionComparison, Finding, Suppression,
6};
7use crate::resource_limits::{read_utf8, MAX_BASELINE_INPUT_BYTES};
8use anyhow::{bail, Context, Result};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15/// Returns tracked changes since `base` plus untracked files beneath `root`.
16pub fn git_changed_files(root: &Path, base: &str) -> Result<BTreeSet<PathBuf>> {
17    let base_oid = resolve_commit(root, base)?;
18    let tracked = git_paths(
19        root,
20        &[
21            "diff",
22            "--relative",
23            "--name-only",
24            "-z",
25            "--diff-filter=ACMR",
26            &base_oid,
27            "--",
28        ],
29        &format!("git diff against `{base}` failed"),
30    )?;
31    let untracked = git_paths(
32        root,
33        &["ls-files", "--others", "--exclude-standard", "-z", "--"],
34        "git untracked-file discovery failed",
35    )?;
36    Ok(tracked
37        .into_iter()
38        .chain(untracked)
39        .map(|path| root.join(path))
40        .collect())
41}
42
43/// Git environment variables that a `git` process exports to the hooks it runs. They outrank a
44/// child invocation's `-C`, so leaving them in place would point changed-files mode at whichever
45/// repository invoked the hook instead of the analyzed root.
46const INHERITED_GIT_VARIABLES: [&str; 8] = [
47    "GIT_DIR",
48    "GIT_WORK_TREE",
49    "GIT_INDEX_FILE",
50    "GIT_OBJECT_DIRECTORY",
51    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
52    "GIT_COMMON_DIR",
53    "GIT_PREFIX",
54    "GIT_CEILING_DIRECTORIES",
55];
56
57/// Builds a `git` invocation bound to `root` and nothing else, so results describe the analyzed
58/// tree whether CukeDedup runs from a shell, CI, or another repository's Git hook.
59fn git_at(root: &Path) -> Command {
60    let mut command = Command::new("git");
61    for variable in INHERITED_GIT_VARIABLES {
62        command.env_remove(variable);
63    }
64    command.arg("-C").arg(root);
65    command
66}
67
68fn resolve_commit(root: &Path, base: &str) -> Result<String> {
69    if base.trim().is_empty() || base.starts_with('-') {
70        bail!("invalid --changed-since revision `{base}`");
71    }
72    let revision = format!("{base}^{{commit}}");
73    let output = git_at(root)
74        .args(["rev-parse", "--verify", "--end-of-options"])
75        .arg(&revision)
76        .output()
77        .with_context(|| "failed to resolve the --changed-since revision")?;
78    if !output.status.success() {
79        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
80        bail!("could not resolve --changed-since revision `{base}`: {stderr}");
81    }
82    let oid = String::from_utf8(output.stdout)
83        .context("git returned a non-UTF-8 commit object id")?
84        .trim()
85        .to_owned();
86    if oid.is_empty() || !oid.bytes().all(|byte| byte.is_ascii_hexdigit()) {
87        bail!("git returned an invalid commit object id for `{base}`");
88    }
89    Ok(oid)
90}
91
92/// Rejects changed-file mode when the analyzed root itself is excluded by Git.
93pub fn ensure_changed_root_is_trackable(root: &Path) -> Result<()> {
94    let root = normalize_platform_path(root.to_path_buf());
95    let repository = git_at(&root)
96        .args(["rev-parse", "--show-toplevel"])
97        .output()
98        .with_context(|| "failed to locate the Git repository for changed-files mode")?;
99    if !repository.status.success() {
100        bail!(
101            "changed-files mode requires a Git repository: {}",
102            String::from_utf8_lossy(&repository.stderr).trim()
103        );
104    }
105    let repository = normalize_platform_path(
106        PathBuf::from(
107            String::from_utf8(repository.stdout)
108                .context("git returned a non-UTF-8 repository path")?
109                .trim(),
110        )
111        .canonicalize()
112        .with_context(|| "failed to canonicalize the Git repository root")?,
113    );
114    if root == repository {
115        return Ok(());
116    }
117    let relative = root.strip_prefix(&repository).with_context(|| {
118        format!(
119            "target {} is outside Git repository {}",
120            root.display(),
121            repository.display()
122        )
123    })?;
124    let ignored = git_at(&repository)
125        .args(["check-ignore", "--quiet", "--"])
126        .arg(relative)
127        .status()
128        .with_context(|| "failed to check whether the changed-files target is ignored")?;
129    match ignored.code() {
130        Some(0) => bail!(
131            "changed-files target {} is ignored by Git and cannot be analyzed incrementally",
132            root.display()
133        ),
134        Some(1) => Ok(()),
135        _ => bail!("git check-ignore failed for target {}", root.display()),
136    }
137}
138
139fn git_paths(root: &Path, arguments: &[&str], failure: &str) -> Result<Vec<PathBuf>> {
140    let output = git_at(root)
141        .args(["-c", "core.quotePath=false"])
142        .args(arguments)
143        .output()
144        .with_context(|| "failed to run git for changed-files mode")?;
145    if !output.status.success() {
146        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
147        bail!("{failure}: {stderr}");
148    }
149    output
150        .stdout
151        .split(|byte| *byte == b'\0')
152        .filter(|path| !path.is_empty())
153        .map(path_from_git_bytes)
154        .collect()
155}
156
157#[cfg(unix)]
158fn path_from_git_bytes(bytes: &[u8]) -> Result<PathBuf> {
159    use std::ffi::OsStr;
160    use std::os::unix::ffi::OsStrExt;
161
162    Ok(PathBuf::from(OsStr::from_bytes(bytes)))
163}
164
165#[cfg(not(unix))]
166fn path_from_git_bytes(bytes: &[u8]) -> Result<PathBuf> {
167    Ok(PathBuf::from(
168        std::str::from_utf8(bytes).context("git returned a non-UTF-8 file name")?,
169    ))
170}
171
172/// Retains findings whose primary or related location belongs to `changed`.
173pub fn retain_changed_findings(findings: &mut Vec<Finding>, changed: &BTreeSet<PathBuf>) {
174    findings.retain(|finding| {
175        changed.contains(&finding.primary.path)
176            || finding
177                .related
178                .iter()
179                .any(|location| changed.contains(&location.path))
180    });
181}
182
183/// Schema version of the compact, reviewable baseline format.
184pub const BASELINE_SCHEMA_VERSION: u32 = 2;
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188/// Accepted semantic finding fingerprints and their multiplicities.
189#[non_exhaustive]
190pub struct BaselineFile {
191    /// Baseline format version.
192    pub schema_version: u32,
193    /// Stable fingerprint counts, sorted for deterministic pull-request diffs.
194    pub fingerprints: BTreeMap<String, usize>,
195}
196
197impl BaselineFile {
198    /// Creates a baseline using the current schema version.
199    pub fn new(fingerprints: BTreeMap<String, usize>) -> Self {
200        Self {
201            schema_version: BASELINE_SCHEMA_VERSION,
202            fingerprints,
203        }
204    }
205
206    fn empty() -> Self {
207        Self::new(BTreeMap::new())
208    }
209}
210
211impl Default for BaselineFile {
212    fn default() -> Self {
213        Self::new(BTreeMap::new())
214    }
215}
216
217#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
218/// Effect of applying or rewriting a baseline.
219#[non_exhaustive]
220pub struct BaselineOutcome {
221    /// Findings matched and suppressed by the baseline.
222    pub suppressed: usize,
223    /// Active findings beyond the baseline's recorded multiplicities.
224    pub new_findings: usize,
225    /// Fingerprint instances added while updating.
226    pub added: usize,
227    /// Fingerprint instances removed while updating.
228    pub removed: usize,
229}
230
231/// Suppresses findings found in a schema-compatible semantic baseline.
232///
233/// Multiplicity is consumed in finding order, so adding another instance of accepted
234/// duplication remains visible even when its content fingerprint is already present.
235pub fn apply_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
236    let baseline = load_baseline(baseline_path)?;
237    apply_loaded_baseline(findings, baseline_path, &baseline)
238}
239
240/// Rewrites a baseline from the current active findings and suppresses those findings.
241pub fn update_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
242    let previous = if baseline_path.is_file() {
243        load_baseline_for_update(baseline_path)?
244    } else {
245        BaselineFile::empty()
246    };
247    let current = build_baseline(findings);
248    let added = count_added(&previous.fingerprints, &current.fingerprints);
249    let removed = count_added(&current.fingerprints, &previous.fingerprints);
250    save_baseline(baseline_path, &current)?;
251    let mut outcome = apply_loaded_baseline(findings, baseline_path, &current)?;
252    outcome.added = added;
253    outcome.removed = removed;
254    Ok(outcome)
255}
256
257fn load_baseline(baseline_path: &Path) -> Result<BaselineFile> {
258    let baseline = parse_baseline(baseline_path)?;
259    if baseline.schema_version != BASELINE_SCHEMA_VERSION {
260        bail!(
261            "unsupported baseline schema version `{}` (expected `{}`); regenerate it with --update-baseline",
262            baseline.schema_version,
263            BASELINE_SCHEMA_VERSION
264        );
265    }
266    Ok(baseline)
267}
268
269fn load_baseline_for_update(baseline_path: &Path) -> Result<BaselineFile> {
270    let baseline = parse_baseline(baseline_path)?;
271    if baseline.schema_version > BASELINE_SCHEMA_VERSION {
272        bail!(
273            "baseline schema version `{}` is newer than supported version `{}` and cannot be replaced safely",
274            baseline.schema_version,
275            BASELINE_SCHEMA_VERSION
276        );
277    }
278    Ok(baseline)
279}
280
281fn parse_baseline(baseline_path: &Path) -> Result<BaselineFile> {
282    let text = read_utf8(baseline_path, "baseline", MAX_BASELINE_INPUT_BYTES)?;
283    // serde_json's default recursion limit remains enabled for untrusted baseline input.
284    serde_json::from_str(&text)
285        .with_context(|| format!("failed to parse baseline {}", baseline_path.display()))
286}
287
288fn save_baseline(path: &Path, baseline: &BaselineFile) -> Result<()> {
289    if let Some(parent) = path.parent() {
290        fs::create_dir_all(parent)
291            .with_context(|| format!("failed to create baseline directory {}", parent.display()))?;
292    }
293    let text = serde_json::to_string_pretty(baseline).context("failed to serialize baseline")?;
294    fs::write(path, format!("{text}\n"))
295        .with_context(|| format!("failed to write baseline {}", path.display()))
296}
297
298fn build_baseline(findings: &[Finding]) -> BaselineFile {
299    let mut baseline = BaselineFile::empty();
300    for finding in findings.iter().filter(|finding| finding.is_active()) {
301        *baseline
302            .fingerprints
303            .entry(finding_fingerprint(finding))
304            .or_default() += 1;
305    }
306    baseline
307}
308
309fn apply_loaded_baseline(
310    findings: &mut [Finding],
311    baseline_path: &Path,
312    baseline: &BaselineFile,
313) -> Result<BaselineOutcome> {
314    let mut allowance = baseline.fingerprints.clone();
315    let mut outcome = BaselineOutcome::default();
316    for finding in findings {
317        if !finding.is_active() {
318            continue;
319        }
320        let fingerprint = finding_fingerprint(finding);
321        match allowance.get_mut(&fingerprint) {
322            Some(remaining) if *remaining > 0 => {
323                *remaining -= 1;
324                finding.suppression = Some(Suppression {
325                    reason: format!("present in baseline {}", baseline_path.display()),
326                });
327                outcome.suppressed += 1;
328            }
329            _ => outcome.new_findings += 1,
330        }
331    }
332    Ok(outcome)
333}
334
335/// Returns a location-independent fingerprint for baseline comparison.
336pub fn finding_fingerprint(finding: &Finding) -> String {
337    if let Some(cluster) = &finding.evidence.cluster {
338        let rule = finding.rule.to_string();
339        let mut fingerprints = cluster
340            .definition_fingerprints
341            .iter()
342            .map(String::as_str)
343            .collect::<Vec<_>>();
344        fingerprints.sort_unstable();
345        return stable_fingerprint_parts(std::iter::once(rule.as_str()).chain(fingerprints));
346    }
347    let semantic = finding
348        .evidence
349        .comparison
350        .as_ref()
351        .map(comparison_fingerprint_input)
352        .unwrap_or_else(|| {
353            format!(
354                "{}\u{0}{}\u{0}{}",
355                finding.message,
356                finding.evidence.matcher_difference,
357                finding.evidence.handler_evidence
358            )
359        });
360    stable_fingerprint(&format!("{}\u{0}{semantic}", finding.rule))
361}
362
363fn comparison_fingerprint_input(comparison: &DefinitionComparison) -> String {
364    let mut sides = [
365        comparison.left_fingerprint.clone(),
366        comparison.right_fingerprint.clone(),
367    ];
368    sides.sort();
369    sides.join("\u{0}")
370}
371
372fn count_added(previous: &BTreeMap<String, usize>, current: &BTreeMap<String, usize>) -> usize {
373    current
374        .iter()
375        .map(|(fingerprint, count)| {
376            count.saturating_sub(previous.get(fingerprint).copied().unwrap_or_default())
377        })
378        .sum()
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::model::{DefinitionCluster, FindingEvidence, Rule, Severity, SourceLocation};
385
386    fn initialize_repository(root: &Path) {
387        for arguments in [
388            ["init", "-q"].as_slice(),
389            ["config", "user.email", "test@example.com"].as_slice(),
390            ["config", "user.name", "Test"].as_slice(),
391            ["add", "."].as_slice(),
392            ["commit", "-qm", "initial"].as_slice(),
393        ] {
394            assert!(fixture_git()
395                .args(arguments)
396                .current_dir(root)
397                .status()
398                .unwrap()
399                .success());
400        }
401    }
402
403    /// Git exports `GIT_DIR`, `GIT_INDEX_FILE`, and friends to the processes it spawns. When this
404    /// suite runs from a Git hook, those variables outrank the `current_dir` and `-C` of a nested
405    /// invocation, so a fixture's `git add` would stage temporary paths into the real repository's
406    /// index and mark every tracked file deleted. Strip that environment instead.
407    fn fixture_git() -> Command {
408        let mut command = Command::new("git");
409        for variable in INHERITED_GIT_VARIABLES {
410            command.env_remove(variable);
411        }
412        command
413    }
414
415    fn finding(root: &Path, file: &str) -> Finding {
416        Finding {
417            rule: Rule::DuplicateMatcher,
418            severity: Severity::Error,
419            message: "duplicate".to_owned(),
420            primary: SourceLocation::new(root.join(file), 2, 1, 2, 10),
421            related: vec![SourceLocation::new(
422                root.join("steps/shared.ts"),
423                4,
424                1,
425                4,
426                10,
427            )],
428            evidence: FindingEvidence {
429                matcher_similarity: Some(1.0),
430                handler_similarity: Some(0.5),
431                matcher_difference: "same".to_owned(),
432                handler_evidence: "different".to_owned(),
433                comparison: None,
434                cluster: None,
435            },
436            suggested_action: "consolidate".to_owned(),
437            suppression: None,
438        }
439    }
440
441    #[test]
442    fn changed_mode_keeps_findings_related_to_a_changed_file() {
443        let root = Path::new("/repo");
444        let mut findings = vec![
445            finding(root, "steps/changed.ts"),
446            finding(root, "steps/old.ts"),
447        ];
448        let changed = BTreeSet::from([root.join("steps/changed.ts")]);
449        retain_changed_findings(&mut findings, &changed);
450        assert_eq!(findings.len(), 1);
451        assert!(findings[0].primary.path.ends_with("changed.ts"));
452    }
453
454    #[test]
455    fn cluster_fingerprints_are_location_and_member_order_independent() {
456        let root = Path::new("/repo");
457        let mut original = finding(root, "steps/a.ts");
458        original.evidence.cluster = Some(DefinitionCluster {
459            member_count: 3,
460            definition_fingerprints: vec!["charlie".into(), "alpha".into(), "bravo".into()],
461            pair_findings_collapsed: 2,
462            members_truncated: false,
463        });
464        let mut moved = finding(root, "moved/renamed.ts");
465        moved.evidence.cluster = Some(DefinitionCluster {
466            member_count: 3,
467            definition_fingerprints: vec!["bravo".into(), "charlie".into(), "alpha".into()],
468            pair_findings_collapsed: 99,
469            members_truncated: false,
470        });
471
472        assert_eq!(finding_fingerprint(&original), finding_fingerprint(&moved));
473        moved
474            .evidence
475            .cluster
476            .as_mut()
477            .unwrap()
478            .definition_fingerprints
479            .push("delta".into());
480        assert_ne!(finding_fingerprint(&original), finding_fingerprint(&moved));
481    }
482
483    #[test]
484    fn changed_files_are_relative_to_a_subdirectory_and_include_untracked_files() {
485        let directory = tempfile::tempdir().unwrap();
486        let root = directory.path();
487        fs::create_dir_all(root.join("packages/e2e/steps")).unwrap();
488        fs::write(root.join("packages/e2e/steps/träcked.ts"), "before").unwrap();
489        initialize_repository(root);
490        fs::write(root.join("packages/e2e/steps/träcked.ts"), "after").unwrap();
491        fs::write(root.join("packages/e2e/steps/ new step.ts"), "new").unwrap();
492        #[cfg(unix)]
493        fs::write(root.join("packages/e2e/steps/trailing step.ts "), "new").unwrap();
494
495        let subdirectory = root.join("packages/e2e");
496        let changed = git_changed_files(&subdirectory, "HEAD").unwrap();
497        let mut expected = BTreeSet::from([
498            subdirectory.join("steps/ new step.ts"),
499            subdirectory.join("steps/träcked.ts"),
500        ]);
501        #[cfg(unix)]
502        expected.insert(subdirectory.join("steps/trailing step.ts "));
503        assert_eq!(changed, expected);
504    }
505
506    #[cfg(unix)]
507    #[test]
508    fn changed_files_preserve_newlines_in_unix_names() {
509        let directory = tempfile::tempdir().unwrap();
510        let root = directory.path();
511        let steps = root.join("steps");
512        fs::create_dir_all(&steps).unwrap();
513        let newline = steps.join("line\nbreak.ts");
514        fs::write(&newline, "before").unwrap();
515        initialize_repository(root);
516        fs::write(&newline, "after").unwrap();
517
518        let changed = git_changed_files(root, "HEAD").unwrap();
519        assert_eq!(changed, BTreeSet::from([newline]));
520    }
521
522    #[cfg(unix)]
523    #[test]
524    fn git_path_decoder_preserves_non_utf8_unix_bytes() {
525        use std::os::unix::ffi::OsStrExt;
526
527        let bytes = b"steps/non-utf8-\xff.ts";
528        let path = path_from_git_bytes(bytes).unwrap();
529        assert_eq!(path.as_os_str().as_bytes(), bytes);
530    }
531
532    #[cfg(target_os = "linux")]
533    #[test]
534    fn changed_files_preserve_non_utf8_linux_names() {
535        use std::ffi::OsString;
536        use std::os::unix::ffi::OsStringExt;
537
538        let directory = tempfile::tempdir().unwrap();
539        let root = directory.path();
540        let steps = root.join("steps");
541        fs::create_dir_all(&steps).unwrap();
542        let non_utf8 = steps.join(OsString::from_vec(b"non-utf8-\xff.ts".to_vec()));
543        fs::write(&non_utf8, "before").unwrap();
544        initialize_repository(root);
545        fs::write(&non_utf8, "after").unwrap();
546
547        let changed = git_changed_files(root, "HEAD").unwrap();
548        assert_eq!(changed, BTreeSet::from([non_utf8]));
549    }
550
551    #[test]
552    fn semantic_baseline_survives_location_changes_and_enforces_multiplicity() {
553        let directory = tempfile::tempdir().unwrap();
554        let root = directory.path();
555        let baseline_path = root.join("baseline.json");
556        let mut accepted = vec![finding(root, "steps/original.ts")];
557        let update = update_baseline(&mut accepted, &baseline_path).unwrap();
558        assert_eq!(update.added, 1);
559        assert_eq!(update.removed, 0);
560        assert_eq!(update.suppressed, 1);
561
562        let mut current = vec![
563            finding(root, "moved/renamed.ts"),
564            finding(root, "steps/third-copy.ts"),
565        ];
566        current[0].primary.line = 200;
567        let outcome = apply_baseline(&mut current, &baseline_path).unwrap();
568        assert_eq!(outcome.suppressed, 1);
569        assert_eq!(outcome.new_findings, 1);
570        assert!(!current[0].is_active());
571        assert!(current[1].is_active());
572
573        let baseline: BaselineFile =
574            serde_json::from_str(&fs::read_to_string(&baseline_path).unwrap()).unwrap();
575        assert_eq!(baseline.schema_version, BASELINE_SCHEMA_VERSION);
576        assert_eq!(baseline.fingerprints.values().sum::<usize>(), 1);
577    }
578
579    #[test]
580    fn changed_mode_validates_revisions_repositories_and_ignored_roots() {
581        let directory = tempfile::tempdir().unwrap();
582        fs::write(directory.path().join("tracked.ts"), "initial").unwrap();
583        initialize_repository(directory.path());
584        assert!(
585            ensure_changed_root_is_trackable(&directory.path().canonicalize().unwrap()).is_ok()
586        );
587        for revision in ["", " ", "--all", "missing-revision"] {
588            assert!(
589                resolve_commit(directory.path(), revision).is_err(),
590                "{revision}"
591            );
592        }
593        assert!(git_paths(directory.path(), &["not-a-command"], "expected failure").is_err());
594
595        let ignored = directory.path().join("ignored");
596        fs::create_dir(&ignored).unwrap();
597        fs::write(directory.path().join(".gitignore"), "ignored/\n").unwrap();
598        assert!(
599            ensure_changed_root_is_trackable(&ignored.canonicalize().unwrap())
600                .unwrap_err()
601                .to_string()
602                .contains("ignored by Git")
603        );
604
605        let outside = tempfile::tempdir().unwrap();
606        assert!(ensure_changed_root_is_trackable(outside.path()).is_err());
607    }
608
609    #[test]
610    fn changed_mode_keeps_findings_with_changed_related_locations() {
611        let root = Path::new("/repo");
612        let mut findings = vec![finding(root, "steps/old.ts")];
613        let changed = BTreeSet::from([root.join("steps/shared.ts")]);
614        retain_changed_findings(&mut findings, &changed);
615        assert_eq!(findings.len(), 1);
616    }
617
618    #[test]
619    fn baseline_validation_counts_removals_and_ignores_suppressed_findings() {
620        let directory = tempfile::tempdir().unwrap();
621        let baseline_path = directory.path().join("nested/baseline.json");
622        let mut initial = vec![
623            finding(directory.path(), "one.ts"),
624            finding(directory.path(), "two.ts"),
625        ];
626        initial[1].message = "second duplicate".to_owned();
627        update_baseline(&mut initial, &baseline_path).unwrap();
628
629        let mut current = vec![finding(directory.path(), "one.ts")];
630        current[0].suppression = Some(Suppression {
631            reason: "already accepted elsewhere".to_owned(),
632        });
633        let outcome = update_baseline(&mut current, &baseline_path).unwrap();
634        assert_eq!(outcome.added, 0);
635        assert_eq!(outcome.removed, 2);
636        assert_eq!(outcome.suppressed, 0);
637
638        fs::write(&baseline_path, r#"{"schemaVersion":99,"fingerprints":{}}"#).unwrap();
639        assert!(apply_baseline(&mut [], &baseline_path)
640            .unwrap_err()
641            .to_string()
642            .contains("unsupported baseline schema"));
643        fs::write(&baseline_path, "not json").unwrap();
644        assert!(apply_baseline(&mut [], &baseline_path)
645            .unwrap_err()
646            .to_string()
647            .contains("failed to parse baseline"));
648    }
649
650    #[test]
651    fn comparison_fingerprints_are_independent_of_pair_order() {
652        let comparison = DefinitionComparison {
653            left_fingerprint: "left".to_owned(),
654            right_fingerprint: "right".to_owned(),
655            left_matcher: String::new(),
656            right_matcher: String::new(),
657            left_handler: String::new(),
658            right_handler: String::new(),
659            matcher_diff: crate::model::MatcherDiff {
660                prefix: String::new(),
661                left_change: String::new(),
662                right_change: String::new(),
663                suffix: String::new(),
664            },
665        };
666        let mut reversed = comparison.clone();
667        std::mem::swap(
668            &mut reversed.left_fingerprint,
669            &mut reversed.right_fingerprint,
670        );
671        assert_eq!(
672            comparison_fingerprint_input(&comparison),
673            comparison_fingerprint_input(&reversed)
674        );
675        assert_eq!(
676            count_added(
677                &BTreeMap::from([("a".to_owned(), 3)]),
678                &BTreeMap::from([("a".to_owned(), 1)])
679            ),
680            0
681        );
682    }
683}