lihaaf 0.1.2

Fast compile-fail and compile-pass test harness for Rust proc macros; a faster trybuild-style workflow
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
//! Dirty-worktree-safe generated output policy for compat mode.
//!
//! The compat driver materializes a small set of artifacts inside the
//! adopter's target-crate checkout: the staged overlay
//! (`target/lihaaf-overlay/Cargo.toml`), the §3.3 envelope, and a
//! transient directory of converted fixtures under
//! `target/lihaaf-compat-converted/`. Per
//! `docs/compatibility-plan.md` §3.2.3:
//!
//! > Generated overlays, copied fixture trees, and generated Lihaaf
//! > snapshots must either live under an ignored compat output
//! > directory or be deliberately included in the PR payload. A compat
//! > run must not leave ambiguous untracked files in the fork.
//!
//! This module owns the lifecycle of those generated paths: every
//! component that produces an artifact calls [`CleanupGuard::track`] before
//! returning the path to the caller, and the driver consumes the guard
//! at the end of [`crate::compat::run`] via [`CleanupGuard::finalize`].
//! Drop is the safety-net for panic / early-return paths.
//!
//! ## Classification
//!
//! Each tracked path is bucketed into one of four states by
//! [`GeneratedPathClass`]:
//!
//! - [`Committed`](GeneratedPathClass::Committed) — `git ls-files
//!   --error-unmatch` returns 0. The path is checked into the target
//!   crate's repository; cleanup is a no-op (the user already
//!   authorized the artifact to live in the worktree).
//! - [`Ignored`](GeneratedPathClass::Ignored) — `git check-ignore --quiet`
//!   returns 0 (or the path lives under `<target_root>/target/`, which
//!   cargo treats as implicitly ignored even before any `.gitignore`
//!   covers it). The user already authorized the artifact via the
//!   `.gitignore` choice; cleanup is a no-op.
//! - [`Cleaned`](GeneratedPathClass::Cleaned) — neither tracked nor
//!   ignored. The driver MUST remove the artifact on every exit path,
//!   including panic and early-return, so the dirty-worktree rule
//!   holds even on failure.
//! - [`Kept`](GeneratedPathClass::Kept) — set on `--keep-output` runs.
//!   Every path that would have been [`Cleaned`](GeneratedPathClass::Cleaned)
//!   is preserved instead; the §3.3 envelope records the residue so
//!   the operator can clean up manually.
//!
//! ## Locked decisions
//!
//! 1. **`Drop` is a safety net, not the primary cleanup path.** The
//!    driver calls [`CleanupGuard::finalize`] at the end of
//!    [`crate::compat::run`]; Drop fires only on panic / early-return
//!    paths. Once `finalize` has run, Drop is a no-op (the consumed
//!    guard has nothing left to clean).
//! 2. **`git check-ignore` is the source of truth for the `Ignored`
//!    classification.** Re-implementing `.gitignore`'s pattern semantics
//!    (glob, negation, `**`, per-directory files) would be a sizable
//!    sub-project; outsourcing to `git` is correct and cheap. The
//!    classifier additionally treats `<target_root>/target/` as
//!    `Ignored` even when no `.gitignore` covers it, because cargo
//!    itself owns that directory.
//! 3. **`git` absence falls back to `Cleaned`.** Without `git` on
//!    `PATH` (or without a `.git/` directory in `target_root`), the
//!    classifier cannot prove a path is ignored or committed; the safe
//!    default is to remove the path on exit.
//! 4. **`--keep-output` overrides `Cleaned`, never `Ignored` /
//!    `Committed`.** A flag designed for local debugging does not
//!    override the user's explicit `.gitignore` or `git add` choice.
//! 5. **SIGINT / SIGTERM are out of scope.** Installing a
//!    signal handler would either pull in a new crate (`ctrlc` /
//!    `signal-hook`) or hand-roll cross-platform FFI; both expand the
//!    dependency surface for marginal gain. Drop covers the panic /
//!    early-return cases; `SIGKILL` and `SIGTERM` remain unrecoverable.
//! 6. **Panic hook is diagnostic, not cleanup.** Drop runs during stack
//!    unwinding on panic, so the cleanup itself is guaranteed by the
//!    Drop impl alone. The optional [`install_panic_hook`] adds a
//!    diagnostic line that names the partial path before the panic
//!    propagates, chaining to the previously-installed hook so
//!    `libtest`'s panic capture continues to work.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};

use crate::error::Error;

/// Classification of a path the compat driver generated.
///
/// One bucket per generated artifact. Populated by
/// [`CleanupGuard::finalize`] from the raw `PendingPath` entries the
/// driver registered during the run.
///
/// `pub` (with the parent module pinned at `pub(crate)`) so the
/// crate root can `#[doc(hidden)]` re-export this for the test
/// crate. Not part of any v0.1 stability contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeneratedPathClass {
    /// Path is tracked by the target crate's git index. Compat does
    /// not clean it; the user already authorized the artifact to live
    /// in the worktree.
    Committed,
    /// Path is covered by the target crate's `.gitignore` (or lives
    /// under `<target_root>/target/`). Compat does not clean it; the
    /// user already authorized the artifact via the ignore rule.
    Ignored,
    /// Path is neither tracked nor ignored. Compat removed it on exit
    /// so the worktree stays clean.
    Cleaned,
    /// Path would have been [`Cleaned`](Self::Cleaned) but
    /// `--keep-output` was set. The §3.3 envelope records the residue
    /// so the operator can remove it manually.
    Kept,
}

/// One generated path the driver tracked, after classification has
/// run. The `path` is preserved verbatim from registration so the §3.3
/// envelope can render it directly.
///
/// `pub` (with the parent module pinned at `pub(crate)`) so the
/// crate root can `#[doc(hidden)]` re-export this for the test
/// crate. Not part of any v0.1 stability contract.
#[derive(Debug, Clone)]
pub struct GeneratedPath {
    /// Absolute path the driver produced.
    pub path: PathBuf,
    /// Final classification, populated by
    /// [`CleanupGuard::finalize`].
    pub class: GeneratedPathClass,
}

/// One un-classified registration entry. The cleanup classifier walks
/// these in [`CleanupGuard::finalize`] and produces the
/// [`GeneratedPath`] list the envelope writer consumes.
///
/// `target_root` is recorded per-entry rather than once per guard
/// because future multi-root scenarios may add multiple roots (e.g. an overlay
/// generated under one crate and a sidecar generated under another).
/// In v0.1 every entry shares the same root, but the per-entry shape
/// keeps the structure additive.
#[derive(Debug, Clone)]
struct PendingPath {
    /// Absolute path the driver produced.
    path: PathBuf,
    /// The target crate root the path was produced under. Used as the
    /// `git` working directory for `git check-ignore` / `git ls-files`.
    target_root: PathBuf,
}

/// Tracker for generated paths.
///
/// Wrapped by [`CleanupGuard`] for the public surface; the tracker
/// itself is internal because the lifecycle (Drop, finalize) belongs
/// to the guard.
#[derive(Debug, Default)]
struct CleanupTracker {
    /// Pending registrations. Populated by [`CleanupGuard::track`];
    /// drained by [`CleanupGuard::finalize`] or by the Drop safety net.
    pending: Vec<PendingPath>,
}

/// RAII handle for cleanup of compat-generated paths.
///
/// Construct one near the top of [`crate::compat::run`], call
/// [`CleanupGuard::track`] for every generated path, and call
/// [`CleanupGuard::finalize`] at every well-known exit. If `finalize`
/// is never called (panic, `?`-propagation, early `return`), the Drop
/// impl runs the cleanup as a safety net.
///
/// ## Once-only semantics
///
/// Cleanup runs at most once per guard. Both [`Self::finalize`] and
/// the Drop impl swap a shared [`AtomicBool`] before touching the
/// tracker state, so the second call is a no-op. `finalize` is the
/// explicit, well-known path and reports filesystem errors via
/// `Result`; Drop is the panic / early-return safety net and
/// best-effort-swallows filesystem errors so an unwinding panic is
/// never masked by a secondary cleanup error. The two paths share the
/// private `classify_entry` helper but otherwise differ in mutex
/// acquisition (Drop uses `try_lock`) and error handling.
///
/// ## Thread safety
///
/// The interior `Mutex<CleanupTracker>` makes the guard `Sync` so it
/// can be referenced from `&self` methods on multiple threads if
/// fixture conversion is parallelized. The `try_lock` path in
/// Drop avoids a secondary panic if another thread is still inside
/// `track` when an unwinding panic begins on this thread; the
/// original panic is more informative than a "poisoned mutex"
/// secondary panic.
/// `pub` (with the parent module pinned at `pub(crate)`) so the
/// crate root can `#[doc(hidden)]` re-export this for the test
/// crate. Not part of any v0.1 stability contract.
#[derive(Debug)]
pub struct CleanupGuard {
    /// Tracker state. Behind a `Mutex` so `track` can be called from
    /// `&self` (overlay generation is single-threaded today,
    /// but fixture conversion is the natural place to
    /// parallelize and the guard surface should not need to change).
    inner: Mutex<CleanupTracker>,
    /// Mirrors `CompatArgs::inner_cli.keep_output`. When `true`, every
    /// `Cleaned` classification is promoted to `Kept` and the
    /// filesystem is not touched.
    keep_output: bool,
    /// Set once cleanup has run (via `finalize` or Drop). Prevents
    /// double-cleanup and lets Drop skip the lock entirely when the
    /// guard has already been consumed.
    cleaned: AtomicBool,
}

impl CleanupGuard {
    /// Construct a fresh guard. `keep_output` is typically
    /// [`crate::cli::Cli::keep_output`] — pass it through from the
    /// driver so a single source of truth governs cleanup and
    /// inner-session output-retention.
    ///
    /// `pub` to allow the test crate's `#[doc(hidden)]` re-export to
    /// reach this constructor. Adopters must drive compat mode through
    /// `cargo lihaaf --compat`, not the Rust API.
    pub fn new(keep_output: bool) -> Self {
        Self {
            inner: Mutex::new(CleanupTracker::default()),
            keep_output,
            cleaned: AtomicBool::new(false),
        }
    }

    /// Register a generated path for classification + cleanup.
    ///
    /// `path` may be absolute OR relative to `target_root`; the
    /// API boundary accepts both for caller convenience, but the
    /// tracker stores the path in absolute form internally. This
    /// matters because the classifier (`is_under_cargo_target`)
    /// does a `starts_with` check against `<target_root>/target` —
    /// a relative path would silently miss the prefix and be
    /// misclassified as `Cleaned` (then removed) when it actually
    /// lives under `target/`. `target_root` is the adopter's
    /// `--compat-root` — the working directory for the `git`
    /// invocations in [`CleanupGuard::finalize`].
    ///
    /// Registration is cheap: the entry is appended to a pending
    /// vector; classification + filesystem work is deferred to
    /// `finalize`. This keeps the hot path (overlay generation,
    /// fixture conversion) free of subprocess spawns.
    ///
    /// `pub` to allow the test crate's `#[doc(hidden)]` re-export to
    /// reach this method.
    pub fn track(&self, path: PathBuf, target_root: &Path) {
        // Resolve relative inputs against `target_root` eagerly. The
        // classifier's `starts_with` check and the `git` subprocess
        // both behave correctly on absolute paths; storing the
        // absolute form here means the rest of the cleanup pipeline
        // is invariant w.r.t. how the caller chose to express the
        // path. `PathBuf::join` is a no-op when `path` is already
        // absolute, so absolute inputs pass through unchanged.
        let absolute = if path.is_absolute() {
            path
        } else {
            target_root.join(&path)
        };
        // Acquire-only — if a previous panic poisoned the mutex, we
        // still want to record the new entry. The `into_inner` /
        // `get_mut` path used by Drop handles the poisoning
        // gracefully.
        let mut guard = match self.inner.lock() {
            Ok(g) => g,
            Err(poisoned) => poisoned.into_inner(),
        };
        guard.pending.push(PendingPath {
            path: absolute,
            target_root: target_root.to_path_buf(),
        });
    }

    /// Classify every registered path, remove what needs removing,
    /// and return the final list for the §3.3 envelope.
    ///
    /// The returned vector is sorted by `path` so the envelope is
    /// deterministic across runs. After `finalize` returns, the guard
    /// has been consumed; the Drop impl is a no-op.
    ///
    /// `pub` to allow the test crate's `#[doc(hidden)]` re-export to
    /// reach this method.
    pub fn finalize(self) -> Result<Vec<GeneratedPath>, Error> {
        self.run_cleanup_once()
    }

    /// Internal: run the cleanup pass at most once.
    ///
    /// Called by both [`Self::finalize`] (the explicit, well-known
    /// path) and [`Drop::drop`] (the safety net). The
    /// [`AtomicBool`] gate makes the second call a no-op so a
    /// finalize-then-Drop sequence does not classify or touch the
    /// filesystem twice.
    ///
    /// **Error policy:** every pending entry is processed even if a
    /// previous entry's removal failed. Failures are accumulated; the
    /// first failure's `Error::Io` is returned with a path-list suffix
    /// when more than one removal failed. This matters because the
    /// atomic-gate marks the guard cleaned at function entry — short-
    /// circuiting on the first error would silently leak every later
    /// entry (Drop sees the gate already tripped and no-ops). The
    /// classifications for every entry, whether removal succeeded or
    /// failed, are returned in the success case; on aggregate error the
    /// returned `Vec` is empty so the caller does not consume a stale
    /// list (the §3.3 envelope writer treats a failed cleanup as a
    /// session-level failure and does not emit residue records for it).
    fn run_cleanup_once(&self) -> Result<Vec<GeneratedPath>, Error> {
        // `swap` returns the previous value. If `true`, cleanup
        // already ran; bail with an empty list (the caller's first
        // call already received the canonical list).
        if self.cleaned.swap(true, Ordering::SeqCst) {
            return Ok(Vec::new());
        }

        let pending = {
            let mut guard = match self.inner.lock() {
                Ok(g) => g,
                Err(poisoned) => poisoned.into_inner(),
            };
            std::mem::take(&mut guard.pending)
        };

        let mut classified: Vec<GeneratedPath> = Vec::with_capacity(pending.len());
        let mut first_error: Option<Error> = None;
        let mut additional_failed_paths: Vec<PathBuf> = Vec::new();
        for entry in pending {
            let final_class = classify_entry(&entry, self.keep_output);
            if final_class == GeneratedPathClass::Cleaned
                && let Err(err) = remove_path_best_effort(&entry.path)
            {
                if first_error.is_none() {
                    first_error = Some(err);
                } else {
                    additional_failed_paths.push(entry.path.clone());
                }
            }
            classified.push(GeneratedPath {
                path: entry.path,
                class: final_class,
            });
        }

        if let Some(err) = first_error {
            return Err(aggregate_cleanup_error(err, additional_failed_paths));
        }

        // Determinism: sort by path so two runs from the same input
        // produce byte-identical envelopes (the §3.3 contract).
        classified.sort_by(|a, b| a.path.cmp(&b.path));
        Ok(classified)
    }
}

/// Build the aggregate-failure `Error` returned by [`CleanupGuard::run_cleanup_once`].
///
/// `first_error` is preserved verbatim — the original `Error::Io`'s
/// `source`, `context`, and `path` fields all carry forward so callers
/// can still pattern-match on the underlying `io::ErrorKind`. When more
/// than one entry failed, the additional paths are appended to the
/// context line so the operator sees the full failure surface in one
/// message rather than chasing a series of suppressed errors.
fn aggregate_cleanup_error(first_error: Error, additional: Vec<PathBuf>) -> Error {
    if additional.is_empty() {
        return first_error;
    }
    match first_error {
        Error::Io {
            source,
            context,
            path,
        } => {
            let suffix = additional
                .iter()
                .map(|p| p.display().to_string())
                .collect::<Vec<_>>()
                .join(", ");
            Error::Io {
                source,
                context: format!(
                    "{context} (and {} other compat-generated path(s) also failed to remove: {suffix})",
                    additional.len(),
                ),
                path,
            }
        }
        // `remove_path_best_effort` only returns `Error::Io`; any other
        // variant is a bug, but we surface it verbatim rather than
        // panic so the original diagnostic survives.
        other => other,
    }
}

/// Classify `entry` and apply the `--keep-output` promotion. Returns
/// the final class so the caller can decide whether to remove the
/// path (only [`GeneratedPathClass::Cleaned`] requires removal). The
/// removal itself is the caller's responsibility — `finalize` uses `?`
/// propagation; Drop swallows errors mid-unwind.
fn classify_entry(entry: &PendingPath, keep_output: bool) -> GeneratedPathClass {
    let class = classify(&entry.target_root, &entry.path);
    match (class, keep_output) {
        (GeneratedPathClass::Cleaned, true) => GeneratedPathClass::Kept,
        (other, _) => other,
    }
}

impl Drop for CleanupGuard {
    /// Safety-net cleanup for panic / early-return paths.
    ///
    /// Best-effort only: a filesystem error inside Drop is ignored
    /// rather than double-panicking — the original panic is the more
    /// informative signal. Per the plan §5 risk note, this path uses
    /// `try_lock` rather than `lock` so a contended mutex (e.g. a
    /// parallel fixture-conversion worker in Phase 6+) cannot cause
    /// Drop to block or to panic on a poisoned lock.
    fn drop(&mut self) {
        // If `finalize` already ran, the atomic flag short-circuits
        // and this is a no-op without touching the mutex at all.
        if self.cleaned.swap(true, Ordering::SeqCst) {
            return;
        }

        // `try_lock` avoids the two Drop-time hazards the plan calls
        // out: blocking indefinitely when another thread holds the
        // lock, and an unwinding-Drop double-panic on a poisoned
        // mutex. If the lock is unavailable, we silently skip the
        // pending entries — they will leak rather than corrupt the
        // already-panicking process.
        //
        // In a poisoned-mutex case we still try to drain the pending
        // entries via `into_inner` (the `match` handles both arms),
        // because a panic that poisoned the mutex on another thread
        // is exactly the case the safety-net cleanup exists for.
        let pending = match self.inner.try_lock() {
            Ok(mut guard) => std::mem::take(&mut guard.pending),
            Err(std::sync::TryLockError::Poisoned(poisoned)) => {
                std::mem::take(&mut poisoned.into_inner().pending)
            }
            Err(std::sync::TryLockError::WouldBlock) => return,
        };

        for entry in pending {
            let final_class = classify_entry(&entry, self.keep_output);
            if final_class == GeneratedPathClass::Cleaned {
                // Best-effort: discard the error so Drop does not
                // double-panic on a filesystem failure mid-unwind.
                let _ = remove_path_best_effort(&entry.path);
            }
        }
    }
}

/// Classify one path against `target_root`'s git state.
///
/// Order of checks:
///
/// 1. Path resolves under `<target_root>/target/` — treated as
///    [`Ignored`](GeneratedPathClass::Ignored) without invoking git.
///    Cargo owns `target/`; even fork checkouts that lack a
///    `.gitignore` rule for it still treat it as transient.
/// 2. `git ls-files --error-unmatch -- <path>` exits 0 — the path is
///    committed.
/// 3. `git check-ignore --quiet -- <path>` exits 0 — the path is
///    ignored.
/// 4. Otherwise — `Cleaned` (the driver removes it on exit).
///
/// `git` invocations use `<target_root>` as the working directory so
/// the right `.gitignore` rules apply (per-directory `.gitignore`
/// files compose in git's pattern resolution).
///
/// Per locked decision §5.3, a missing `git` binary or a non-git
/// directory falls through to `Cleaned` — the safe default when the
/// classifier cannot prove the path is ignored or committed.
fn classify(target_root: &Path, path: &Path) -> GeneratedPathClass {
    if is_under_cargo_target(target_root, path) {
        return GeneratedPathClass::Ignored;
    }
    if git_is_tracked(target_root, path) {
        return GeneratedPathClass::Committed;
    }
    if git_is_ignored(target_root, path) {
        return GeneratedPathClass::Ignored;
    }
    GeneratedPathClass::Cleaned
}

/// Returns `true` when `path` lives under `<target_root>/target/`.
///
/// Implementation: canonicalize neither path (canonicalization fails
/// for paths that have just been removed, which is exactly the state
/// we may be classifying during cleanup). Instead, compare
/// `target_root.join("target")` against `path`'s `starts_with`. False
/// positives are not a concern: lihaaf does not generate artifacts
/// under any sibling directory named `target`.
fn is_under_cargo_target(target_root: &Path, path: &Path) -> bool {
    let target_dir = target_root.join("target");
    path.starts_with(&target_dir)
}

/// `git ls-files --error-unmatch -- <path>` — exit 0 means the path is
/// tracked. Any other exit (1, 128, "command not found", …) means
/// the classifier cannot prove tracked status and should fall through.
fn git_is_tracked(target_root: &Path, path: &Path) -> bool {
    git_quiet_status(target_root, &["ls-files", "--error-unmatch", "--"], path)
}

/// `git check-ignore --quiet -- <path>` — exit 0 means the path is
/// covered by a `.gitignore` rule reachable from `target_root`.
/// Any other exit means "not ignored" or "git unavailable"; both
/// fall through to the `Cleaned` default.
fn git_is_ignored(target_root: &Path, path: &Path) -> bool {
    git_quiet_status(target_root, &["check-ignore", "--quiet", "--"], path)
}

/// Shared spawn shape for the two classifier git calls.
///
/// Spawns `git <args> <path>` with `<target_root>` as the working
/// directory, redirects stdout / stderr to null (we only consume the
/// exit code), and returns `true` when exit is 0. Spawn failure (no
/// `git` on `PATH`, OS error) returns `false` — the caller treats
/// that as "classifier cannot prove the property" and falls through.
fn git_quiet_status(target_root: &Path, args: &[&str], path: &Path) -> bool {
    let output = Command::new("git")
        .args(args)
        .arg(path)
        .current_dir(target_root)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status();
    match output {
        Ok(status) => status.success(),
        Err(_) => false,
    }
}

/// Remove a compat-generated `path` from the filesystem without
/// relying on a prior `symlink_metadata` stat. Best-effort: a
/// non-existent path is treated as already-cleaned (no error) so the
/// cleanup is idempotent across reruns.
///
/// Thin wrapper that delegates to [`crate::util::remove_path_race_free`]
/// with the `"compat-generated"` context prefix. The race-free cascade
/// itself lives in `util` because the same TOCTOU shape exists at
/// `src/dylib.rs`'s managed-dylib swap; sharing the cascade keeps both
/// call sites in sync. The full cascade design,
/// platform-specific cfg arms, and "why not pre-stat" rationale are
/// documented on [`crate::util::remove_path_race_free`].
fn remove_path_best_effort(path: &Path) -> Result<(), Error> {
    crate::util::remove_path_race_free(path, "compat-generated")
}

/// Install a panic hook that names compat-generated paths in the
/// panic diagnostic. Optional sibling of the Drop guard: Drop runs
/// the actual cleanup, but the panic hook gives the operator a
/// pointer to the partial path so they can investigate or clean up
/// manually if the unwind itself is interrupted.
///
/// The previous hook is preserved and chained: `libtest`'s panic
/// capture (used by `cargo test`) continues to work, and any panic
/// hook installed by the binary's `main` runs after this one.
///
/// **Single-install semantics.** The hook is installed at most once
/// per process (gated by an internal [`AtomicBool`]). The compat
/// driver calls this once at the start of [`crate::compat::run`];
/// repeated calls (from re-entrant compat runs or from tests that
/// share a binary) are no-ops.
///
/// **Diagnostic, not cleanup.** This hook does NOT perform cleanup —
/// the Drop guard does. The hook exists to surface partial-path
/// information that would otherwise be lost in the panic noise. See
/// locked decision §5.6 in this module's header for the full
/// rationale.
///
/// `pub` to allow the test crate's `#[doc(hidden)]` re-export to
/// reach this function.
pub fn install_panic_hook() {
    static INSTALLED: AtomicBool = AtomicBool::new(false);
    if INSTALLED.swap(true, Ordering::SeqCst) {
        return;
    }

    let previous = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        // The hook fires on every panic in the process, not just
        // compat-mode panics. We deliberately keep the diagnostic
        // minimal — adding context that depends on global state
        // (e.g. "the current compat run was tracking <paths>") would
        // require shared mutable state with its own poisoning
        // failure modes. The Drop guard owns the cleanup; this hook
        // just makes sure the panic surface stays informative.
        eprintln!(
            "lihaaf compat: panic during compat run — Drop guard will attempt cleanup of \
             registered paths; see envelope for residue list"
        );
        previous(info);
    }));
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Helper: classify a path against a directory that has no `git`
    /// metadata. The classifier should fall through to `Cleaned` per
    /// locked decision §5.3.
    #[test]
    fn classify_without_git_falls_through_to_cleaned() {
        let tmp = tempfile::tempdir().expect("tempdir for classify-no-git test");
        let path = tmp.path().join("artifact.txt");
        std::fs::write(&path, b"contents").expect("write artifact");

        let class = classify(tmp.path(), &path);
        assert_eq!(
            class,
            GeneratedPathClass::Cleaned,
            "non-git tempdir must classify as Cleaned"
        );
    }

    /// `is_under_cargo_target` correctly buckets `<root>/target/...`
    /// paths even when the directory does not exist yet (the classifier
    /// runs during cleanup, when the path may already be removed).
    #[test]
    fn cargo_target_is_classified_without_filesystem_lookup() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let target = tmp.path().join("target").join("lihaaf-compat-converted");
        // Deliberately do NOT create the directory; the classifier
        // must work on logical-path bookkeeping alone.
        let class = classify(tmp.path(), &target);
        assert_eq!(class, GeneratedPathClass::Ignored);
    }

    /// `is_under_cargo_target` returns `false` for sibling directories
    /// that happen to contain the word "target" but are not the
    /// `<root>/target/` cargo directory.
    #[test]
    fn sibling_target_directory_is_not_under_cargo_target() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let sibling = tmp.path().join("targets").join("file.txt");
        assert!(!is_under_cargo_target(tmp.path(), &sibling));
    }

    /// `remove_path_best_effort` on a non-existent path is a no-op.
    /// This is the rerun-idempotence invariant: cleanup may be called
    /// on a path that a previous attempt already removed.
    #[test]
    fn remove_nonexistent_path_is_ok() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let path = tmp.path().join("never-existed.txt");
        remove_path_best_effort(&path).expect("removing non-existent path must succeed");
    }

    /// **`remove_path_best_effort` removes a symlink-to-directory
    /// without following.** On Unix, step 1 of the cascade
    /// (`std::fs::remove_file` → `unlink(2)`) unlinks the symlink
    /// regardless of target kind, so this case terminates at step 1
    /// without touching the target tree. On Windows the same case
    /// falls through to step 2 (`std::fs::remove_dir` →
    /// `RemoveDirectoryW`), which removes the directory-symlink LINK
    /// without recursing into the target. Either way the target tree
    /// is preserved.
    #[cfg(unix)]
    #[test]
    fn remove_symlink_to_directory_unix() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let target = tmp.path().join("real_dir");
        std::fs::create_dir_all(&target).expect("create target dir");
        std::fs::write(target.join("inside.txt"), b"keep me").expect("write into target");

        let link = tmp.path().join("link_to_dir");
        std::os::unix::fs::symlink(&target, &link).expect("create symlink-to-dir");

        // Sanity: the link exists and resolves to a directory.
        assert!(link.exists(), "symlink must exist before removal");
        assert!(
            link.symlink_metadata().unwrap().file_type().is_symlink(),
            "link_to_dir must be a symlink, not a real dir"
        );

        remove_path_best_effort(&link).expect("removing the symlink must succeed");

        // The link is gone.
        assert!(
            !link.exists() && link.symlink_metadata().is_err(),
            "symlink must be removed"
        );
        // The target tree is untouched (we removed the link, not its
        // contents).
        assert!(target.exists(), "target directory must NOT be removed");
        assert!(
            target.join("inside.txt").exists(),
            "target's contents must NOT be removed"
        );
    }

    /// `remove_path_best_effort` deletes a directory tree
    /// recursively. This matches the `target/lihaaf-compat-converted/`
    /// shape Phase 6 will produce.
    #[test]
    fn remove_directory_tree() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let nested = tmp.path().join("dir").join("nested");
        std::fs::create_dir_all(&nested).expect("create nested dir");
        std::fs::write(nested.join("file.txt"), b"data").expect("write nested file");

        let dir = tmp.path().join("dir");
        remove_path_best_effort(&dir).expect("recursive removal");
        assert!(!dir.exists(), "directory tree must be gone after cleanup");
    }

    /// **Unix `PermissionDenied` on step 1 surfaces with the file-stage
    /// context.** A regular file whose parent denies write permission
    /// returns `EACCES` from `unlink(2)`. The cascade must not conflate
    /// this with the Windows directory signal: the file must not fall
    /// through to the empty-dir or recursive-removal contexts because
    /// the failure has nothing to do with directories.
    ///
    /// Pin: the file-stage context (`"removing compat-generated
    /// file/symlink"`) is what an operator sees, so they can map the
    /// diagnostic to "fix the parent's write permission" without
    /// hunting for a phantom directory.
    #[cfg(unix)]
    #[test]
    fn unix_permission_denied_surfaces_as_file_stage_error() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().expect("tempdir for unix-eacces test");
        let blocked_parent = tmp.path().join("locked");
        std::fs::create_dir(&blocked_parent).expect("create blocked parent");
        let inside = blocked_parent.join("inside.txt");
        std::fs::write(&inside, b"contents").expect("create file inside locked");

        // Strip write permission from the parent so unlink(2) of
        // `inside` returns EACCES (PermissionDenied).
        let original_perms = std::fs::metadata(&blocked_parent)
            .expect("read parent perms")
            .permissions();
        std::fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o555))
            .expect("strip parent write permission");

        let result = remove_path_best_effort(&inside);

        // Restore perms BEFORE the asserts so a panicking test still
        // leaves the tempdir cleanable. (tempfile's drop() also walks
        // the tree; without restored perms it would silently fail.)
        std::fs::set_permissions(&blocked_parent, original_perms).expect("restore parent perms");

        let err = result.expect_err("EACCES on file unlink must surface as error");
        match err {
            Error::Io { context, .. } => {
                assert_eq!(
                    context, "removing compat-generated file/symlink",
                    "Unix EACCES must surface with the file-stage context, not \
                     the empty-dir/recursive-dir context; got `{context}`"
                );
            }
            other => panic!("expected Error::Io for EACCES, got {other:?}"),
        }
    }
}