1use std::path::{Component, Path, PathBuf};
22
23use objects::HeddleError;
24use repo::ThreadMode;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CheckoutPathPlan {
33 pub path: PathBuf,
35 pub from_explicit_path: bool,
41}
42
43pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum CheckoutCopyPolicy {
87 PreferReflink,
90 FullCopy,
92 None,
94}
95
96pub 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
105pub 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
118pub const ADVISORY_ACTIVE_HEAVY_THREAD_THRESHOLD: usize = 1;
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum SharedTargetRedirectDecision {
131 Apply,
133 SkipNonRustWorkspace,
135 NotApplicable,
137}
138
139pub 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
159pub fn shared_target_redirect_applies(decision: SharedTargetRedirectDecision) -> bool {
161 matches!(decision, SharedTargetRedirectDecision::Apply)
162}
163
164pub 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
177pub 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
193pub fn mode_is_bytes_on_disk(mode: &ThreadMode) -> bool {
199 matches!(mode, ThreadMode::Solid | ThreadMode::Materialized)
200}
201
202pub fn plan_hydrate(hydrate_requested: bool, mode: &ThreadMode) -> bool {
206 hydrate_requested && mode_is_bytes_on_disk(mode)
207}
208
209pub fn plan_write_manifest(mode: &ThreadMode) -> bool {
213 matches!(mode, ThreadMode::Materialized)
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum MaterializeStep {
226 CreateTargetDir,
228 WriteThreadRef,
230 MaterializeCheckout { copy_policy: CheckoutCopyPolicy },
232 WriteManifest,
234 WriteCargoConfigRedirect,
236 HydrateIgnoredDirs,
238 EstablishVirtualizedMount,
240 WriteThreadRecord,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct ThreadMaterializePlan {
247 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
256pub 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 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
307pub 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#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct StartTransactionPlan {
360 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
369pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum TargetDirClaimKind {
400 Created,
402 AdoptedEmpty,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum CheckoutRewindPlan {
409 ClearAndRemoveDir,
411 ClearContentsOnly,
413 TouchNothing,
415}
416
417pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433pub enum SelfCreatedDirRewindPlan {
434 RemoveIfStillAtPath,
436 TouchNothing,
438}
439
440pub 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
451pub fn path_is_under_or_equal(path: &Path, root: &Path) -> bool {
460 path == root || path.starts_with(root)
461}
462
463pub fn path_is_strict_descendant(path: &Path, root: &Path) -> bool {
465 path != root && path.starts_with(root)
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474pub enum ThreadsRootPathClass {
475 ManagedCheckoutSlot,
477 ThreadsRoot,
479 BareThreadDir,
481 HeddleStorage,
483 OutsideHeddle,
485}
486
487pub 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
512pub fn threads_root_path_layout_allowed(class: ThreadsRootPathClass) -> bool {
517 matches!(
518 class,
519 ThreadsRootPathClass::ManagedCheckoutSlot | ThreadsRootPathClass::OutsideHeddle
520 )
521}
522
523pub 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#[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
601pub 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
621pub fn path_components_are_safe(path: &Path) -> bool {
623 !path.components().any(|c| matches!(c, Component::ParentDir))
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
628pub enum RelativePathNormalizeError {
629 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
645pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
680pub enum TargetDirCreateIntent {
681 AttemptCreate,
683 AdoptOnly,
685}
686
687pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
698pub enum CreateDirAttempt {
699 Created,
701 AlreadyExists,
703}
704
705#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707pub enum TargetLeafShape {
708 Absent,
710 EmptyDirectory,
712 NonEmptyDirectory,
714 Symlink,
716 NotDirectory,
718}
719
720#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub enum TargetLeafRefusal {
723 DoesNotExist,
724 IsSymlink,
725 NotDirectory,
726 NotEmpty,
727}
728
729impl TargetLeafRefusal {
730 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
741pub 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
767pub 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
778pub fn claim_kind_for_create_attempt(attempt: CreateDirAttempt) -> Option<TargetDirClaimKind> {
780 match attempt {
781 CreateDirAttempt::Created => Some(TargetDirClaimKind::Created),
782 CreateDirAttempt::AlreadyExists => None,
784 }
785}
786
787pub fn claim_kind_after_empty_dir_adoption() -> TargetDirClaimKind {
789 TargetDirClaimKind::AdoptedEmpty
790}
791
792pub fn require_established_claim(
794 claim: Option<TargetDirClaimKind>,
795) -> Result<TargetDirClaimKind, TargetLeafRefusal> {
796 claim.ok_or(TargetLeafRefusal::DoesNotExist)
797}
798
799#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805pub struct StartEffectStagingFacts {
806 pub claim_established: bool,
808 pub has_shared_target_dir: bool,
810}
811
812#[derive(Debug, Clone, Copy, PartialEq, Eq)]
814pub enum StartEffectPreconditionError {
815 ClaimNotEstablished,
817 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
837pub 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
848pub 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#[derive(Debug, Clone, PartialEq, Eq)]
875pub enum StartCleanupStep {
876 RestoreThreadRecord,
878 UnmountVirtualized,
880 UnwindHydrate,
882 RestoreCargoConfig,
884 RestoreManifest,
886 RewindCheckout { plan: CheckoutRewindPlan },
888 RollbackThreadRef,
890 RemoveSelfCreatedDir { plan: SelfCreatedDirRewindPlan },
892}
893
894pub 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
945pub 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 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}