use std::path::{Component, Path, PathBuf};
use objects::HeddleError;
use repo::ThreadMode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutPathPlan {
pub path: PathBuf,
pub from_explicit_path: bool,
}
pub fn plan_checkout_path(
mode: &ThreadMode,
explicit_path: Option<PathBuf>,
managed_default: PathBuf,
) -> CheckoutPathPlan {
match mode {
ThreadMode::Virtualized => CheckoutPathPlan {
path: managed_default,
from_explicit_path: false,
},
ThreadMode::Materialized | ThreadMode::Solid => match explicit_path {
Some(path) => CheckoutPathPlan {
path,
from_explicit_path: true,
},
None => CheckoutPathPlan {
path: managed_default,
from_explicit_path: false,
},
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckoutCopyPolicy {
PreferReflink,
FullCopy,
None,
}
pub fn plan_checkout_copy_policy(mode: &ThreadMode) -> CheckoutCopyPolicy {
match mode {
ThreadMode::Materialized => CheckoutCopyPolicy::PreferReflink,
ThreadMode::Solid => CheckoutCopyPolicy::FullCopy,
ThreadMode::Virtualized => CheckoutCopyPolicy::None,
}
}
pub fn should_warn_materialized_without_reflink(
explicit_materialized_request: bool,
supports_reflink: bool,
) -> bool {
explicit_materialized_request && !supports_reflink
}
pub const ADVISORY_ACTIVE_HEAVY_THREAD_THRESHOLD: usize = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SharedTargetRedirectDecision {
Apply,
SkipNonRustWorkspace,
NotApplicable,
}
pub fn plan_shared_target_redirect(
requested: bool,
mode: &ThreadMode,
is_rust_workspace: bool,
) -> SharedTargetRedirectDecision {
if !requested || !mode_is_bytes_on_disk(mode) {
return SharedTargetRedirectDecision::NotApplicable;
}
if is_rust_workspace {
SharedTargetRedirectDecision::Apply
} else {
SharedTargetRedirectDecision::SkipNonRustWorkspace
}
}
pub fn shared_target_redirect_applies(decision: SharedTargetRedirectDecision) -> bool {
matches!(decision, SharedTargetRedirectDecision::Apply)
}
pub fn shared_target_workspace_is_busy(
is_rust_workspace: bool,
active_heavy_thread_count: usize,
) -> bool {
is_rust_workspace && active_heavy_thread_count >= ADVISORY_ACTIVE_HEAVY_THREAD_THRESHOLD
}
pub fn should_advise_shared_target(
shared_target_requested: bool,
mode: &ThreadMode,
workspace_is_busy: bool,
) -> bool {
!shared_target_requested && mode_is_bytes_on_disk(mode) && workspace_is_busy
}
pub fn mode_is_bytes_on_disk(mode: &ThreadMode) -> bool {
matches!(mode, ThreadMode::Solid | ThreadMode::Materialized)
}
pub fn plan_hydrate(hydrate_requested: bool, mode: &ThreadMode) -> bool {
hydrate_requested && mode_is_bytes_on_disk(mode)
}
pub fn plan_write_manifest(mode: &ThreadMode) -> bool {
matches!(mode, ThreadMode::Materialized)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MaterializeStep {
CreateTargetDir,
WriteThreadRef,
MaterializeCheckout { copy_policy: CheckoutCopyPolicy },
WriteManifest,
WriteCargoConfigRedirect,
HydrateIgnoredDirs,
EstablishVirtualizedMount,
WriteThreadRecord,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThreadMaterializePlan {
pub steps: Vec<MaterializeStep>,
pub copy_policy: CheckoutCopyPolicy,
pub write_manifest: bool,
pub apply_shared_target: bool,
pub hydrate: bool,
pub virtualized_mount: bool,
}
pub fn plan_thread_materialize(
mode: &ThreadMode,
apply_shared_target: bool,
hydrate_requested: bool,
) -> ThreadMaterializePlan {
let copy_policy = plan_checkout_copy_policy(mode);
let write_manifest = plan_write_manifest(mode);
let hydrate = plan_hydrate(hydrate_requested, mode);
let virtualized_mount = matches!(mode, ThreadMode::Virtualized);
let apply_shared_target = apply_shared_target && mode_is_bytes_on_disk(mode);
let mut steps = vec![
MaterializeStep::CreateTargetDir,
MaterializeStep::WriteThreadRef,
];
match mode {
ThreadMode::Solid | ThreadMode::Materialized => {
steps.push(MaterializeStep::MaterializeCheckout { copy_policy });
if write_manifest {
steps.push(MaterializeStep::WriteManifest);
}
if apply_shared_target {
steps.push(MaterializeStep::WriteCargoConfigRedirect);
}
if hydrate {
steps.push(MaterializeStep::HydrateIgnoredDirs);
}
}
ThreadMode::Virtualized => {
steps.push(MaterializeStep::EstablishVirtualizedMount);
}
}
steps.push(MaterializeStep::WriteThreadRecord);
ThreadMaterializePlan {
steps,
copy_policy,
write_manifest,
apply_shared_target,
hydrate,
virtualized_mount,
}
}
pub fn plan_materialize_steps(
mode: &ThreadMode,
apply_shared_target: bool,
hydrate_requested: bool,
) -> Vec<MaterializeStep> {
plan_thread_materialize(mode, apply_shared_target, hydrate_requested).steps
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartEffectKind {
CreateTargetDir,
WriteThreadRef,
MaterializeCheckout,
WriteManifest,
WriteCargoConfigRedirect,
HydrateIgnoredDirs,
EstablishVirtualizedMount,
WriteThreadRecord,
}
impl MaterializeStep {
pub fn effect_kind(&self) -> StartEffectKind {
match self {
Self::CreateTargetDir => StartEffectKind::CreateTargetDir,
Self::WriteThreadRef => StartEffectKind::WriteThreadRef,
Self::MaterializeCheckout { .. } => StartEffectKind::MaterializeCheckout,
Self::WriteManifest => StartEffectKind::WriteManifest,
Self::WriteCargoConfigRedirect => StartEffectKind::WriteCargoConfigRedirect,
Self::HydrateIgnoredDirs => StartEffectKind::HydrateIgnoredDirs,
Self::EstablishVirtualizedMount => StartEffectKind::EstablishVirtualizedMount,
Self::WriteThreadRecord => StartEffectKind::WriteThreadRecord,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartTransactionPlan {
pub effects: Vec<StartEffectKind>,
pub copy_policy: CheckoutCopyPolicy,
pub write_manifest: bool,
pub apply_shared_target: bool,
pub hydrate: bool,
pub virtualized_mount: bool,
}
pub fn plan_start_transaction(
mode: &ThreadMode,
apply_shared_target: bool,
hydrate_requested: bool,
) -> StartTransactionPlan {
let materialize = plan_thread_materialize(mode, apply_shared_target, hydrate_requested);
StartTransactionPlan {
effects: materialize
.steps
.iter()
.map(MaterializeStep::effect_kind)
.collect(),
copy_policy: materialize.copy_policy,
write_manifest: materialize.write_manifest,
apply_shared_target: materialize.apply_shared_target,
hydrate: materialize.hydrate,
virtualized_mount: materialize.virtualized_mount,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetDirClaimKind {
Created,
AdoptedEmpty,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckoutRewindPlan {
ClearAndRemoveDir,
ClearContentsOnly,
TouchNothing,
}
pub fn plan_checkout_rewind(claim: Option<TargetDirClaimKind>) -> CheckoutRewindPlan {
match claim {
Some(TargetDirClaimKind::Created) => CheckoutRewindPlan::ClearAndRemoveDir,
Some(TargetDirClaimKind::AdoptedEmpty) => CheckoutRewindPlan::ClearContentsOnly,
None => CheckoutRewindPlan::TouchNothing,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelfCreatedDirRewindPlan {
RemoveIfStillAtPath,
TouchNothing,
}
pub fn plan_self_created_dir_rewind(claim: Option<TargetDirClaimKind>) -> SelfCreatedDirRewindPlan {
match claim {
Some(TargetDirClaimKind::Created) => SelfCreatedDirRewindPlan::RemoveIfStillAtPath,
Some(TargetDirClaimKind::AdoptedEmpty) | None => SelfCreatedDirRewindPlan::TouchNothing,
}
}
pub fn path_is_under_or_equal(path: &Path, root: &Path) -> bool {
path == root || path.starts_with(root)
}
pub fn path_is_strict_descendant(path: &Path, root: &Path) -> bool {
path != root && path.starts_with(root)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThreadsRootPathClass {
ManagedCheckoutSlot,
ThreadsRoot,
BareThreadDir,
HeddleStorage,
OutsideHeddle,
}
pub fn classify_path_vs_threads_root(
path: &Path,
heddle_dir: &Path,
threads_root: &Path,
) -> ThreadsRootPathClass {
if path_is_under_or_equal(path, threads_root) {
if path == threads_root {
return ThreadsRootPathClass::ThreadsRoot;
}
if path.parent() == Some(threads_root) {
return ThreadsRootPathClass::BareThreadDir;
}
return ThreadsRootPathClass::ManagedCheckoutSlot;
}
if path_is_under_or_equal(path, heddle_dir) {
return ThreadsRootPathClass::HeddleStorage;
}
ThreadsRootPathClass::OutsideHeddle
}
pub fn threads_root_path_layout_allowed(class: ThreadsRootPathClass) -> bool {
matches!(
class,
ThreadsRootPathClass::ManagedCheckoutSlot | ThreadsRootPathClass::OutsideHeddle
)
}
pub fn validate_threads_root_path_safety(
path: &Path,
heddle_dir: &Path,
threads_root: &Path,
reserved_regions: &[(PathBuf, Option<PathBuf>)],
) -> Result<(), ThreadsRootPathSafetyError> {
let class = classify_path_vs_threads_root(path, heddle_dir, threads_root);
match class {
ThreadsRootPathClass::ThreadsRoot => Err(ThreadsRootPathSafetyError::IsThreadsRoot {
path: path.to_path_buf(),
}),
ThreadsRootPathClass::BareThreadDir => Err(ThreadsRootPathSafetyError::IsBareThreadDir {
path: path.to_path_buf(),
}),
ThreadsRootPathClass::HeddleStorage => Err(ThreadsRootPathSafetyError::IsHeddleStorage {
path: path.to_path_buf(),
}),
ThreadsRootPathClass::OutsideHeddle => Ok(()),
ThreadsRootPathClass::ManagedCheckoutSlot => {
for (region, exempt) in reserved_regions {
if path_is_nested_in_reserved_region(path, region, exempt.as_deref()) {
return Err(ThreadsRootPathSafetyError::NestedInReserved {
path: path.to_path_buf(),
reserved: region.clone(),
});
}
}
Ok(())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ThreadsRootPathSafetyError {
IsThreadsRoot { path: PathBuf },
IsBareThreadDir { path: PathBuf },
IsHeddleStorage { path: PathBuf },
NestedInReserved { path: PathBuf, reserved: PathBuf },
}
impl std::fmt::Display for ThreadsRootPathSafetyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::IsThreadsRoot { path } => write!(
f,
"worktree target '{}' is the threads root (not a per-thread leaf)",
path.display()
),
Self::IsBareThreadDir { path } => write!(
f,
"worktree target '{}' is a bare thread dir (checkout leaf required)",
path.display()
),
Self::IsHeddleStorage { path } => write!(
f,
"worktree target '{}' is under heddle storage (outside threads/)",
path.display()
),
Self::NestedInReserved { path, reserved } => write!(
f,
"worktree target '{}' is nested inside reserved region '{}'",
path.display(),
reserved.display()
),
}
}
}
impl std::error::Error for ThreadsRootPathSafetyError {}
pub fn path_is_nested_in_reserved_region(
candidate: &Path,
reserved_dir: &Path,
exempt_exact: Option<&Path>,
) -> bool {
if !candidate.starts_with(reserved_dir) {
return false;
}
if let Some(exempt) = exempt_exact
&& candidate == exempt
{
return false;
}
true
}
pub fn path_components_are_safe(path: &Path) -> bool {
!path.components().any(|c| matches!(c, Component::ParentDir))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelativePathNormalizeError {
UnsafeComponent,
}
impl std::fmt::Display for RelativePathNormalizeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsafeComponent => {
write!(f, "path remainder contains an unsafe path component")
}
}
}
}
impl std::error::Error for RelativePathNormalizeError {}
pub fn append_safe_relative_components(
mut base: PathBuf,
remainder: &Path,
) -> Result<PathBuf, RelativePathNormalizeError> {
for component in remainder.components() {
match component {
Component::Normal(part) => base.push(part),
Component::CurDir => {}
Component::ParentDir | Component::Prefix(_) | Component::RootDir => {
return Err(RelativePathNormalizeError::UnsafeComponent);
}
}
}
Ok(base)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetDirCreateIntent {
AttemptCreate,
AdoptOnly,
}
pub fn plan_target_dir_create_intent(plan_created: bool) -> TargetDirCreateIntent {
if plan_created {
TargetDirCreateIntent::AttemptCreate
} else {
TargetDirCreateIntent::AdoptOnly
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreateDirAttempt {
Created,
AlreadyExists,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetLeafShape {
Absent,
EmptyDirectory,
NonEmptyDirectory,
Symlink,
NotDirectory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetLeafRefusal {
DoesNotExist,
IsSymlink,
NotDirectory,
NotEmpty,
}
impl TargetLeafRefusal {
pub fn as_reason_str(self) -> &'static str {
match self {
Self::DoesNotExist => "does not exist",
Self::IsSymlink => "is a symlink",
Self::NotDirectory => "is not a directory",
Self::NotEmpty => "is not empty",
}
}
}
pub fn classify_target_leaf_shape(
exists: bool,
is_symlink: bool,
is_dir: bool,
is_empty: bool,
) -> TargetLeafShape {
if !exists {
return TargetLeafShape::Absent;
}
if is_symlink {
return TargetLeafShape::Symlink;
}
if !is_dir {
return TargetLeafShape::NotDirectory;
}
if is_empty {
TargetLeafShape::EmptyDirectory
} else {
TargetLeafShape::NonEmptyDirectory
}
}
pub fn validate_empty_dir_adoption(shape: TargetLeafShape) -> Result<(), TargetLeafRefusal> {
match shape {
TargetLeafShape::EmptyDirectory => Ok(()),
TargetLeafShape::Absent => Err(TargetLeafRefusal::DoesNotExist),
TargetLeafShape::Symlink => Err(TargetLeafRefusal::IsSymlink),
TargetLeafShape::NotDirectory => Err(TargetLeafRefusal::NotDirectory),
TargetLeafShape::NonEmptyDirectory => Err(TargetLeafRefusal::NotEmpty),
}
}
pub fn claim_kind_for_create_attempt(attempt: CreateDirAttempt) -> Option<TargetDirClaimKind> {
match attempt {
CreateDirAttempt::Created => Some(TargetDirClaimKind::Created),
CreateDirAttempt::AlreadyExists => None,
}
}
pub fn claim_kind_after_empty_dir_adoption() -> TargetDirClaimKind {
TargetDirClaimKind::AdoptedEmpty
}
pub fn require_established_claim(
claim: Option<TargetDirClaimKind>,
) -> Result<TargetDirClaimKind, TargetLeafRefusal> {
claim.ok_or(TargetLeafRefusal::DoesNotExist)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StartEffectStagingFacts {
pub claim_established: bool,
pub has_shared_target_dir: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartEffectPreconditionError {
ClaimNotEstablished,
SharedTargetDirMissing,
}
impl std::fmt::Display for StartEffectPreconditionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ClaimNotEstablished => {
write!(f, "start effect requires an established target-dir claim")
}
Self::SharedTargetDirMissing => write!(
f,
"start plan includes cargo-config redirect but no shared_target_dir"
),
}
}
}
impl std::error::Error for StartEffectPreconditionError {}
pub fn effect_requires_established_claim(effect: StartEffectKind) -> bool {
matches!(
effect,
StartEffectKind::MaterializeCheckout
| StartEffectKind::WriteManifest
| StartEffectKind::WriteCargoConfigRedirect
| StartEffectKind::HydrateIgnoredDirs
)
}
pub fn validate_start_effect_preconditions(
effect: StartEffectKind,
facts: StartEffectStagingFacts,
) -> Result<(), StartEffectPreconditionError> {
if effect_requires_established_claim(effect) && !facts.claim_established {
return Err(StartEffectPreconditionError::ClaimNotEstablished);
}
if matches!(effect, StartEffectKind::WriteCargoConfigRedirect) && !facts.has_shared_target_dir {
return Err(StartEffectPreconditionError::SharedTargetDirMissing);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StartCleanupStep {
RestoreThreadRecord,
UnmountVirtualized,
UnwindHydrate,
RestoreCargoConfig,
RestoreManifest,
RewindCheckout { plan: CheckoutRewindPlan },
RollbackThreadRef,
RemoveSelfCreatedDir { plan: SelfCreatedDirRewindPlan },
}
pub fn plan_start_cleanup(
applied: &[StartEffectKind],
target_claim: Option<TargetDirClaimKind>,
) -> Vec<StartCleanupStep> {
let checkout_plan = plan_checkout_rewind(target_claim);
let self_created_plan = plan_self_created_dir_rewind(target_claim);
let mut steps = Vec::with_capacity(applied.len());
for effect in applied.iter().rev() {
match effect {
StartEffectKind::WriteThreadRecord => {
steps.push(StartCleanupStep::RestoreThreadRecord);
}
StartEffectKind::EstablishVirtualizedMount => {
steps.push(StartCleanupStep::UnmountVirtualized);
}
StartEffectKind::HydrateIgnoredDirs => {
steps.push(StartCleanupStep::UnwindHydrate);
}
StartEffectKind::WriteCargoConfigRedirect => {
steps.push(StartCleanupStep::RestoreCargoConfig);
}
StartEffectKind::WriteManifest => {
steps.push(StartCleanupStep::RestoreManifest);
}
StartEffectKind::MaterializeCheckout => {
steps.push(StartCleanupStep::RewindCheckout {
plan: checkout_plan,
});
}
StartEffectKind::WriteThreadRef => {
steps.push(StartCleanupStep::RollbackThreadRef);
}
StartEffectKind::CreateTargetDir => {
steps.push(StartCleanupStep::RemoveSelfCreatedDir {
plan: self_created_plan,
});
}
}
}
steps
}
pub fn classify_materialize_error(err: anyhow::Error) -> HeddleError {
match err.downcast::<HeddleError>() {
Ok(heddle) => heddle,
Err(err) => match err
.downcast_ref::<std::io::Error>()
.map(std::io::Error::kind)
{
Some(kind) => HeddleError::Io(std::io::Error::new(kind, format!("{err:#}"))),
None => HeddleError::Conflict(format!("{err:#}")),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_checkout_path_honors_explicit_for_bytes_modes() {
let managed = PathBuf::from("/repo/.heddle/threads/a/repo");
let explicit = PathBuf::from("/tmp/work");
let solid = plan_checkout_path(&ThreadMode::Solid, Some(explicit.clone()), managed.clone());
assert_eq!(solid.path, explicit);
assert!(solid.from_explicit_path);
let materialized = plan_checkout_path(&ThreadMode::Materialized, None, managed.clone());
assert_eq!(materialized.path, managed);
assert!(!materialized.from_explicit_path);
}
#[test]
fn plan_checkout_path_virtualized_ignores_explicit() {
let managed = PathBuf::from("/repo/.heddle/threads/v/repo");
let plan = plan_checkout_path(
&ThreadMode::Virtualized,
Some(PathBuf::from("/tmp/user-named")),
managed.clone(),
);
assert_eq!(plan.path, managed);
assert!(!plan.from_explicit_path);
}
#[test]
fn copy_policy_matches_mode() {
assert_eq!(
plan_checkout_copy_policy(&ThreadMode::Materialized),
CheckoutCopyPolicy::PreferReflink
);
assert_eq!(
plan_checkout_copy_policy(&ThreadMode::Solid),
CheckoutCopyPolicy::FullCopy
);
assert_eq!(
plan_checkout_copy_policy(&ThreadMode::Virtualized),
CheckoutCopyPolicy::None
);
}
#[test]
fn warn_only_for_explicit_materialized_without_reflink() {
assert!(should_warn_materialized_without_reflink(true, false));
assert!(!should_warn_materialized_without_reflink(true, true));
assert!(!should_warn_materialized_without_reflink(false, false));
}
#[test]
fn shared_target_redirect_decisions() {
assert_eq!(
plan_shared_target_redirect(true, &ThreadMode::Materialized, true),
SharedTargetRedirectDecision::Apply
);
assert_eq!(
plan_shared_target_redirect(true, &ThreadMode::Solid, false),
SharedTargetRedirectDecision::SkipNonRustWorkspace
);
assert_eq!(
plan_shared_target_redirect(true, &ThreadMode::Virtualized, true),
SharedTargetRedirectDecision::NotApplicable
);
assert_eq!(
plan_shared_target_redirect(false, &ThreadMode::Materialized, true),
SharedTargetRedirectDecision::NotApplicable
);
assert!(shared_target_redirect_applies(
SharedTargetRedirectDecision::Apply
));
assert!(!shared_target_redirect_applies(
SharedTargetRedirectDecision::SkipNonRustWorkspace
));
}
#[test]
fn shared_target_advisory_requires_busy_heavy_without_flag() {
assert!(shared_target_workspace_is_busy(true, 1));
assert!(!shared_target_workspace_is_busy(true, 0));
assert!(!shared_target_workspace_is_busy(false, 5));
assert!(should_advise_shared_target(
false,
&ThreadMode::Materialized,
true
));
assert!(should_advise_shared_target(false, &ThreadMode::Solid, true));
assert!(!should_advise_shared_target(
true,
&ThreadMode::Materialized,
true
));
assert!(!should_advise_shared_target(
false,
&ThreadMode::Virtualized,
true
));
assert!(!should_advise_shared_target(
false,
&ThreadMode::Materialized,
false
));
}
#[test]
fn plan_hydrate_and_manifest_gate_on_mode() {
assert!(plan_hydrate(true, &ThreadMode::Solid));
assert!(plan_hydrate(true, &ThreadMode::Materialized));
assert!(!plan_hydrate(true, &ThreadMode::Virtualized));
assert!(!plan_hydrate(false, &ThreadMode::Solid));
assert!(plan_write_manifest(&ThreadMode::Materialized));
assert!(!plan_write_manifest(&ThreadMode::Solid));
assert!(!plan_write_manifest(&ThreadMode::Virtualized));
}
#[test]
fn materialize_steps_materialized_with_shared_and_hydrate() {
let plan = plan_thread_materialize(&ThreadMode::Materialized, true, true);
assert_eq!(plan.copy_policy, CheckoutCopyPolicy::PreferReflink);
assert!(plan.write_manifest);
assert!(plan.apply_shared_target);
assert!(plan.hydrate);
assert!(!plan.virtualized_mount);
assert_eq!(
plan.steps,
vec![
MaterializeStep::CreateTargetDir,
MaterializeStep::WriteThreadRef,
MaterializeStep::MaterializeCheckout {
copy_policy: CheckoutCopyPolicy::PreferReflink
},
MaterializeStep::WriteManifest,
MaterializeStep::WriteCargoConfigRedirect,
MaterializeStep::HydrateIgnoredDirs,
MaterializeStep::WriteThreadRecord,
]
);
}
#[test]
fn materialize_steps_solid_minimal() {
let steps = plan_materialize_steps(&ThreadMode::Solid, false, false);
assert_eq!(
steps,
vec![
MaterializeStep::CreateTargetDir,
MaterializeStep::WriteThreadRef,
MaterializeStep::MaterializeCheckout {
copy_policy: CheckoutCopyPolicy::FullCopy
},
MaterializeStep::WriteThreadRecord,
]
);
}
#[test]
fn materialize_steps_virtualized() {
let plan = plan_thread_materialize(&ThreadMode::Virtualized, true, true);
assert_eq!(plan.copy_policy, CheckoutCopyPolicy::None);
assert!(!plan.write_manifest);
assert!(
!plan.apply_shared_target,
"virtualized ignores shared-target"
);
assert!(!plan.hydrate, "virtualized ignores hydrate");
assert!(plan.virtualized_mount);
assert_eq!(
plan.steps,
vec![
MaterializeStep::CreateTargetDir,
MaterializeStep::WriteThreadRef,
MaterializeStep::EstablishVirtualizedMount,
MaterializeStep::WriteThreadRecord,
]
);
}
#[test]
fn mode_is_bytes_on_disk_predicate() {
assert!(mode_is_bytes_on_disk(&ThreadMode::Solid));
assert!(mode_is_bytes_on_disk(&ThreadMode::Materialized));
assert!(!mode_is_bytes_on_disk(&ThreadMode::Virtualized));
}
#[test]
fn start_transaction_plan_matches_materialize_steps() {
let plan = plan_start_transaction(&ThreadMode::Materialized, true, true);
assert_eq!(plan.copy_policy, CheckoutCopyPolicy::PreferReflink);
assert!(plan.write_manifest);
assert!(plan.apply_shared_target);
assert!(plan.hydrate);
assert!(!plan.virtualized_mount);
assert_eq!(
plan.effects,
vec![
StartEffectKind::CreateTargetDir,
StartEffectKind::WriteThreadRef,
StartEffectKind::MaterializeCheckout,
StartEffectKind::WriteManifest,
StartEffectKind::WriteCargoConfigRedirect,
StartEffectKind::HydrateIgnoredDirs,
StartEffectKind::WriteThreadRecord,
]
);
let virtualized = plan_start_transaction(&ThreadMode::Virtualized, true, true);
assert_eq!(
virtualized.effects,
vec![
StartEffectKind::CreateTargetDir,
StartEffectKind::WriteThreadRef,
StartEffectKind::EstablishVirtualizedMount,
StartEffectKind::WriteThreadRecord,
]
);
assert!(virtualized.virtualized_mount);
assert!(!virtualized.apply_shared_target);
assert!(!virtualized.hydrate);
}
#[test]
fn materialize_step_effect_kind_strips_payload() {
assert_eq!(
MaterializeStep::MaterializeCheckout {
copy_policy: CheckoutCopyPolicy::FullCopy
}
.effect_kind(),
StartEffectKind::MaterializeCheckout
);
assert_eq!(
MaterializeStep::WriteManifest.effect_kind(),
StartEffectKind::WriteManifest
);
}
#[test]
fn checkout_and_self_created_rewind_plans_key_on_claim() {
assert_eq!(
plan_checkout_rewind(Some(TargetDirClaimKind::Created)),
CheckoutRewindPlan::ClearAndRemoveDir
);
assert_eq!(
plan_checkout_rewind(Some(TargetDirClaimKind::AdoptedEmpty)),
CheckoutRewindPlan::ClearContentsOnly
);
assert_eq!(plan_checkout_rewind(None), CheckoutRewindPlan::TouchNothing);
assert_eq!(
plan_self_created_dir_rewind(Some(TargetDirClaimKind::Created)),
SelfCreatedDirRewindPlan::RemoveIfStillAtPath
);
assert_eq!(
plan_self_created_dir_rewind(Some(TargetDirClaimKind::AdoptedEmpty)),
SelfCreatedDirRewindPlan::TouchNothing
);
assert_eq!(
plan_self_created_dir_rewind(None),
SelfCreatedDirRewindPlan::TouchNothing
);
}
#[test]
fn start_cleanup_reverses_applied_effects_for_created_claim() {
let applied = plan_start_transaction(&ThreadMode::Materialized, true, true).effects;
let cleanup = plan_start_cleanup(&applied, Some(TargetDirClaimKind::Created));
assert_eq!(
cleanup,
vec![
StartCleanupStep::RestoreThreadRecord,
StartCleanupStep::UnwindHydrate,
StartCleanupStep::RestoreCargoConfig,
StartCleanupStep::RestoreManifest,
StartCleanupStep::RewindCheckout {
plan: CheckoutRewindPlan::ClearAndRemoveDir
},
StartCleanupStep::RollbackThreadRef,
StartCleanupStep::RemoveSelfCreatedDir {
plan: SelfCreatedDirRewindPlan::RemoveIfStillAtPath
},
]
);
}
#[test]
fn start_cleanup_partial_hydrate_with_adopted_claim() {
let applied = [
StartEffectKind::CreateTargetDir,
StartEffectKind::WriteThreadRef,
StartEffectKind::MaterializeCheckout,
StartEffectKind::HydrateIgnoredDirs,
];
let cleanup = plan_start_cleanup(&applied, Some(TargetDirClaimKind::AdoptedEmpty));
assert_eq!(
cleanup,
vec![
StartCleanupStep::UnwindHydrate,
StartCleanupStep::RewindCheckout {
plan: CheckoutRewindPlan::ClearContentsOnly
},
StartCleanupStep::RollbackThreadRef,
StartCleanupStep::RemoveSelfCreatedDir {
plan: SelfCreatedDirRewindPlan::TouchNothing
},
]
);
}
#[test]
fn start_cleanup_virtualized_and_refused_claim() {
let applied = plan_start_transaction(&ThreadMode::Virtualized, false, false).effects;
let cleanup = plan_start_cleanup(&applied, None);
assert_eq!(
cleanup,
vec![
StartCleanupStep::RestoreThreadRecord,
StartCleanupStep::UnmountVirtualized,
StartCleanupStep::RollbackThreadRef,
StartCleanupStep::RemoveSelfCreatedDir {
plan: SelfCreatedDirRewindPlan::TouchNothing
},
]
);
}
#[test]
fn classify_materialize_error_preserves_io_and_does_not_mislabel_as_conflict() {
let bare_io = anyhow::Error::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No such file or directory (os error 2)",
));
let mapped = classify_materialize_error(bare_io);
assert!(
matches!(mapped, HeddleError::Io(_)),
"a bare io error must surface as Io, got {mapped:?}"
);
assert!(
!format!("{mapped}").starts_with("conflict:"),
"io error must not be reported as a conflict: {mapped}"
);
let structured_io = anyhow::Error::new(HeddleError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No such file or directory (os error 2)",
)));
assert!(
matches!(
classify_materialize_error(structured_io),
HeddleError::Io(_)
),
"a propagated HeddleError::Io must keep its variant"
);
let conflict = anyhow::Error::new(HeddleError::Conflict("real merge conflict".to_string()));
assert!(
matches!(
classify_materialize_error(conflict),
HeddleError::Conflict(_)
),
"a genuine conflict must remain a conflict"
);
}
#[test]
fn threads_root_path_safety_classifies_layout() {
let heddle = PathBuf::from("/repo/.heddle");
let threads = heddle.join("threads");
assert_eq!(
classify_path_vs_threads_root(&threads, &heddle, &threads),
ThreadsRootPathClass::ThreadsRoot
);
assert_eq!(
classify_path_vs_threads_root(&threads.join("feat"), &heddle, &threads),
ThreadsRootPathClass::BareThreadDir
);
assert_eq!(
classify_path_vs_threads_root(&threads.join("feat").join("repo"), &heddle, &threads),
ThreadsRootPathClass::ManagedCheckoutSlot
);
assert_eq!(
classify_path_vs_threads_root(&heddle.join("objects"), &heddle, &threads),
ThreadsRootPathClass::HeddleStorage
);
assert_eq!(
classify_path_vs_threads_root(Path::new("/tmp/work"), &heddle, &threads),
ThreadsRootPathClass::OutsideHeddle
);
assert!(threads_root_path_layout_allowed(
ThreadsRootPathClass::ManagedCheckoutSlot
));
assert!(threads_root_path_layout_allowed(
ThreadsRootPathClass::OutsideHeddle
));
assert!(!threads_root_path_layout_allowed(
ThreadsRootPathClass::ThreadsRoot
));
assert!(!threads_root_path_layout_allowed(
ThreadsRootPathClass::BareThreadDir
));
assert!(!threads_root_path_layout_allowed(
ThreadsRootPathClass::HeddleStorage
));
}
#[test]
fn threads_root_path_safety_rejects_nested_reserved() {
let heddle = PathBuf::from("/repo/.heddle");
let threads = heddle.join("threads");
let thread_dir = threads.join("feat");
let checkout = thread_dir.join("repo");
let nested = checkout.join("nested");
assert!(path_is_nested_in_reserved_region(
&nested,
&thread_dir,
Some(checkout.as_path())
));
assert!(!path_is_nested_in_reserved_region(
&checkout,
&thread_dir,
Some(checkout.as_path())
));
let err = validate_threads_root_path_safety(
&nested,
&heddle,
&threads,
&[(thread_dir.clone(), Some(checkout.clone()))],
)
.unwrap_err();
assert!(matches!(
err,
ThreadsRootPathSafetyError::NestedInReserved { .. }
));
assert!(
validate_threads_root_path_safety(
&checkout,
&heddle,
&threads,
&[(thread_dir, Some(checkout.clone()))],
)
.is_ok()
);
}
#[test]
fn append_safe_relative_components_refuses_escape() {
let base = PathBuf::from("/resolved/ancestor");
assert_eq!(
append_safe_relative_components(base.clone(), Path::new("a/b")).unwrap(),
PathBuf::from("/resolved/ancestor/a/b")
);
assert_eq!(
append_safe_relative_components(base.clone(), Path::new("a/./b")).unwrap(),
PathBuf::from("/resolved/ancestor/a/b")
);
assert_eq!(
append_safe_relative_components(base.clone(), Path::new("a/../b")).unwrap_err(),
RelativePathNormalizeError::UnsafeComponent
);
assert!(!path_components_are_safe(Path::new("/a/../b")));
assert!(path_components_are_safe(Path::new("/a/b")));
assert!(path_is_strict_descendant(
Path::new("/repo/.heddle/threads/x/y"),
Path::new("/repo/.heddle/threads")
));
assert!(!path_is_strict_descendant(
Path::new("/repo/.heddle/threads"),
Path::new("/repo/.heddle/threads")
));
}
#[test]
fn empty_dir_adoption_and_create_intent() {
assert_eq!(
plan_target_dir_create_intent(true),
TargetDirCreateIntent::AttemptCreate
);
assert_eq!(
plan_target_dir_create_intent(false),
TargetDirCreateIntent::AdoptOnly
);
assert_eq!(
classify_target_leaf_shape(false, false, false, false),
TargetLeafShape::Absent
);
assert_eq!(
classify_target_leaf_shape(true, true, true, true),
TargetLeafShape::Symlink
);
assert_eq!(
classify_target_leaf_shape(true, false, false, false),
TargetLeafShape::NotDirectory
);
assert_eq!(
classify_target_leaf_shape(true, false, true, true),
TargetLeafShape::EmptyDirectory
);
assert_eq!(
classify_target_leaf_shape(true, false, true, false),
TargetLeafShape::NonEmptyDirectory
);
assert!(validate_empty_dir_adoption(TargetLeafShape::EmptyDirectory).is_ok());
assert_eq!(
validate_empty_dir_adoption(TargetLeafShape::NonEmptyDirectory).unwrap_err(),
TargetLeafRefusal::NotEmpty
);
assert_eq!(
validate_empty_dir_adoption(TargetLeafShape::Symlink).unwrap_err(),
TargetLeafRefusal::IsSymlink
);
assert_eq!(TargetLeafRefusal::NotEmpty.as_reason_str(), "is not empty");
assert_eq!(
claim_kind_for_create_attempt(CreateDirAttempt::Created),
Some(TargetDirClaimKind::Created)
);
assert_eq!(
claim_kind_for_create_attempt(CreateDirAttempt::AlreadyExists),
None
);
assert_eq!(
claim_kind_after_empty_dir_adoption(),
TargetDirClaimKind::AdoptedEmpty
);
assert!(require_established_claim(Some(TargetDirClaimKind::Created)).is_ok());
assert!(require_established_claim(None).is_err());
}
#[test]
fn effect_staging_preconditions_gate_claim_and_shared_target() {
let no_claim = StartEffectStagingFacts {
claim_established: false,
has_shared_target_dir: true,
};
let with_claim = StartEffectStagingFacts {
claim_established: true,
has_shared_target_dir: false,
};
let full = StartEffectStagingFacts {
claim_established: true,
has_shared_target_dir: true,
};
assert!(
validate_start_effect_preconditions(StartEffectKind::CreateTargetDir, no_claim).is_ok()
);
assert!(
validate_start_effect_preconditions(StartEffectKind::WriteThreadRef, no_claim).is_ok()
);
assert_eq!(
validate_start_effect_preconditions(StartEffectKind::MaterializeCheckout, no_claim)
.unwrap_err(),
StartEffectPreconditionError::ClaimNotEstablished
);
assert_eq!(
validate_start_effect_preconditions(
StartEffectKind::WriteCargoConfigRedirect,
with_claim
)
.unwrap_err(),
StartEffectPreconditionError::SharedTargetDirMissing
);
assert!(
validate_start_effect_preconditions(StartEffectKind::WriteCargoConfigRedirect, full)
.is_ok()
);
assert!(effect_requires_established_claim(
StartEffectKind::MaterializeCheckout
));
assert!(!effect_requires_established_claim(
StartEffectKind::CreateTargetDir
));
}
#[test]
fn classify_materialize_error_preserves_context_when_reclassifying_io() {
use anyhow::Context as _;
let with_ctx = Err::<(), _>(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"os error 13",
))
.context("writing .cargo/config.toml to /work/.cargo/config.toml")
.unwrap_err();
let mapped = classify_materialize_error(with_ctx);
assert!(
matches!(&mapped, HeddleError::Io(io) if io.kind() == std::io::ErrorKind::PermissionDenied),
"io kind must survive reclassification, got {mapped:?}"
);
let msg = format!("{mapped}");
assert!(
msg.contains(".cargo/config.toml") && msg.contains("writing"),
"reclassified io error must retain the path/action context: {msg}"
);
}
}