Skip to main content

verbs/
thread_materialize.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure thread materialization planning.
3//!
4//! Owns decision logic for the `heddle start` / `heddle thread start`
5//! materialization path once a concrete [`ThreadMode`] is known:
6//! - checkout path layout (explicit `--path` vs managed default)
7//! - ordered materialize step sequence (create dir, copy tree, manifest, …)
8//! - typed start-transaction effect kinds and reverse cleanup lists
9//! - target-dir claim → checkout / self-created-dir rewind actions
10//! - path safety vs `.heddle/threads` layout and relative-path normalization
11//! - empty-dir adoption / claim-intent pure validators
12//! - effect staging preconditions (claim established, shared-target present)
13//! - classification of mid-apply `anyhow` failures into [`HeddleError`]
14//! - reflink vs full-copy policy for bytes-on-disk checkouts
15//! - cargo `--shared-target` redirect and advisory flags
16//!
17//! Filesystem clonefile/copy, mount RPCs, cargo-config writes, and the
18//! `start_atomic` transaction stay CLI-owned. Callers resolve host/config
19//! facts first, then invoke these helpers.
20
21use std::path::{Component, Path, PathBuf};
22
23use objects::HeddleError;
24use repo::ThreadMode;
25
26// ---------------------------------------------------------------------------
27// Path layout
28// ---------------------------------------------------------------------------
29
30/// Pure plan for where a new thread checkout should land.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CheckoutPathPlan {
33    /// Absolute or caller-resolved path the start path should use.
34    pub path: PathBuf,
35    /// True when the planned path is the caller's explicit `--path`.
36    ///
37    /// Virtualized mounts always use the managed default so a user-named
38    /// directory is never shadowed by a kernel mount; this is `false` even
39    /// if the caller passed `--path`.
40    pub from_explicit_path: bool,
41}
42
43/// Plan the checkout path for a start once mode is known.
44///
45/// Rules (matching CLI `start_thread`):
46/// - [`ThreadMode::Virtualized`] always uses `managed_default` (ignores
47///   explicit `--path`).
48/// - [`ThreadMode::Materialized`] / [`ThreadMode::Solid`] honor
49///   `explicit_path` when present, else `managed_default`.
50///
51/// `managed_default` is the fully-built
52/// `.heddle/threads/<encoded>/<repo-name>` path from
53/// `repo.managed_checkout_path(name)` (or equivalent).
54pub fn plan_checkout_path(
55    mode: &ThreadMode,
56    explicit_path: Option<PathBuf>,
57    managed_default: PathBuf,
58) -> CheckoutPathPlan {
59    match mode {
60        ThreadMode::Virtualized => CheckoutPathPlan {
61            path: managed_default,
62            from_explicit_path: false,
63        },
64        ThreadMode::Materialized | ThreadMode::Solid => match explicit_path {
65            Some(path) => CheckoutPathPlan {
66                path,
67                from_explicit_path: true,
68            },
69            None => CheckoutPathPlan {
70                path: managed_default,
71                from_explicit_path: false,
72            },
73        },
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Copy policy (reflink vs full copy)
79// ---------------------------------------------------------------------------
80
81/// How a bytes-on-disk checkout should populate the tree.
82///
83/// The CLI / materializer owns the actual `clonefile` / `FICLONE` / copy
84/// calls. This only captures the pure policy intent from mode.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum CheckoutCopyPolicy {
87    /// Prefer filesystem reflink/clonefile; fall back to full copy when the
88    /// host rejects it. Used for [`ThreadMode::Materialized`].
89    PreferReflink,
90    /// Always perform a full byte copy. Used for [`ThreadMode::Solid`].
91    FullCopy,
92    /// No tree copy — virtualized mounts do not materialize bytes.
93    None,
94}
95
96/// Pure reflink-vs-copy policy from the planned thread mode.
97pub fn plan_checkout_copy_policy(mode: &ThreadMode) -> CheckoutCopyPolicy {
98    match mode {
99        ThreadMode::Materialized => CheckoutCopyPolicy::PreferReflink,
100        ThreadMode::Solid => CheckoutCopyPolicy::FullCopy,
101        ThreadMode::Virtualized => CheckoutCopyPolicy::None,
102    }
103}
104
105/// Whether start should warn that an explicit `--workspace materialized`
106/// will fall back to per-file copies on this host.
107///
108/// Auto-mode silently downgrades to solid via [`crate::plan_thread_mode`];
109/// an explicit materialized request is honored but the user is told disk
110/// usage will match solid.
111pub fn should_warn_materialized_without_reflink(
112    explicit_materialized_request: bool,
113    supports_reflink: bool,
114) -> bool {
115    explicit_materialized_request && !supports_reflink
116}
117
118// ---------------------------------------------------------------------------
119// Shared-target redirect / advisory
120// ---------------------------------------------------------------------------
121
122/// Active heavy (solid/materialized) threads at or above which a
123/// `--shared-target` heads-up is emitted when starting another heavy
124/// thread in a Rust workspace without the flag.
125pub const ADVISORY_ACTIVE_HEAVY_THREAD_THRESHOLD: usize = 1;
126
127/// Pure decision for whether `--shared-target` should write a cargo
128/// `target-dir` redirect after checkout materialize.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum SharedTargetRedirectDecision {
131    /// Apply `.cargo/config.toml` redirect after materialize.
132    Apply,
133    /// Flag set but workspace has no top-level `Cargo.toml` — no-op (debug).
134    SkipNonRustWorkspace,
135    /// Flag not set, or mode is not bytes-on-disk.
136    NotApplicable,
137}
138
139/// Plan the `--shared-target` cargo-config redirect from pure inputs.
140///
141/// `is_rust_workspace` is a caller-supplied fact (typically presence of a
142/// top-level `Cargo.toml`). Directory creation and config writes remain
143/// CLI-owned.
144pub fn plan_shared_target_redirect(
145    requested: bool,
146    mode: &ThreadMode,
147    is_rust_workspace: bool,
148) -> SharedTargetRedirectDecision {
149    if !requested || !mode_is_bytes_on_disk(mode) {
150        return SharedTargetRedirectDecision::NotApplicable;
151    }
152    if is_rust_workspace {
153        SharedTargetRedirectDecision::Apply
154    } else {
155        SharedTargetRedirectDecision::SkipNonRustWorkspace
156    }
157}
158
159/// Whether [`plan_shared_target_redirect`] selected an apply.
160pub fn shared_target_redirect_applies(decision: SharedTargetRedirectDecision) -> bool {
161    matches!(decision, SharedTargetRedirectDecision::Apply)
162}
163
164/// Whether the workspace looks busy enough for a `--shared-target` heads-up
165/// (Rust + active heavy-thread population), independent of the start flags.
166///
167/// Callers typically supply
168/// `is_rust_workspace && active_heavy_thread_count >= threshold` after probing
169/// the repo; this keeps the threshold comparison pure and unit-testable.
170pub fn shared_target_workspace_is_busy(
171    is_rust_workspace: bool,
172    active_heavy_thread_count: usize,
173) -> bool {
174    is_rust_workspace && active_heavy_thread_count >= ADVISORY_ACTIVE_HEAVY_THREAD_THRESHOLD
175}
176
177/// Whether start should print the `--shared-target` heads-up advisory.
178///
179/// Heuristic (matching CLI): flag not requested, mode is solid/materialized,
180/// and the workspace is busy ([`shared_target_workspace_is_busy`]).
181///
182/// `workspace_is_busy` must reflect the *pre-start* population (before the
183/// new thread is recorded). Callers may pass a precomputed I/O oracle
184/// (e.g. CLI `should_advise_shared_target(repo)`) as `workspace_is_busy`.
185pub fn should_advise_shared_target(
186    shared_target_requested: bool,
187    mode: &ThreadMode,
188    workspace_is_busy: bool,
189) -> bool {
190    !shared_target_requested && mode_is_bytes_on_disk(mode) && workspace_is_busy
191}
192
193// ---------------------------------------------------------------------------
194// Hydrate + mode predicates
195// ---------------------------------------------------------------------------
196
197/// Whether a mode materializes a real on-disk checkout (vs a virtual mount).
198pub fn mode_is_bytes_on_disk(mode: &ThreadMode) -> bool {
199    matches!(mode, ThreadMode::Solid | ThreadMode::Materialized)
200}
201
202/// Whether `--hydrate` should run for this mode.
203///
204/// Hydrate only applies to solid/materialized checkouts.
205pub fn plan_hydrate(hydrate_requested: bool, mode: &ThreadMode) -> bool {
206    hydrate_requested && mode_is_bytes_on_disk(mode)
207}
208
209/// Whether a materialized-thread manifest sidecar should be written.
210///
211/// Only [`ThreadMode::Materialized`] records the per-thread manifest.
212pub fn plan_write_manifest(mode: &ThreadMode) -> bool {
213    matches!(mode, ThreadMode::Materialized)
214}
215
216// ---------------------------------------------------------------------------
217// Materialize step sequence
218// ---------------------------------------------------------------------------
219
220/// One step in the atomic start materialization sequence.
221///
222/// Order matches `start_atomic::StartThread::apply`. Execution (FS, refs,
223/// mounts) remains CLI-owned; this is the pure checklist.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum MaterializeStep {
226    /// Create the materialization target directory (transaction first step).
227    CreateTargetDir,
228    /// CAS-write the thread ref (+ staged `ThreadCreate` when brand-new).
229    WriteThreadRef,
230    /// Materialize `.heddle` metadata + worktree bytes under the target.
231    MaterializeCheckout { copy_policy: CheckoutCopyPolicy },
232    /// Write `.heddle/threads/<name>/manifest.toml` (materialized only).
233    WriteManifest,
234    /// Write `.cargo/config.toml` shared `target-dir` redirect.
235    WriteCargoConfigRedirect,
236    /// Symlink ignored dirs from the parent (`--hydrate`).
237    HydrateIgnoredDirs,
238    /// Establish the FUSE/virtual mount (virtualized only).
239    EstablishVirtualizedMount,
240    /// Converge the ThreadManager record.
241    WriteThreadRecord,
242}
243
244/// Structured pure plan for the start materialization path.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct ThreadMaterializePlan {
247    /// Ordered steps for the start transaction apply path.
248    pub steps: Vec<MaterializeStep>,
249    pub copy_policy: CheckoutCopyPolicy,
250    pub write_manifest: bool,
251    pub apply_shared_target: bool,
252    pub hydrate: bool,
253    pub virtualized_mount: bool,
254}
255
256/// Build the pure materialize plan from mode + post-decision flags.
257///
258/// `apply_shared_target` should already be the result of
259/// [`shared_target_redirect_applies`] (or equivalent). `hydrate_requested`
260/// is the raw CLI flag; this function gates it on mode via [`plan_hydrate`].
261pub fn plan_thread_materialize(
262    mode: &ThreadMode,
263    apply_shared_target: bool,
264    hydrate_requested: bool,
265) -> ThreadMaterializePlan {
266    let copy_policy = plan_checkout_copy_policy(mode);
267    let write_manifest = plan_write_manifest(mode);
268    let hydrate = plan_hydrate(hydrate_requested, mode);
269    let virtualized_mount = matches!(mode, ThreadMode::Virtualized);
270    // Shared-target only applies to bytes-on-disk modes; ignore a stale true
271    // for virtualized so the step list stays honest.
272    let apply_shared_target = apply_shared_target && mode_is_bytes_on_disk(mode);
273
274    let mut steps = vec![
275        MaterializeStep::CreateTargetDir,
276        MaterializeStep::WriteThreadRef,
277    ];
278    match mode {
279        ThreadMode::Solid | ThreadMode::Materialized => {
280            steps.push(MaterializeStep::MaterializeCheckout { copy_policy });
281            if write_manifest {
282                steps.push(MaterializeStep::WriteManifest);
283            }
284            if apply_shared_target {
285                steps.push(MaterializeStep::WriteCargoConfigRedirect);
286            }
287            if hydrate {
288                steps.push(MaterializeStep::HydrateIgnoredDirs);
289            }
290        }
291        ThreadMode::Virtualized => {
292            steps.push(MaterializeStep::EstablishVirtualizedMount);
293        }
294    }
295    steps.push(MaterializeStep::WriteThreadRecord);
296
297    ThreadMaterializePlan {
298        steps,
299        copy_policy,
300        write_manifest,
301        apply_shared_target,
302        hydrate,
303        virtualized_mount,
304    }
305}
306
307/// Convenience: ordered step list only.
308pub fn plan_materialize_steps(
309    mode: &ThreadMode,
310    apply_shared_target: bool,
311    hydrate_requested: bool,
312) -> Vec<MaterializeStep> {
313    plan_thread_materialize(mode, apply_shared_target, hydrate_requested).steps
314}
315
316// ---------------------------------------------------------------------------
317// Transaction effect kinds + start transaction plan
318// ---------------------------------------------------------------------------
319
320/// Payload-free kind of a durable effect the start transaction can stage.
321///
322/// Mirrors [`MaterializeStep`] without the copy-policy payload so applied-
323/// effect lists and cleanup planners stay `Copy`. Execution remains CLI-owned
324/// (`start_atomic::StartThread::apply`).
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum StartEffectKind {
327    CreateTargetDir,
328    WriteThreadRef,
329    MaterializeCheckout,
330    WriteManifest,
331    WriteCargoConfigRedirect,
332    HydrateIgnoredDirs,
333    EstablishVirtualizedMount,
334    WriteThreadRecord,
335}
336
337impl MaterializeStep {
338    /// Strip the copy-policy payload to the pure effect kind.
339    pub fn effect_kind(&self) -> StartEffectKind {
340        match self {
341            Self::CreateTargetDir => StartEffectKind::CreateTargetDir,
342            Self::WriteThreadRef => StartEffectKind::WriteThreadRef,
343            Self::MaterializeCheckout { .. } => StartEffectKind::MaterializeCheckout,
344            Self::WriteManifest => StartEffectKind::WriteManifest,
345            Self::WriteCargoConfigRedirect => StartEffectKind::WriteCargoConfigRedirect,
346            Self::HydrateIgnoredDirs => StartEffectKind::HydrateIgnoredDirs,
347            Self::EstablishVirtualizedMount => StartEffectKind::EstablishVirtualizedMount,
348            Self::WriteThreadRecord => StartEffectKind::WriteThreadRecord,
349        }
350    }
351}
352
353/// Typed start-transaction plan: ordered effect kinds + mode flags.
354///
355/// Built from the same inputs as [`plan_thread_materialize`]; preferred when
356/// callers need effect-kind lists (cleanup, ledger assertions) rather than
357/// the payload-bearing [`MaterializeStep`] sequence.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct StartTransactionPlan {
360    /// Forward-apply order of durable effects.
361    pub effects: Vec<StartEffectKind>,
362    pub copy_policy: CheckoutCopyPolicy,
363    pub write_manifest: bool,
364    pub apply_shared_target: bool,
365    pub hydrate: bool,
366    pub virtualized_mount: bool,
367}
368
369/// Build the typed start-transaction plan from mode + post-decision flags.
370pub fn plan_start_transaction(
371    mode: &ThreadMode,
372    apply_shared_target: bool,
373    hydrate_requested: bool,
374) -> StartTransactionPlan {
375    let materialize = plan_thread_materialize(mode, apply_shared_target, hydrate_requested);
376    StartTransactionPlan {
377        effects: materialize
378            .steps
379            .iter()
380            .map(MaterializeStep::effect_kind)
381            .collect(),
382        copy_policy: materialize.copy_policy,
383        write_manifest: materialize.write_manifest,
384        apply_shared_target: materialize.apply_shared_target,
385        hydrate: materialize.hydrate,
386        virtualized_mount: materialize.virtualized_mount,
387    }
388}
389
390// ---------------------------------------------------------------------------
391// Target-dir claim → pure rewind actions
392// ---------------------------------------------------------------------------
393
394/// What the target-dir claim established about the worktree leaf (pure).
395///
396/// CLI `start_atomic` captures an open directory handle alongside this kind;
397/// rewinds and writers key on the kind, never a stale plan-time bool.
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum TargetDirClaimKind {
400    /// This start created the leaf as a fresh empty directory.
401    Created,
402    /// This start adopted a pre-existing real empty directory.
403    AdoptedEmpty,
404}
405
406/// Pure checkout-dir rewind action for a settled (or absent) target claim.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum CheckoutRewindPlan {
409    /// Clear contents, then remove the directory if it still occupies the path.
410    ClearAndRemoveDir,
411    /// Clear only the contents this start wrote; leave the directory itself.
412    ClearContentsOnly,
413    /// Claim never established — touch nothing.
414    TouchNothing,
415}
416
417/// Plan how the checkout rewind treats the target leaf for a claim outcome.
418///
419/// Rules (matching CLI `rewind_checkout`):
420/// - [`TargetDirClaimKind::Created`] → clear + remove dir
421/// - [`TargetDirClaimKind::AdoptedEmpty`] → clear contents only
422/// - `None` → touch nothing (refused/unestablished leaf)
423pub fn plan_checkout_rewind(claim: Option<TargetDirClaimKind>) -> CheckoutRewindPlan {
424    match claim {
425        Some(TargetDirClaimKind::Created) => CheckoutRewindPlan::ClearAndRemoveDir,
426        Some(TargetDirClaimKind::AdoptedEmpty) => CheckoutRewindPlan::ClearContentsOnly,
427        None => CheckoutRewindPlan::TouchNothing,
428    }
429}
430
431/// Pure self-created target-dir removal action (the create-step inverse).
432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433pub enum SelfCreatedDirRewindPlan {
434    /// Remove the leaf only if the claim identity still occupies the path.
435    RemoveIfStillAtPath,
436    /// Adopted or unestablished — never remove the leaf.
437    TouchNothing,
438}
439
440/// Plan the create-step inverse for a settled (or absent) target claim.
441///
442/// Only a [`TargetDirClaimKind::Created`] claim removes the leaf; adopted
443/// dirs and `None` are left untouched (matching CLI `remove_self_created_dir`).
444pub fn plan_self_created_dir_rewind(claim: Option<TargetDirClaimKind>) -> SelfCreatedDirRewindPlan {
445    match claim {
446        Some(TargetDirClaimKind::Created) => SelfCreatedDirRewindPlan::RemoveIfStillAtPath,
447        Some(TargetDirClaimKind::AdoptedEmpty) | None => SelfCreatedDirRewindPlan::TouchNothing,
448    }
449}
450
451// ---------------------------------------------------------------------------
452// Path safety (threads root layout + relative remainder normalization)
453// ---------------------------------------------------------------------------
454
455/// Lexical containment: `path` is `root` or a strict descendant.
456///
457/// Paths must already be absolute and free of `..` (caller-normalized). Uses
458/// [`Path::starts_with`], so a root of `/a` does not match `/ab`.
459pub fn path_is_under_or_equal(path: &Path, root: &Path) -> bool {
460    path == root || path.starts_with(root)
461}
462
463/// Lexical strict descent: under `root` but not equal to it.
464pub fn path_is_strict_descendant(path: &Path, root: &Path) -> bool {
465    path != root && path.starts_with(root)
466}
467
468/// Pure classification of a candidate checkout path vs heddle layout.
469///
470/// Mirrors the lexical half of CLI `validate_worktree_target` (heddle#572):
471/// managed checkouts live under `.heddle/threads/<seg>/<leaf>`, never on the
472/// threads root or bare per-thread dir, and never on other heddle storage.
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474pub enum ThreadsRootPathClass {
475    /// Under `.heddle/threads/` as a per-thread checkout slot (allowed).
476    ManagedCheckoutSlot,
477    /// Exactly `.heddle/threads` — forbidden.
478    ThreadsRoot,
479    /// Direct child of threads root (`threads/<seg>` without leaf) — forbidden.
480    BareThreadDir,
481    /// Under `.heddle/` but outside `threads/` — forbidden storage.
482    HeddleStorage,
483    /// Outside `.heddle/` entirely (user `--path` or external) — allowed here.
484    OutsideHeddle,
485}
486
487/// Classify `path` relative to `heddle_dir` / `threads_root`.
488///
489/// Inputs must be absolute normalized paths (no `..`). Nested-in-existing-
490/// thread checks need reserved regions from the caller; see
491/// [`path_is_nested_in_reserved_region`].
492pub fn classify_path_vs_threads_root(
493    path: &Path,
494    heddle_dir: &Path,
495    threads_root: &Path,
496) -> ThreadsRootPathClass {
497    if path_is_under_or_equal(path, threads_root) {
498        if path == threads_root {
499            return ThreadsRootPathClass::ThreadsRoot;
500        }
501        if path.parent() == Some(threads_root) {
502            return ThreadsRootPathClass::BareThreadDir;
503        }
504        return ThreadsRootPathClass::ManagedCheckoutSlot;
505    }
506    if path_is_under_or_equal(path, heddle_dir) {
507        return ThreadsRootPathClass::HeddleStorage;
508    }
509    ThreadsRootPathClass::OutsideHeddle
510}
511
512/// Whether [`classify_path_vs_threads_root`] accepts the layout class.
513///
514/// Nested-in-existing-thread is a separate pure check
515/// ([`path_is_nested_in_reserved_region`]).
516pub fn threads_root_path_layout_allowed(class: ThreadsRootPathClass) -> bool {
517    matches!(
518        class,
519        ThreadsRootPathClass::ManagedCheckoutSlot | ThreadsRootPathClass::OutsideHeddle
520    )
521}
522
523/// Pure layout + reserved-region validation for a worktree target under the
524/// threads root / heddle storage policy.
525///
526/// `reserved_regions` is `(region_root, exempt_exact_path)` pairs: candidate is
527/// nested when it starts with `region_root` unless it equals `exempt_exact`
528/// (self-thread re-materialize exemption). Callers enumerate durable thread
529/// records; this stays FS-free.
530pub fn validate_threads_root_path_safety(
531    path: &Path,
532    heddle_dir: &Path,
533    threads_root: &Path,
534    reserved_regions: &[(PathBuf, Option<PathBuf>)],
535) -> Result<(), ThreadsRootPathSafetyError> {
536    let class = classify_path_vs_threads_root(path, heddle_dir, threads_root);
537    match class {
538        ThreadsRootPathClass::ThreadsRoot => Err(ThreadsRootPathSafetyError::IsThreadsRoot {
539            path: path.to_path_buf(),
540        }),
541        ThreadsRootPathClass::BareThreadDir => Err(ThreadsRootPathSafetyError::IsBareThreadDir {
542            path: path.to_path_buf(),
543        }),
544        ThreadsRootPathClass::HeddleStorage => Err(ThreadsRootPathSafetyError::IsHeddleStorage {
545            path: path.to_path_buf(),
546        }),
547        ThreadsRootPathClass::OutsideHeddle => Ok(()),
548        ThreadsRootPathClass::ManagedCheckoutSlot => {
549            for (region, exempt) in reserved_regions {
550                if path_is_nested_in_reserved_region(path, region, exempt.as_deref()) {
551                    return Err(ThreadsRootPathSafetyError::NestedInReserved {
552                        path: path.to_path_buf(),
553                        reserved: region.clone(),
554                    });
555                }
556            }
557            Ok(())
558        }
559    }
560}
561
562/// Failures from pure threads-root / heddle-storage path safety checks.
563#[derive(Debug, Clone, PartialEq, Eq)]
564pub enum ThreadsRootPathSafetyError {
565    IsThreadsRoot { path: PathBuf },
566    IsBareThreadDir { path: PathBuf },
567    IsHeddleStorage { path: PathBuf },
568    NestedInReserved { path: PathBuf, reserved: PathBuf },
569}
570
571impl std::fmt::Display for ThreadsRootPathSafetyError {
572    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
573        match self {
574            Self::IsThreadsRoot { path } => write!(
575                f,
576                "worktree target '{}' is the threads root (not a per-thread leaf)",
577                path.display()
578            ),
579            Self::IsBareThreadDir { path } => write!(
580                f,
581                "worktree target '{}' is a bare thread dir (checkout leaf required)",
582                path.display()
583            ),
584            Self::IsHeddleStorage { path } => write!(
585                f,
586                "worktree target '{}' is under heddle storage (outside threads/)",
587                path.display()
588            ),
589            Self::NestedInReserved { path, reserved } => write!(
590                f,
591                "worktree target '{}' is nested inside reserved region '{}'",
592                path.display(),
593                reserved.display()
594            ),
595        }
596    }
597}
598
599impl std::error::Error for ThreadsRootPathSafetyError {}
600
601/// Whether `candidate` falls inside `reserved_dir`, with optional exact exempt.
602///
603/// Matching CLI `is_inside_existing_thread` for one reserved region: under the
604/// region is nested, unless `candidate == exempt_exact` (self-thread checkout).
605pub fn path_is_nested_in_reserved_region(
606    candidate: &Path,
607    reserved_dir: &Path,
608    exempt_exact: Option<&Path>,
609) -> bool {
610    if !candidate.starts_with(reserved_dir) {
611        return false;
612    }
613    if let Some(exempt) = exempt_exact
614        && candidate == exempt
615    {
616        return false;
617    }
618    true
619}
620
621/// Whether a path component sequence is free of `..` escape segments.
622pub fn path_components_are_safe(path: &Path) -> bool {
623    !path.components().any(|c| matches!(c, Component::ParentDir))
624}
625
626/// Failures from pure relative-remainder normalization.
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
628pub enum RelativePathNormalizeError {
629    /// Remainder contained `..`, a root, or a prefix component.
630    UnsafeComponent,
631}
632
633impl std::fmt::Display for RelativePathNormalizeError {
634    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
635        match self {
636            Self::UnsafeComponent => {
637                write!(f, "path remainder contains an unsafe path component")
638            }
639        }
640    }
641}
642
643impl std::error::Error for RelativePathNormalizeError {}
644
645/// Append unresolved remainder components onto a resolved base.
646///
647/// Matches CLI `canonicalize_existing_ancestor` remainder handling:
648/// - `Normal` → push
649/// - `CurDir` → ignore
650/// - `ParentDir` / `Prefix` / `RootDir` → refuse (escape / re-root)
651///
652/// Callers supply the already-canonical existing ancestor as `base` and the
653/// non-existing tail as `remainder` (from `path.strip_prefix(ancestor)`).
654pub fn append_safe_relative_components(
655    mut base: PathBuf,
656    remainder: &Path,
657) -> Result<PathBuf, RelativePathNormalizeError> {
658    for component in remainder.components() {
659        match component {
660            Component::Normal(part) => base.push(part),
661            Component::CurDir => {}
662            Component::ParentDir | Component::Prefix(_) | Component::RootDir => {
663                return Err(RelativePathNormalizeError::UnsafeComponent);
664            }
665        }
666    }
667    Ok(base)
668}
669
670// ---------------------------------------------------------------------------
671// Empty-dir adoption / create-intent pure validators
672// ---------------------------------------------------------------------------
673
674/// Plan-time intent for the create-target-dir step (before FS create/adopt).
675///
676/// Derived solely from `plan_worktree_target`'s `target_dir_created` bool; the
677/// runtime claim may still land as AdoptedEmpty if a concurrent create races
678/// the transaction ([`CreateDirAttempt::AlreadyExists`]).
679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
680pub enum TargetDirCreateIntent {
681    /// Leaf was absent at plan time — attempt `create_dir`, adopt on race.
682    AttemptCreate,
683    /// Leaf was a pre-existing empty dir — adopt only, never create/remove.
684    AdoptOnly,
685}
686
687/// Pure create-intent from the plan-time `target_dir_created` observation.
688pub fn plan_target_dir_create_intent(plan_created: bool) -> TargetDirCreateIntent {
689    if plan_created {
690        TargetDirCreateIntent::AttemptCreate
691    } else {
692        TargetDirCreateIntent::AdoptOnly
693    }
694}
695
696/// Outcome of a pure-facing `create_dir` attempt the caller reports.
697#[derive(Debug, Clone, Copy, PartialEq, Eq)]
698pub enum CreateDirAttempt {
699    /// `create_dir` returned Ok — this start owns the leaf.
700    Created,
701    /// Leaf already exists — must re-validate and possibly adopt empty.
702    AlreadyExists,
703}
704
705/// Observed shape of the worktree leaf (caller supplies FS facts).
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707pub enum TargetLeafShape {
708    /// Path does not exist.
709    Absent,
710    /// Real empty directory (non-symlink).
711    EmptyDirectory,
712    /// Real non-empty directory.
713    NonEmptyDirectory,
714    /// Symlink leaf.
715    Symlink,
716    /// Regular file or other non-directory.
717    NotDirectory,
718}
719
720/// Why a leaf cannot be adopted as an empty directory claim.
721#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub enum TargetLeafRefusal {
723    DoesNotExist,
724    IsSymlink,
725    NotDirectory,
726    NotEmpty,
727}
728
729impl TargetLeafRefusal {
730    /// Reason fragment for CLI `target_dir_shape_refusal` messages.
731    pub fn as_reason_str(self) -> &'static str {
732        match self {
733            Self::DoesNotExist => "does not exist",
734            Self::IsSymlink => "is a symlink",
735            Self::NotDirectory => "is not a directory",
736            Self::NotEmpty => "is not empty",
737        }
738    }
739}
740
741/// Pure: classify metadata facts into a leaf shape (emptiness supplied when dir).
742///
743/// `is_empty` is only consulted when the leaf is a real directory; callers may
744/// pass `false` when they have not inspected children for non-dirs.
745pub fn classify_target_leaf_shape(
746    exists: bool,
747    is_symlink: bool,
748    is_dir: bool,
749    is_empty: bool,
750) -> TargetLeafShape {
751    if !exists {
752        return TargetLeafShape::Absent;
753    }
754    if is_symlink {
755        return TargetLeafShape::Symlink;
756    }
757    if !is_dir {
758        return TargetLeafShape::NotDirectory;
759    }
760    if is_empty {
761        TargetLeafShape::EmptyDirectory
762    } else {
763        TargetLeafShape::NonEmptyDirectory
764    }
765}
766
767/// Pure empty-dir adoption gate: only [`TargetLeafShape::EmptyDirectory`] ok.
768pub fn validate_empty_dir_adoption(shape: TargetLeafShape) -> Result<(), TargetLeafRefusal> {
769    match shape {
770        TargetLeafShape::EmptyDirectory => Ok(()),
771        TargetLeafShape::Absent => Err(TargetLeafRefusal::DoesNotExist),
772        TargetLeafShape::Symlink => Err(TargetLeafRefusal::IsSymlink),
773        TargetLeafShape::NotDirectory => Err(TargetLeafRefusal::NotDirectory),
774        TargetLeafShape::NonEmptyDirectory => Err(TargetLeafRefusal::NotEmpty),
775    }
776}
777
778/// Pure claim kind after a successful create or a successful empty-dir adopt.
779pub fn claim_kind_for_create_attempt(attempt: CreateDirAttempt) -> Option<TargetDirClaimKind> {
780    match attempt {
781        CreateDirAttempt::Created => Some(TargetDirClaimKind::Created),
782        // AlreadyExists still requires adoption validation; kind is decided then.
783        CreateDirAttempt::AlreadyExists => None,
784    }
785}
786
787/// Pure claim kind once empty-dir adoption is validated.
788pub fn claim_kind_after_empty_dir_adoption() -> TargetDirClaimKind {
789    TargetDirClaimKind::AdoptedEmpty
790}
791
792/// Pure: whether a settled claim is present (writers/stage_checkout require it).
793pub fn require_established_claim(
794    claim: Option<TargetDirClaimKind>,
795) -> Result<TargetDirClaimKind, TargetLeafRefusal> {
796    claim.ok_or(TargetLeafRefusal::DoesNotExist)
797}
798
799// ---------------------------------------------------------------------------
800// Effect staging preconditions
801// ---------------------------------------------------------------------------
802
803/// Caller-supplied facts for pure start-effect staging gates.
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805pub struct StartEffectStagingFacts {
806    /// Whether [`TargetDirClaimKind`] was established by create-target-dir.
807    pub claim_established: bool,
808    /// Whether a shared-target redirect dir was supplied for this start.
809    pub has_shared_target_dir: bool,
810}
811
812/// Why a planned effect must not run its FS forward yet.
813#[derive(Debug, Clone, Copy, PartialEq, Eq)]
814pub enum StartEffectPreconditionError {
815    /// Effect writes through the claimed checkout but claim is missing.
816    ClaimNotEstablished,
817    /// Cargo-config redirect is in the plan but no shared-target dir was given.
818    SharedTargetDirMissing,
819}
820
821impl std::fmt::Display for StartEffectPreconditionError {
822    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
823        match self {
824            Self::ClaimNotEstablished => {
825                write!(f, "start effect requires an established target-dir claim")
826            }
827            Self::SharedTargetDirMissing => write!(
828                f,
829                "start plan includes cargo-config redirect but no shared_target_dir"
830            ),
831        }
832    }
833}
834
835impl std::error::Error for StartEffectPreconditionError {}
836
837/// Whether this effect writes through the claimed checkout leaf.
838pub fn effect_requires_established_claim(effect: StartEffectKind) -> bool {
839    matches!(
840        effect,
841        StartEffectKind::MaterializeCheckout
842            | StartEffectKind::WriteManifest
843            | StartEffectKind::WriteCargoConfigRedirect
844            | StartEffectKind::HydrateIgnoredDirs
845    )
846}
847
848/// Pure preconditions an effect needs before its FS forward may run.
849///
850/// Mirrors CLI `start_atomic` apply gates: claim-using writers refuse when the
851/// create-target-dir step never established a claim; cargo-config refuses when
852/// the plan includes a redirect without a shared-target dir.
853pub fn validate_start_effect_preconditions(
854    effect: StartEffectKind,
855    facts: StartEffectStagingFacts,
856) -> Result<(), StartEffectPreconditionError> {
857    if effect_requires_established_claim(effect) && !facts.claim_established {
858        return Err(StartEffectPreconditionError::ClaimNotEstablished);
859    }
860    if matches!(effect, StartEffectKind::WriteCargoConfigRedirect) && !facts.has_shared_target_dir {
861        return Err(StartEffectPreconditionError::SharedTargetDirMissing);
862    }
863    Ok(())
864}
865
866// ---------------------------------------------------------------------------
867// Cleanup / rewind step lists from applied effects
868// ---------------------------------------------------------------------------
869
870/// One reverse-order cleanup action implied by a successfully applied effect.
871///
872/// Order from [`plan_start_cleanup`] is reverse-apply (last applied first),
873/// matching the atomic mutation rewind ledger. FS/ref execution stays CLI-owned.
874#[derive(Debug, Clone, PartialEq, Eq)]
875pub enum StartCleanupStep {
876    /// Converge thread records back to the pre-start snapshot.
877    RestoreThreadRecord,
878    /// Tear down the virtualized mount if this start established it.
879    UnmountVirtualized,
880    /// Unlink hydrate dep symlinks (+ restore the exclude file if written).
881    UnwindHydrate,
882    /// Restore prior cargo config bytes, or remove a config this start created.
883    RestoreCargoConfig,
884    /// Restore prior manifest bytes, or remove a manifest this start created.
885    RestoreManifest,
886    /// Clear per-root materialize sidecars, then apply the checkout-dir rewind.
887    RewindCheckout { plan: CheckoutRewindPlan },
888    /// CAS-guarded rollback of the thread ref to its pre-start expectation.
889    RollbackThreadRef,
890    /// Remove a self-created target dir (create-step inverse).
891    RemoveSelfCreatedDir { plan: SelfCreatedDirRewindPlan },
892}
893
894/// Build reverse cleanup steps for effects that successfully applied.
895///
896/// `applied` is forward-apply order (the subset of [`StartTransactionPlan::effects`]
897/// whose forwards completed). `target_claim` is the settled claim from the
898/// create-target-dir step (`None` when that step refused or never ran).
899///
900/// Partial starts are expressible by passing only the applied prefix — e.g. a
901/// failure mid-hydrate yields applied effects through `HydrateIgnoredDirs` and
902/// rewinds every hydrate link plus the checkout.
903pub fn plan_start_cleanup(
904    applied: &[StartEffectKind],
905    target_claim: Option<TargetDirClaimKind>,
906) -> Vec<StartCleanupStep> {
907    let checkout_plan = plan_checkout_rewind(target_claim);
908    let self_created_plan = plan_self_created_dir_rewind(target_claim);
909    let mut steps = Vec::with_capacity(applied.len());
910    for effect in applied.iter().rev() {
911        match effect {
912            StartEffectKind::WriteThreadRecord => {
913                steps.push(StartCleanupStep::RestoreThreadRecord);
914            }
915            StartEffectKind::EstablishVirtualizedMount => {
916                steps.push(StartCleanupStep::UnmountVirtualized);
917            }
918            StartEffectKind::HydrateIgnoredDirs => {
919                steps.push(StartCleanupStep::UnwindHydrate);
920            }
921            StartEffectKind::WriteCargoConfigRedirect => {
922                steps.push(StartCleanupStep::RestoreCargoConfig);
923            }
924            StartEffectKind::WriteManifest => {
925                steps.push(StartCleanupStep::RestoreManifest);
926            }
927            StartEffectKind::MaterializeCheckout => {
928                steps.push(StartCleanupStep::RewindCheckout {
929                    plan: checkout_plan,
930                });
931            }
932            StartEffectKind::WriteThreadRef => {
933                steps.push(StartCleanupStep::RollbackThreadRef);
934            }
935            StartEffectKind::CreateTargetDir => {
936                steps.push(StartCleanupStep::RemoveSelfCreatedDir {
937                    plan: self_created_plan,
938                });
939            }
940        }
941    }
942    steps
943}
944
945// ---------------------------------------------------------------------------
946// Mid-apply error classification
947// ---------------------------------------------------------------------------
948
949/// Classify an `anyhow` error from a materialize/hydrate helper into the
950/// [`HeddleError`] the start transaction's `Result` requires.
951///
952/// Mirrors CLI `start_atomic::apply_error` (heddle#571): must NOT blanket-wrap
953/// every failure as a `Conflict`. A plain I/O failure mid-materialize (e.g.
954/// `clonefile`/`FICLONE` ENOENT) must surface as `Io` so diagnosis and
955/// `exit::from_error` kind-keyed classification stay correct.
956///
957/// Recovery rules:
958/// - an already-structured [`HeddleError`] keeps its variant;
959/// - an error whose chain bottoms out in a `std::io::Error` becomes
960///   [`HeddleError::Io`], preserving both the original `ErrorKind` and the
961///   full `anyhow` context (`{err:#}`) as the message;
962/// - only a genuinely-unclassifiable error falls back to
963///   [`HeddleError::Conflict`].
964pub fn classify_materialize_error(err: anyhow::Error) -> HeddleError {
965    match err.downcast::<HeddleError>() {
966        Ok(heddle) => heddle,
967        Err(err) => match err
968            .downcast_ref::<std::io::Error>()
969            .map(std::io::Error::kind)
970        {
971            Some(kind) => HeddleError::Io(std::io::Error::new(kind, format!("{err:#}"))),
972            None => HeddleError::Conflict(format!("{err:#}")),
973        },
974    }
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    #[test]
982    fn plan_checkout_path_honors_explicit_for_bytes_modes() {
983        let managed = PathBuf::from("/repo/.heddle/threads/a/repo");
984        let explicit = PathBuf::from("/tmp/work");
985
986        let solid = plan_checkout_path(&ThreadMode::Solid, Some(explicit.clone()), managed.clone());
987        assert_eq!(solid.path, explicit);
988        assert!(solid.from_explicit_path);
989
990        let materialized = plan_checkout_path(&ThreadMode::Materialized, None, managed.clone());
991        assert_eq!(materialized.path, managed);
992        assert!(!materialized.from_explicit_path);
993    }
994
995    #[test]
996    fn plan_checkout_path_virtualized_ignores_explicit() {
997        let managed = PathBuf::from("/repo/.heddle/threads/v/repo");
998        let plan = plan_checkout_path(
999            &ThreadMode::Virtualized,
1000            Some(PathBuf::from("/tmp/user-named")),
1001            managed.clone(),
1002        );
1003        assert_eq!(plan.path, managed);
1004        assert!(!plan.from_explicit_path);
1005    }
1006
1007    #[test]
1008    fn copy_policy_matches_mode() {
1009        assert_eq!(
1010            plan_checkout_copy_policy(&ThreadMode::Materialized),
1011            CheckoutCopyPolicy::PreferReflink
1012        );
1013        assert_eq!(
1014            plan_checkout_copy_policy(&ThreadMode::Solid),
1015            CheckoutCopyPolicy::FullCopy
1016        );
1017        assert_eq!(
1018            plan_checkout_copy_policy(&ThreadMode::Virtualized),
1019            CheckoutCopyPolicy::None
1020        );
1021    }
1022
1023    #[test]
1024    fn warn_only_for_explicit_materialized_without_reflink() {
1025        assert!(should_warn_materialized_without_reflink(true, false));
1026        assert!(!should_warn_materialized_without_reflink(true, true));
1027        assert!(!should_warn_materialized_without_reflink(false, false));
1028    }
1029
1030    #[test]
1031    fn shared_target_redirect_decisions() {
1032        assert_eq!(
1033            plan_shared_target_redirect(true, &ThreadMode::Materialized, true),
1034            SharedTargetRedirectDecision::Apply
1035        );
1036        assert_eq!(
1037            plan_shared_target_redirect(true, &ThreadMode::Solid, false),
1038            SharedTargetRedirectDecision::SkipNonRustWorkspace
1039        );
1040        assert_eq!(
1041            plan_shared_target_redirect(true, &ThreadMode::Virtualized, true),
1042            SharedTargetRedirectDecision::NotApplicable
1043        );
1044        assert_eq!(
1045            plan_shared_target_redirect(false, &ThreadMode::Materialized, true),
1046            SharedTargetRedirectDecision::NotApplicable
1047        );
1048        assert!(shared_target_redirect_applies(
1049            SharedTargetRedirectDecision::Apply
1050        ));
1051        assert!(!shared_target_redirect_applies(
1052            SharedTargetRedirectDecision::SkipNonRustWorkspace
1053        ));
1054    }
1055
1056    #[test]
1057    fn shared_target_advisory_requires_busy_heavy_without_flag() {
1058        assert!(shared_target_workspace_is_busy(true, 1));
1059        assert!(!shared_target_workspace_is_busy(true, 0));
1060        assert!(!shared_target_workspace_is_busy(false, 5));
1061
1062        assert!(should_advise_shared_target(
1063            false,
1064            &ThreadMode::Materialized,
1065            true
1066        ));
1067        assert!(should_advise_shared_target(false, &ThreadMode::Solid, true));
1068        assert!(!should_advise_shared_target(
1069            true,
1070            &ThreadMode::Materialized,
1071            true
1072        ));
1073        assert!(!should_advise_shared_target(
1074            false,
1075            &ThreadMode::Virtualized,
1076            true
1077        ));
1078        assert!(!should_advise_shared_target(
1079            false,
1080            &ThreadMode::Materialized,
1081            false
1082        ));
1083    }
1084
1085    #[test]
1086    fn plan_hydrate_and_manifest_gate_on_mode() {
1087        assert!(plan_hydrate(true, &ThreadMode::Solid));
1088        assert!(plan_hydrate(true, &ThreadMode::Materialized));
1089        assert!(!plan_hydrate(true, &ThreadMode::Virtualized));
1090        assert!(!plan_hydrate(false, &ThreadMode::Solid));
1091
1092        assert!(plan_write_manifest(&ThreadMode::Materialized));
1093        assert!(!plan_write_manifest(&ThreadMode::Solid));
1094        assert!(!plan_write_manifest(&ThreadMode::Virtualized));
1095    }
1096
1097    #[test]
1098    fn materialize_steps_materialized_with_shared_and_hydrate() {
1099        let plan = plan_thread_materialize(&ThreadMode::Materialized, true, true);
1100        assert_eq!(plan.copy_policy, CheckoutCopyPolicy::PreferReflink);
1101        assert!(plan.write_manifest);
1102        assert!(plan.apply_shared_target);
1103        assert!(plan.hydrate);
1104        assert!(!plan.virtualized_mount);
1105        assert_eq!(
1106            plan.steps,
1107            vec![
1108                MaterializeStep::CreateTargetDir,
1109                MaterializeStep::WriteThreadRef,
1110                MaterializeStep::MaterializeCheckout {
1111                    copy_policy: CheckoutCopyPolicy::PreferReflink
1112                },
1113                MaterializeStep::WriteManifest,
1114                MaterializeStep::WriteCargoConfigRedirect,
1115                MaterializeStep::HydrateIgnoredDirs,
1116                MaterializeStep::WriteThreadRecord,
1117            ]
1118        );
1119    }
1120
1121    #[test]
1122    fn materialize_steps_solid_minimal() {
1123        let steps = plan_materialize_steps(&ThreadMode::Solid, false, false);
1124        assert_eq!(
1125            steps,
1126            vec![
1127                MaterializeStep::CreateTargetDir,
1128                MaterializeStep::WriteThreadRef,
1129                MaterializeStep::MaterializeCheckout {
1130                    copy_policy: CheckoutCopyPolicy::FullCopy
1131                },
1132                MaterializeStep::WriteThreadRecord,
1133            ]
1134        );
1135    }
1136
1137    #[test]
1138    fn materialize_steps_virtualized() {
1139        let plan = plan_thread_materialize(&ThreadMode::Virtualized, true, true);
1140        assert_eq!(plan.copy_policy, CheckoutCopyPolicy::None);
1141        assert!(!plan.write_manifest);
1142        assert!(
1143            !plan.apply_shared_target,
1144            "virtualized ignores shared-target"
1145        );
1146        assert!(!plan.hydrate, "virtualized ignores hydrate");
1147        assert!(plan.virtualized_mount);
1148        assert_eq!(
1149            plan.steps,
1150            vec![
1151                MaterializeStep::CreateTargetDir,
1152                MaterializeStep::WriteThreadRef,
1153                MaterializeStep::EstablishVirtualizedMount,
1154                MaterializeStep::WriteThreadRecord,
1155            ]
1156        );
1157    }
1158
1159    #[test]
1160    fn mode_is_bytes_on_disk_predicate() {
1161        assert!(mode_is_bytes_on_disk(&ThreadMode::Solid));
1162        assert!(mode_is_bytes_on_disk(&ThreadMode::Materialized));
1163        assert!(!mode_is_bytes_on_disk(&ThreadMode::Virtualized));
1164    }
1165
1166    #[test]
1167    fn start_transaction_plan_matches_materialize_steps() {
1168        let plan = plan_start_transaction(&ThreadMode::Materialized, true, true);
1169        assert_eq!(plan.copy_policy, CheckoutCopyPolicy::PreferReflink);
1170        assert!(plan.write_manifest);
1171        assert!(plan.apply_shared_target);
1172        assert!(plan.hydrate);
1173        assert!(!plan.virtualized_mount);
1174        assert_eq!(
1175            plan.effects,
1176            vec![
1177                StartEffectKind::CreateTargetDir,
1178                StartEffectKind::WriteThreadRef,
1179                StartEffectKind::MaterializeCheckout,
1180                StartEffectKind::WriteManifest,
1181                StartEffectKind::WriteCargoConfigRedirect,
1182                StartEffectKind::HydrateIgnoredDirs,
1183                StartEffectKind::WriteThreadRecord,
1184            ]
1185        );
1186
1187        let virtualized = plan_start_transaction(&ThreadMode::Virtualized, true, true);
1188        assert_eq!(
1189            virtualized.effects,
1190            vec![
1191                StartEffectKind::CreateTargetDir,
1192                StartEffectKind::WriteThreadRef,
1193                StartEffectKind::EstablishVirtualizedMount,
1194                StartEffectKind::WriteThreadRecord,
1195            ]
1196        );
1197        assert!(virtualized.virtualized_mount);
1198        assert!(!virtualized.apply_shared_target);
1199        assert!(!virtualized.hydrate);
1200    }
1201
1202    #[test]
1203    fn materialize_step_effect_kind_strips_payload() {
1204        assert_eq!(
1205            MaterializeStep::MaterializeCheckout {
1206                copy_policy: CheckoutCopyPolicy::FullCopy
1207            }
1208            .effect_kind(),
1209            StartEffectKind::MaterializeCheckout
1210        );
1211        assert_eq!(
1212            MaterializeStep::WriteManifest.effect_kind(),
1213            StartEffectKind::WriteManifest
1214        );
1215    }
1216
1217    #[test]
1218    fn checkout_and_self_created_rewind_plans_key_on_claim() {
1219        assert_eq!(
1220            plan_checkout_rewind(Some(TargetDirClaimKind::Created)),
1221            CheckoutRewindPlan::ClearAndRemoveDir
1222        );
1223        assert_eq!(
1224            plan_checkout_rewind(Some(TargetDirClaimKind::AdoptedEmpty)),
1225            CheckoutRewindPlan::ClearContentsOnly
1226        );
1227        assert_eq!(plan_checkout_rewind(None), CheckoutRewindPlan::TouchNothing);
1228
1229        assert_eq!(
1230            plan_self_created_dir_rewind(Some(TargetDirClaimKind::Created)),
1231            SelfCreatedDirRewindPlan::RemoveIfStillAtPath
1232        );
1233        assert_eq!(
1234            plan_self_created_dir_rewind(Some(TargetDirClaimKind::AdoptedEmpty)),
1235            SelfCreatedDirRewindPlan::TouchNothing
1236        );
1237        assert_eq!(
1238            plan_self_created_dir_rewind(None),
1239            SelfCreatedDirRewindPlan::TouchNothing
1240        );
1241    }
1242
1243    #[test]
1244    fn start_cleanup_reverses_applied_effects_for_created_claim() {
1245        let applied = plan_start_transaction(&ThreadMode::Materialized, true, true).effects;
1246        let cleanup = plan_start_cleanup(&applied, Some(TargetDirClaimKind::Created));
1247        assert_eq!(
1248            cleanup,
1249            vec![
1250                StartCleanupStep::RestoreThreadRecord,
1251                StartCleanupStep::UnwindHydrate,
1252                StartCleanupStep::RestoreCargoConfig,
1253                StartCleanupStep::RestoreManifest,
1254                StartCleanupStep::RewindCheckout {
1255                    plan: CheckoutRewindPlan::ClearAndRemoveDir
1256                },
1257                StartCleanupStep::RollbackThreadRef,
1258                StartCleanupStep::RemoveSelfCreatedDir {
1259                    plan: SelfCreatedDirRewindPlan::RemoveIfStillAtPath
1260                },
1261            ]
1262        );
1263    }
1264
1265    #[test]
1266    fn start_cleanup_partial_hydrate_with_adopted_claim() {
1267        // Applied through hydrate; record not yet written. Adopted empty user --path.
1268        let applied = [
1269            StartEffectKind::CreateTargetDir,
1270            StartEffectKind::WriteThreadRef,
1271            StartEffectKind::MaterializeCheckout,
1272            StartEffectKind::HydrateIgnoredDirs,
1273        ];
1274        let cleanup = plan_start_cleanup(&applied, Some(TargetDirClaimKind::AdoptedEmpty));
1275        assert_eq!(
1276            cleanup,
1277            vec![
1278                StartCleanupStep::UnwindHydrate,
1279                StartCleanupStep::RewindCheckout {
1280                    plan: CheckoutRewindPlan::ClearContentsOnly
1281                },
1282                StartCleanupStep::RollbackThreadRef,
1283                StartCleanupStep::RemoveSelfCreatedDir {
1284                    plan: SelfCreatedDirRewindPlan::TouchNothing
1285                },
1286            ]
1287        );
1288    }
1289
1290    #[test]
1291    fn start_cleanup_virtualized_and_refused_claim() {
1292        let applied = plan_start_transaction(&ThreadMode::Virtualized, false, false).effects;
1293        let cleanup = plan_start_cleanup(&applied, None);
1294        assert_eq!(
1295            cleanup,
1296            vec![
1297                StartCleanupStep::RestoreThreadRecord,
1298                StartCleanupStep::UnmountVirtualized,
1299                StartCleanupStep::RollbackThreadRef,
1300                StartCleanupStep::RemoveSelfCreatedDir {
1301                    plan: SelfCreatedDirRewindPlan::TouchNothing
1302                },
1303            ]
1304        );
1305    }
1306
1307    #[test]
1308    fn classify_materialize_error_preserves_io_and_does_not_mislabel_as_conflict() {
1309        let bare_io = anyhow::Error::new(std::io::Error::new(
1310            std::io::ErrorKind::NotFound,
1311            "No such file or directory (os error 2)",
1312        ));
1313        let mapped = classify_materialize_error(bare_io);
1314        assert!(
1315            matches!(mapped, HeddleError::Io(_)),
1316            "a bare io error must surface as Io, got {mapped:?}"
1317        );
1318        assert!(
1319            !format!("{mapped}").starts_with("conflict:"),
1320            "io error must not be reported as a conflict: {mapped}"
1321        );
1322
1323        let structured_io = anyhow::Error::new(HeddleError::Io(std::io::Error::new(
1324            std::io::ErrorKind::NotFound,
1325            "No such file or directory (os error 2)",
1326        )));
1327        assert!(
1328            matches!(
1329                classify_materialize_error(structured_io),
1330                HeddleError::Io(_)
1331            ),
1332            "a propagated HeddleError::Io must keep its variant"
1333        );
1334
1335        let conflict = anyhow::Error::new(HeddleError::Conflict("real merge conflict".to_string()));
1336        assert!(
1337            matches!(
1338                classify_materialize_error(conflict),
1339                HeddleError::Conflict(_)
1340            ),
1341            "a genuine conflict must remain a conflict"
1342        );
1343    }
1344
1345    #[test]
1346    fn threads_root_path_safety_classifies_layout() {
1347        let heddle = PathBuf::from("/repo/.heddle");
1348        let threads = heddle.join("threads");
1349
1350        assert_eq!(
1351            classify_path_vs_threads_root(&threads, &heddle, &threads),
1352            ThreadsRootPathClass::ThreadsRoot
1353        );
1354        assert_eq!(
1355            classify_path_vs_threads_root(&threads.join("feat"), &heddle, &threads),
1356            ThreadsRootPathClass::BareThreadDir
1357        );
1358        assert_eq!(
1359            classify_path_vs_threads_root(&threads.join("feat").join("repo"), &heddle, &threads),
1360            ThreadsRootPathClass::ManagedCheckoutSlot
1361        );
1362        assert_eq!(
1363            classify_path_vs_threads_root(&heddle.join("objects"), &heddle, &threads),
1364            ThreadsRootPathClass::HeddleStorage
1365        );
1366        assert_eq!(
1367            classify_path_vs_threads_root(Path::new("/tmp/work"), &heddle, &threads),
1368            ThreadsRootPathClass::OutsideHeddle
1369        );
1370
1371        assert!(threads_root_path_layout_allowed(
1372            ThreadsRootPathClass::ManagedCheckoutSlot
1373        ));
1374        assert!(threads_root_path_layout_allowed(
1375            ThreadsRootPathClass::OutsideHeddle
1376        ));
1377        assert!(!threads_root_path_layout_allowed(
1378            ThreadsRootPathClass::ThreadsRoot
1379        ));
1380        assert!(!threads_root_path_layout_allowed(
1381            ThreadsRootPathClass::BareThreadDir
1382        ));
1383        assert!(!threads_root_path_layout_allowed(
1384            ThreadsRootPathClass::HeddleStorage
1385        ));
1386    }
1387
1388    #[test]
1389    fn threads_root_path_safety_rejects_nested_reserved() {
1390        let heddle = PathBuf::from("/repo/.heddle");
1391        let threads = heddle.join("threads");
1392        let thread_dir = threads.join("feat");
1393        let checkout = thread_dir.join("repo");
1394        let nested = checkout.join("nested");
1395
1396        assert!(path_is_nested_in_reserved_region(
1397            &nested,
1398            &thread_dir,
1399            Some(checkout.as_path())
1400        ));
1401        assert!(!path_is_nested_in_reserved_region(
1402            &checkout,
1403            &thread_dir,
1404            Some(checkout.as_path())
1405        ));
1406
1407        let err = validate_threads_root_path_safety(
1408            &nested,
1409            &heddle,
1410            &threads,
1411            &[(thread_dir.clone(), Some(checkout.clone()))],
1412        )
1413        .unwrap_err();
1414        assert!(matches!(
1415            err,
1416            ThreadsRootPathSafetyError::NestedInReserved { .. }
1417        ));
1418
1419        assert!(
1420            validate_threads_root_path_safety(
1421                &checkout,
1422                &heddle,
1423                &threads,
1424                &[(thread_dir, Some(checkout.clone()))],
1425            )
1426            .is_ok()
1427        );
1428    }
1429
1430    #[test]
1431    fn append_safe_relative_components_refuses_escape() {
1432        let base = PathBuf::from("/resolved/ancestor");
1433        assert_eq!(
1434            append_safe_relative_components(base.clone(), Path::new("a/b")).unwrap(),
1435            PathBuf::from("/resolved/ancestor/a/b")
1436        );
1437        assert_eq!(
1438            append_safe_relative_components(base.clone(), Path::new("a/./b")).unwrap(),
1439            PathBuf::from("/resolved/ancestor/a/b")
1440        );
1441        assert_eq!(
1442            append_safe_relative_components(base.clone(), Path::new("a/../b")).unwrap_err(),
1443            RelativePathNormalizeError::UnsafeComponent
1444        );
1445        assert!(!path_components_are_safe(Path::new("/a/../b")));
1446        assert!(path_components_are_safe(Path::new("/a/b")));
1447        assert!(path_is_strict_descendant(
1448            Path::new("/repo/.heddle/threads/x/y"),
1449            Path::new("/repo/.heddle/threads")
1450        ));
1451        assert!(!path_is_strict_descendant(
1452            Path::new("/repo/.heddle/threads"),
1453            Path::new("/repo/.heddle/threads")
1454        ));
1455    }
1456
1457    #[test]
1458    fn empty_dir_adoption_and_create_intent() {
1459        assert_eq!(
1460            plan_target_dir_create_intent(true),
1461            TargetDirCreateIntent::AttemptCreate
1462        );
1463        assert_eq!(
1464            plan_target_dir_create_intent(false),
1465            TargetDirCreateIntent::AdoptOnly
1466        );
1467
1468        assert_eq!(
1469            classify_target_leaf_shape(false, false, false, false),
1470            TargetLeafShape::Absent
1471        );
1472        assert_eq!(
1473            classify_target_leaf_shape(true, true, true, true),
1474            TargetLeafShape::Symlink
1475        );
1476        assert_eq!(
1477            classify_target_leaf_shape(true, false, false, false),
1478            TargetLeafShape::NotDirectory
1479        );
1480        assert_eq!(
1481            classify_target_leaf_shape(true, false, true, true),
1482            TargetLeafShape::EmptyDirectory
1483        );
1484        assert_eq!(
1485            classify_target_leaf_shape(true, false, true, false),
1486            TargetLeafShape::NonEmptyDirectory
1487        );
1488
1489        assert!(validate_empty_dir_adoption(TargetLeafShape::EmptyDirectory).is_ok());
1490        assert_eq!(
1491            validate_empty_dir_adoption(TargetLeafShape::NonEmptyDirectory).unwrap_err(),
1492            TargetLeafRefusal::NotEmpty
1493        );
1494        assert_eq!(
1495            validate_empty_dir_adoption(TargetLeafShape::Symlink).unwrap_err(),
1496            TargetLeafRefusal::IsSymlink
1497        );
1498        assert_eq!(TargetLeafRefusal::NotEmpty.as_reason_str(), "is not empty");
1499
1500        assert_eq!(
1501            claim_kind_for_create_attempt(CreateDirAttempt::Created),
1502            Some(TargetDirClaimKind::Created)
1503        );
1504        assert_eq!(
1505            claim_kind_for_create_attempt(CreateDirAttempt::AlreadyExists),
1506            None
1507        );
1508        assert_eq!(
1509            claim_kind_after_empty_dir_adoption(),
1510            TargetDirClaimKind::AdoptedEmpty
1511        );
1512        assert!(require_established_claim(Some(TargetDirClaimKind::Created)).is_ok());
1513        assert!(require_established_claim(None).is_err());
1514    }
1515
1516    #[test]
1517    fn effect_staging_preconditions_gate_claim_and_shared_target() {
1518        let no_claim = StartEffectStagingFacts {
1519            claim_established: false,
1520            has_shared_target_dir: true,
1521        };
1522        let with_claim = StartEffectStagingFacts {
1523            claim_established: true,
1524            has_shared_target_dir: false,
1525        };
1526        let full = StartEffectStagingFacts {
1527            claim_established: true,
1528            has_shared_target_dir: true,
1529        };
1530
1531        assert!(
1532            validate_start_effect_preconditions(StartEffectKind::CreateTargetDir, no_claim).is_ok()
1533        );
1534        assert!(
1535            validate_start_effect_preconditions(StartEffectKind::WriteThreadRef, no_claim).is_ok()
1536        );
1537        assert_eq!(
1538            validate_start_effect_preconditions(StartEffectKind::MaterializeCheckout, no_claim)
1539                .unwrap_err(),
1540            StartEffectPreconditionError::ClaimNotEstablished
1541        );
1542        assert_eq!(
1543            validate_start_effect_preconditions(
1544                StartEffectKind::WriteCargoConfigRedirect,
1545                with_claim
1546            )
1547            .unwrap_err(),
1548            StartEffectPreconditionError::SharedTargetDirMissing
1549        );
1550        assert!(
1551            validate_start_effect_preconditions(StartEffectKind::WriteCargoConfigRedirect, full)
1552                .is_ok()
1553        );
1554        assert!(effect_requires_established_claim(
1555            StartEffectKind::MaterializeCheckout
1556        ));
1557        assert!(!effect_requires_established_claim(
1558            StartEffectKind::CreateTargetDir
1559        ));
1560    }
1561
1562    #[test]
1563    fn classify_materialize_error_preserves_context_when_reclassifying_io() {
1564        use anyhow::Context as _;
1565
1566        let with_ctx = Err::<(), _>(std::io::Error::new(
1567            std::io::ErrorKind::PermissionDenied,
1568            "os error 13",
1569        ))
1570        .context("writing .cargo/config.toml to /work/.cargo/config.toml")
1571        .unwrap_err();
1572
1573        let mapped = classify_materialize_error(with_ctx);
1574        assert!(
1575            matches!(&mapped, HeddleError::Io(io) if io.kind() == std::io::ErrorKind::PermissionDenied),
1576            "io kind must survive reclassification, got {mapped:?}"
1577        );
1578        let msg = format!("{mapped}");
1579        assert!(
1580            msg.contains(".cargo/config.toml") && msg.contains("writing"),
1581            "reclassified io error must retain the path/action context: {msg}"
1582        );
1583    }
1584}