Skip to main content

verbs/
thread_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure thread create/start planning.
3//!
4//! Owns decision logic shared by `heddle start`, `heddle thread start`, and
5//! `heddle thread create`:
6//! - name validation (safe shell-token / reserved structure rules)
7//! - default base selection rules
8//! - path isolation requirements (pure checks on normalized paths/options)
9//! - workspace mode planning from request + host facts
10//!
11//! Materialization, checkout, registry writes, and repository I/O stay
12//! CLI-owned. Callers resolve states/paths first, then invoke these helpers.
13
14use std::path::{Path, PathBuf};
15
16use objects::object::StateId;
17use repo::{ThreadId, ThreadIdError, ThreadMode};
18
19// ---------------------------------------------------------------------------
20// Options / plan types
21// ---------------------------------------------------------------------------
22
23/// Workspace mode as requested by the caller (maps from CLI `--workspace`).
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum WorkspaceModeRequest {
26    /// Let Heddle choose (config + host capabilities).
27    #[default]
28    Auto,
29    /// Clonefile/reflink materialized checkout when the host allows.
30    Materialized,
31    /// Virtualized mount path.
32    Virtualized,
33    /// Full-copy solid checkout.
34    Solid,
35}
36
37/// Caller-supplied start inputs for pure preflight planning.
38///
39/// Field names mirror the CLI `ThreadStartArgs` surface used by
40/// `heddle start` / `heddle thread start`. Network, materialization, and
41/// actor-registry fields that are not part of pure planning are omitted.
42#[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/// Caller-supplied create inputs for pure preflight planning.
56#[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/// Pure plan for `heddle thread create` after name validation.
64#[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/// Pure plan for `heddle start` / `heddle thread start` after option preflight.
72///
73/// Base resolution, FS path checks, and materialization remain with the
74/// caller; this captures what can be decided from options alone.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ThreadStartPlan {
77    pub name: ThreadId,
78    /// True when the caller supplied an explicit `--path`.
79    pub has_explicit_path: bool,
80    /// Whether the start preflight should require a clean worktree.
81    ///
82    /// Matches CLI: isolated checkouts (`--path`) refuse a dirty tree so the
83    /// parent worktree is not partially moved into the new checkout.
84    pub requires_clean_worktree: bool,
85    /// Echo of the caller's workspace request (mode is finalized later with
86    /// host/config facts via [`plan_thread_mode`]).
87    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// ---------------------------------------------------------------------------
98// Errors
99// ---------------------------------------------------------------------------
100
101/// Failures from pure thread create/start planning.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum ThreadPlanError {
104    /// Thread name failed the safe-slug / reserved-structure rule.
105    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/// Failures from pure base selection once states are resolved.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum ThreadBaseError {
133    /// Thread already exists at `existing`, but `--from` resolved to a different state.
134    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
158// ---------------------------------------------------------------------------
159// Name validation / create + start preflight
160// ---------------------------------------------------------------------------
161
162/// Validate a thread name against the shared safe-slug rule.
163///
164/// This is the single creation-boundary check used by start and create. It
165/// rejects empty names, shell metacharacters, `..` segments, and leading `/`
166/// or `-` (see [`repo::validate_thread_id`]).
167pub fn validate_thread_name(name: &str) -> Result<ThreadId, ThreadPlanError> {
168    ThreadId::new(name).map_err(ThreadPlanError::from)
169}
170
171/// Pure preflight for `heddle thread create`.
172pub 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
183/// Pure option preflight for `heddle start` / `heddle thread start`.
184///
185/// Validates the name and records pure path-isolation flags derived from
186/// options. Does not open the repository or touch the filesystem.
187pub 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
205/// Whether `heddle start` must refuse a dirty worktree for these options.
206///
207/// Explicit `--path` materializes an isolated checkout; dirty parent trees
208/// are refused so unsaved work is not partially copied into the new tree.
209pub fn start_requires_clean_worktree(has_explicit_path: bool) -> bool {
210    has_explicit_path
211}
212
213// ---------------------------------------------------------------------------
214// Base selection
215// ---------------------------------------------------------------------------
216
217/// Outcome of pure base selection after `--from` / existing tip are resolved.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub enum ThreadBaseSelection {
220    /// Use this already-resolved change id as the thread base.
221    Use(StateId),
222    /// No existing tip and no `--from`: caller must use current HEAD / bootstrap.
223    RequireCurrent,
224}
225
226/// Select the base state for a new or resumed thread start.
227///
228/// Rules (matching CLI `start_thread`):
229/// 1. Existing tip + matching `--from` (or no `--from`) → use existing tip
230/// 2. Existing tip + mismatched `--from` → [`ThreadBaseError::AnchorMismatch`]
231/// 3. No existing tip + `--from` → use the resolved `--from`
232/// 4. Neither → [`ThreadBaseSelection::RequireCurrent`]
233pub 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// ---------------------------------------------------------------------------
252// Path isolation
253// ---------------------------------------------------------------------------
254
255/// Where an explicit `--path` sits relative to the repository.
256///
257/// Paths must already be normalized (absolute, `..` resolved) by the caller.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum ExplicitPathPlacement {
260    /// Under the repo's `.heddle/` metadata tree (managed checkouts) — allowed.
261    UnderHeddleDir,
262    /// Outside the repository entirely — allowed.
263    OutsideRepo,
264    /// Inside the tracked working tree but not under `.heddle/` — refused on
265    /// git-overlay (would show up as nested unsaved work).
266    InsideTrackedTree,
267}
268
269/// Classify an explicit start path relative to repo root / heddle dir.
270///
271/// `requested`, `repo_root`, and `heddle_dir` must be absolute normalized
272/// paths. Equality and prefix checks are lexical on those normalized forms.
273pub 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
287/// Whether an explicit path placement is allowed for a git-overlay repo.
288pub fn explicit_path_allowed_for_git_overlay(placement: ExplicitPathPlacement) -> bool {
289    !matches!(placement, ExplicitPathPlacement::InsideTrackedTree)
290}
291
292/// Whether path isolation is enforced for this capability.
293///
294/// Git-overlay refuses checkouts inside the tracked tree; native heddle
295/// currently skips this containment guard (matches CLI).
296pub fn path_isolation_enforced(is_git_overlay: bool) -> bool {
297    is_git_overlay
298}
299
300/// Pure path-isolation check for an explicit start path.
301///
302/// Returns `Ok(())` when the path is allowed, or
303/// [`ThreadPathIsolationError::InsideTrackedTree`] when a git-overlay start
304/// would land inside the tracked working tree.
305pub 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/// Failures from pure path isolation checks.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub enum ThreadPathIsolationError {
328    /// Explicit path is inside the tracked worktree of a git-overlay repo.
329    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
353// ---------------------------------------------------------------------------
354// Active reservation (pure diagnostics)
355// ---------------------------------------------------------------------------
356
357/// Whether an active writer reservation blocks starting the same thread name.
358///
359/// Any live reservation refuses a new start. The reserved path is only used
360/// for diagnostics (CLI still surfaces the existing path when present).
361pub fn active_reservation_blocks_start(has_active_reservation: bool) -> bool {
362    has_active_reservation
363}
364
365/// Whether a requested path matches the path held by an active reservation.
366///
367/// When `requested` is `None`, there is nothing to compare and the result is
368/// `true` (no path mismatch to report). When the reservation has no path,
369/// the result is `false`.
370pub 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// ---------------------------------------------------------------------------
382// Workspace mode planning
383// ---------------------------------------------------------------------------
384
385/// Config default used when `--workspace auto` and no explicit path forces
386/// a bytes-on-disk mode.
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum AutoWorkspaceDefault {
389    Materialized,
390    Virtualized,
391    Solid,
392    /// Config says "auto" again → treat as materialized candidate.
393    Auto,
394}
395
396/// Plan the concrete [`ThreadMode`] from the caller request and host facts.
397///
398/// Rules (matching CLI `resolve_thread_mode`):
399/// - Explicit workspace modes win as-is (including materialized without
400///   reflinks — honesty messaging stays CLI-side).
401/// - Auto with an explicit `--path` candidates materialized (navigable
402///   checkout), then may downgrade to solid when the filesystem lacks
403///   reflinks.
404/// - Auto without path uses `auto_default`, then the same reflink downgrade.
405pub 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
436/// Whether the planned mode honors an explicit `--path` for checkout placement.
437///
438/// Virtualized mounts always use a Heddle-managed path so a user-named
439/// directory is never shadowed by a kernel mount.
440pub 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}