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