1use std::path::{Path, PathBuf};
15
16use objects::object::StateId;
17use repo::{ThreadId, ThreadIdError, ThreadMode};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum WorkspaceModeRequest {
26 #[default]
28 Auto,
29 Materialized,
31 Virtualized,
33 Solid,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ThreadStartOptions {
44 pub name: String,
45 pub from: Option<String>,
46 pub path: Option<PathBuf>,
47 pub workspace: WorkspaceModeRequest,
48 pub parent_thread: Option<String>,
49 pub automated: bool,
50 pub task: Option<String>,
51 pub shared_target: bool,
52 pub hydrate: bool,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ThreadCreateOptions {
58 pub name: String,
59 pub ephemeral: bool,
60 pub ttl_secs: Option<u32>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ThreadCreatePlan {
66 pub name: ThreadId,
67 pub ephemeral: bool,
68 pub ttl_secs: Option<u32>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ThreadStartPlan {
77 pub name: ThreadId,
78 pub has_explicit_path: bool,
80 pub requires_clean_worktree: bool,
85 pub workspace: WorkspaceModeRequest,
88 pub from: Option<String>,
89 pub path: Option<PathBuf>,
90 pub parent_thread: Option<String>,
91 pub automated: bool,
92 pub task: Option<String>,
93 pub shared_target: bool,
94 pub hydrate: bool,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum ThreadPlanError {
104 InvalidName(ThreadIdError),
106}
107
108impl std::fmt::Display for ThreadPlanError {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 match self {
111 Self::InvalidName(err) => write!(f, "{err}"),
112 }
113 }
114}
115
116impl std::error::Error for ThreadPlanError {
117 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
118 match self {
119 Self::InvalidName(err) => Some(err),
120 }
121 }
122}
123
124impl From<ThreadIdError> for ThreadPlanError {
125 fn from(value: ThreadIdError) -> Self {
126 Self::InvalidName(value)
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum ThreadBaseError {
133 AnchorMismatch {
135 existing: StateId,
136 requested: StateId,
137 },
138}
139
140impl std::fmt::Display for ThreadBaseError {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 match self {
143 Self::AnchorMismatch {
144 existing,
145 requested,
146 } => write!(
147 f,
148 "thread is anchored at {}, but --from resolved to {}",
149 existing.short(),
150 requested.short()
151 ),
152 }
153 }
154}
155
156impl std::error::Error for ThreadBaseError {}
157
158pub fn validate_thread_name(name: &str) -> Result<ThreadId, ThreadPlanError> {
168 ThreadId::new(name).map_err(ThreadPlanError::from)
169}
170
171pub fn plan_thread_create(
173 options: &ThreadCreateOptions,
174) -> Result<ThreadCreatePlan, ThreadPlanError> {
175 let name = validate_thread_name(&options.name)?;
176 Ok(ThreadCreatePlan {
177 name,
178 ephemeral: options.ephemeral,
179 ttl_secs: options.ttl_secs,
180 })
181}
182
183pub fn plan_thread_start(options: &ThreadStartOptions) -> Result<ThreadStartPlan, ThreadPlanError> {
188 let name = validate_thread_name(&options.name)?;
189 let has_explicit_path = options.path.is_some();
190 Ok(ThreadStartPlan {
191 name,
192 has_explicit_path,
193 requires_clean_worktree: start_requires_clean_worktree(has_explicit_path),
194 workspace: options.workspace,
195 from: options.from.clone(),
196 path: options.path.clone(),
197 parent_thread: options.parent_thread.clone(),
198 automated: options.automated,
199 task: options.task.clone(),
200 shared_target: options.shared_target,
201 hydrate: options.hydrate,
202 })
203}
204
205pub fn start_requires_clean_worktree(has_explicit_path: bool) -> bool {
210 has_explicit_path
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
219pub enum ThreadBaseSelection {
220 Use(StateId),
222 RequireCurrent,
224}
225
226pub fn select_thread_base(
234 requested_from: Option<StateId>,
235 existing_tip: Option<StateId>,
236) -> Result<ThreadBaseSelection, ThreadBaseError> {
237 match (requested_from, existing_tip) {
238 (Some(requested), Some(existing)) if requested != existing => {
239 Err(ThreadBaseError::AnchorMismatch {
240 existing,
241 requested,
242 })
243 }
244 (Some(_), Some(existing)) => Ok(ThreadBaseSelection::Use(existing)),
245 (None, Some(existing)) => Ok(ThreadBaseSelection::Use(existing)),
246 (Some(requested), None) => Ok(ThreadBaseSelection::Use(requested)),
247 (None, None) => Ok(ThreadBaseSelection::RequireCurrent),
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum ExplicitPathPlacement {
260 UnderHeddleDir,
262 OutsideRepo,
264 InsideTrackedTree,
267}
268
269pub fn classify_explicit_path_placement(
274 requested: &Path,
275 repo_root: &Path,
276 heddle_dir: &Path,
277) -> ExplicitPathPlacement {
278 if requested == heddle_dir || requested.starts_with(heddle_dir) {
279 return ExplicitPathPlacement::UnderHeddleDir;
280 }
281 if requested == repo_root || requested.starts_with(repo_root) {
282 return ExplicitPathPlacement::InsideTrackedTree;
283 }
284 ExplicitPathPlacement::OutsideRepo
285}
286
287pub fn explicit_path_allowed_for_git_overlay(placement: ExplicitPathPlacement) -> bool {
289 !matches!(placement, ExplicitPathPlacement::InsideTrackedTree)
290}
291
292pub fn path_isolation_enforced(is_git_overlay: bool) -> bool {
297 is_git_overlay
298}
299
300pub fn check_explicit_path_isolation(
306 is_git_overlay: bool,
307 requested: &Path,
308 repo_root: &Path,
309 heddle_dir: &Path,
310) -> Result<(), ThreadPathIsolationError> {
311 if !path_isolation_enforced(is_git_overlay) {
312 return Ok(());
313 }
314 let placement = classify_explicit_path_placement(requested, repo_root, heddle_dir);
315 if explicit_path_allowed_for_git_overlay(placement) {
316 Ok(())
317 } else {
318 Err(ThreadPathIsolationError::InsideTrackedTree {
319 requested: requested.to_path_buf(),
320 repo_root: repo_root.to_path_buf(),
321 })
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
327pub enum ThreadPathIsolationError {
328 InsideTrackedTree {
330 requested: PathBuf,
331 repo_root: PathBuf,
332 },
333}
334
335impl std::fmt::Display for ThreadPathIsolationError {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 match self {
338 Self::InsideTrackedTree {
339 requested,
340 repo_root,
341 } => write!(
342 f,
343 "refusing thread start path '{}' inside repository '{}'",
344 requested.display(),
345 repo_root.display()
346 ),
347 }
348 }
349}
350
351impl std::error::Error for ThreadPathIsolationError {}
352
353pub fn active_reservation_blocks_start(has_active_reservation: bool) -> bool {
362 has_active_reservation
363}
364
365pub fn active_reservation_path_matches(
371 reserved_path: Option<&Path>,
372 requested_path: Option<&Path>,
373) -> bool {
374 match (reserved_path, requested_path) {
375 (_, None) => true,
376 (None, Some(_)) => false,
377 (Some(reserved), Some(requested)) => reserved == requested,
378 }
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum AutoWorkspaceDefault {
389 Materialized,
390 Virtualized,
391 Solid,
392 Auto,
394}
395
396pub fn plan_thread_mode(
406 workspace: WorkspaceModeRequest,
407 has_explicit_path: bool,
408 auto_default: AutoWorkspaceDefault,
409 supports_reflink: bool,
410) -> ThreadMode {
411 match workspace {
412 WorkspaceModeRequest::Materialized => ThreadMode::Materialized,
413 WorkspaceModeRequest::Virtualized => ThreadMode::Virtualized,
414 WorkspaceModeRequest::Solid => ThreadMode::Solid,
415 WorkspaceModeRequest::Auto => {
416 let candidate = if has_explicit_path {
417 ThreadMode::Materialized
418 } else {
419 match auto_default {
420 AutoWorkspaceDefault::Materialized | AutoWorkspaceDefault::Auto => {
421 ThreadMode::Materialized
422 }
423 AutoWorkspaceDefault::Virtualized => ThreadMode::Virtualized,
424 AutoWorkspaceDefault::Solid => ThreadMode::Solid,
425 }
426 };
427 if candidate == ThreadMode::Materialized && !supports_reflink {
428 ThreadMode::Solid
429 } else {
430 candidate
431 }
432 }
433 }
434}
435
436pub fn mode_honors_explicit_path(mode: &ThreadMode) -> bool {
441 matches!(mode, ThreadMode::Materialized | ThreadMode::Solid)
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn validate_thread_name_accepts_safe_slugs() {
450 assert!(validate_thread_name("feature/auth").is_ok());
451 assert!(validate_thread_name("v1.2").is_ok());
452 assert!(validate_thread_name("team@scope").is_ok());
453 assert!(validate_thread_name("heddle").is_ok());
454 assert!(validate_thread_name("main@hd-abc").is_ok());
455 }
456
457 #[test]
458 fn validate_thread_name_rejects_reserved_heddle_namespace() {
459 assert!(matches!(
460 validate_thread_name("heddle/frontier/main/hc-abc"),
461 Err(ThreadPlanError::InvalidName(_))
462 ));
463 }
464
465 #[test]
466 fn validate_thread_name_rejects_spaces_and_leading_dash() {
467 assert!(matches!(
468 validate_thread_name("bad name"),
469 Err(ThreadPlanError::InvalidName(_))
470 ));
471 assert!(matches!(
472 validate_thread_name("-flaglike"),
473 Err(ThreadPlanError::InvalidName(_))
474 ));
475 assert!(matches!(
476 validate_thread_name(""),
477 Err(ThreadPlanError::InvalidName(_))
478 ));
479 }
480
481 #[test]
482 fn plan_thread_create_validates_name() {
483 let plan = plan_thread_create(&ThreadCreateOptions {
484 name: "scratch".into(),
485 ephemeral: true,
486 ttl_secs: Some(60),
487 })
488 .unwrap();
489 assert_eq!(plan.name.as_str(), "scratch");
490 assert!(plan.ephemeral);
491 assert_eq!(plan.ttl_secs, Some(60));
492
493 assert!(
494 plan_thread_create(&ThreadCreateOptions {
495 name: "has space".into(),
496 ephemeral: false,
497 ttl_secs: None,
498 })
499 .is_err()
500 );
501 }
502
503 #[test]
504 fn plan_thread_start_sets_clean_worktree_for_explicit_path() {
505 let with_path = plan_thread_start(&ThreadStartOptions {
506 name: "a".into(),
507 from: None,
508 path: Some(PathBuf::from("/tmp/a")),
509 workspace: WorkspaceModeRequest::Auto,
510 parent_thread: None,
511 automated: false,
512 task: None,
513 shared_target: false,
514 hydrate: false,
515 })
516 .unwrap();
517 assert!(with_path.has_explicit_path);
518 assert!(with_path.requires_clean_worktree);
519
520 let without = plan_thread_start(&ThreadStartOptions {
521 name: "a".into(),
522 from: None,
523 path: None,
524 workspace: WorkspaceModeRequest::Solid,
525 parent_thread: None,
526 automated: false,
527 task: None,
528 shared_target: false,
529 hydrate: false,
530 })
531 .unwrap();
532 assert!(!without.has_explicit_path);
533 assert!(!without.requires_clean_worktree);
534 }
535
536 #[test]
537 fn select_thread_base_rules() {
538 let a = StateId::from_bytes([4; 32]);
539 let b = StateId::from_bytes([5; 32]);
540 assert_ne!(a, b);
541
542 assert_eq!(
543 select_thread_base(None, Some(a)).unwrap(),
544 ThreadBaseSelection::Use(a)
545 );
546 assert_eq!(
547 select_thread_base(Some(a), None).unwrap(),
548 ThreadBaseSelection::Use(a)
549 );
550 assert_eq!(
551 select_thread_base(Some(a), Some(a)).unwrap(),
552 ThreadBaseSelection::Use(a)
553 );
554 assert_eq!(
555 select_thread_base(None, None).unwrap(),
556 ThreadBaseSelection::RequireCurrent
557 );
558 assert_eq!(
559 select_thread_base(Some(b), Some(a)).unwrap_err(),
560 ThreadBaseError::AnchorMismatch {
561 existing: a,
562 requested: b,
563 }
564 );
565 }
566
567 #[test]
568 fn explicit_path_placement_classifies_containment() {
569 let root = Path::new("/repo");
570 let heddle = Path::new("/repo/.heddle");
571 assert_eq!(
572 classify_explicit_path_placement(Path::new("/repo/.heddle/threads/x"), root, heddle),
573 ExplicitPathPlacement::UnderHeddleDir
574 );
575 assert_eq!(
576 classify_explicit_path_placement(Path::new("/repo/src"), root, heddle),
577 ExplicitPathPlacement::InsideTrackedTree
578 );
579 assert_eq!(
580 classify_explicit_path_placement(Path::new("/tmp/sibling"), root, heddle),
581 ExplicitPathPlacement::OutsideRepo
582 );
583 }
584
585 #[test]
586 fn path_isolation_enforced_only_for_git_overlay() {
587 let root = Path::new("/repo");
588 let heddle = Path::new("/repo/.heddle");
589 let inside = Path::new("/repo/nested");
590 assert!(
591 check_explicit_path_isolation(true, inside, root, heddle).is_err(),
592 "git-overlay must refuse tracked-tree paths"
593 );
594 assert!(
595 check_explicit_path_isolation(false, inside, root, heddle).is_ok(),
596 "native heddle skips this containment guard"
597 );
598 assert!(
599 check_explicit_path_isolation(true, Path::new("/repo/.heddle/t"), root, heddle).is_ok()
600 );
601 assert!(check_explicit_path_isolation(true, Path::new("/out"), root, heddle).is_ok());
602 }
603
604 #[test]
605 fn active_reservation_helpers() {
606 assert!(active_reservation_blocks_start(true));
607 assert!(!active_reservation_blocks_start(false));
608 assert!(active_reservation_path_matches(
609 Some(Path::new("/a")),
610 Some(Path::new("/a"))
611 ));
612 assert!(!active_reservation_path_matches(
613 Some(Path::new("/a")),
614 Some(Path::new("/b"))
615 ));
616 assert!(!active_reservation_path_matches(
617 None,
618 Some(Path::new("/a"))
619 ));
620 assert!(active_reservation_path_matches(Some(Path::new("/a")), None));
621 }
622
623 #[test]
624 fn plan_thread_mode_auto_and_explicit() {
625 assert_eq!(
626 plan_thread_mode(
627 WorkspaceModeRequest::Solid,
628 false,
629 AutoWorkspaceDefault::Virtualized,
630 true
631 ),
632 ThreadMode::Solid
633 );
634 assert_eq!(
635 plan_thread_mode(
636 WorkspaceModeRequest::Auto,
637 true,
638 AutoWorkspaceDefault::Virtualized,
639 true
640 ),
641 ThreadMode::Materialized,
642 "explicit path pulls Auto toward navigable materialized"
643 );
644 assert_eq!(
645 plan_thread_mode(
646 WorkspaceModeRequest::Auto,
647 true,
648 AutoWorkspaceDefault::Virtualized,
649 false
650 ),
651 ThreadMode::Solid,
652 "materialized auto candidate downgrades without reflink"
653 );
654 assert_eq!(
655 plan_thread_mode(
656 WorkspaceModeRequest::Auto,
657 false,
658 AutoWorkspaceDefault::Virtualized,
659 true
660 ),
661 ThreadMode::Virtualized
662 );
663 assert_eq!(
664 plan_thread_mode(
665 WorkspaceModeRequest::Materialized,
666 false,
667 AutoWorkspaceDefault::Solid,
668 false
669 ),
670 ThreadMode::Materialized,
671 "explicit materialized is not silently downgraded"
672 );
673 assert!(mode_honors_explicit_path(&ThreadMode::Solid));
674 assert!(!mode_honors_explicit_path(&ThreadMode::Virtualized));
675 }
676}