Skip to main content

verbs/
clone_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure clone and adopt planning.
3//!
4//! Owns decision logic shared by `heddle clone` and `heddle adopt`:
5//! - destination path validation and absolute-resolution policy
6//! - remote mode selection (local path vs network hosted vs git-overlay URL)
7//! - security preflight flag assembly (no network I/O)
8//! - adopt start-path resolution and path-conflict policy
9//! - monorepo recursive clone: child selection, path anchoring, work order
10//! - monorepo per-node execution steps (validate dest, init, fetch, materialize, map)
11//! - monorepo step ordering validation, progress labels, and result summary
12//!
13//! Filesystem mutations, hosted RPC, git import, and recovery-advice
14//! rendering stay CLI-owned. Callers gather cheap facts (path existence,
15//! RemoteTarget parse result, git/.heddle probes, resolved monorepo trees),
16//! invoke these helpers, then execute I/O from the plan.
17
18use std::path::{Path, PathBuf};
19
20use objects::object::StateId;
21use serde::Serialize;
22
23// ---------------------------------------------------------------------------
24// Clone options / facts
25// ---------------------------------------------------------------------------
26
27/// Caller-supplied clone inputs for pure preflight planning.
28///
29/// Field names mirror the CLI `heddle clone` surface. Network connect,
30/// repository init, and worktree materialization are omitted.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ClonePlanOptions {
33    pub remote: String,
34    pub local: PathBuf,
35    pub thread: Option<String>,
36    /// Raw `--depth` (including `Some(0)`); normalized in the plan.
37    pub depth: Option<u32>,
38    pub lazy: bool,
39    pub filter: Option<String>,
40    pub recursive: bool,
41    /// CLI `--insecure`: allow cleartext to non-loopback hosts on network paths.
42    pub insecure: bool,
43}
44
45/// Cheap facts the CLI gathers before planning (no clone network/FS body).
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ClonePlanFacts {
48    /// Whether the destination path already exists on disk.
49    pub destination_exists: bool,
50    /// Remote classification after `RemoteTarget::parse` and local probes.
51    pub remote_source: CloneRemoteSource,
52}
53
54/// How the CLI classified the remote for mode selection.
55///
56/// Network socket resolution and path existence for `file://` / raw paths
57/// remain caller-owned (`RemoteTarget::parse`). This enum carries only the
58/// pure facts needed to choose an execution mode.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum CloneRemoteSource {
61    /// Local filesystem path (`file://` or existing directory).
62    Local {
63        path: PathBuf,
64        /// `.heddle` metadata directory present at the source.
65        has_heddle: bool,
66        /// Source opens as a Git repository (overlay path candidate).
67        is_git: bool,
68    },
69    /// Hosted/network heddle endpoint (DNS/socket already resolved by CLI).
70    Network {
71        /// Whether a repository path component was present on the URL.
72        has_repo_path: bool,
73    },
74    /// `RemoteTarget::parse` failed; string-shape helpers select fallbacks.
75    Unparsed,
76}
77
78// ---------------------------------------------------------------------------
79// Clone plan / mode / security
80// ---------------------------------------------------------------------------
81
82/// Execution mode selected by [`plan_clone`].
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum CloneMode {
85    /// Local Heddle repository (`.heddle` present or non-git local path).
86    LocalHeddle { remote_path: PathBuf },
87    /// Local Git repository without Heddle metadata → git-overlay clone.
88    LocalGitOverlay { remote_path: PathBuf },
89    /// Unparsed remote that looks like a Git URL (`https://`, `git@`, …).
90    GitOverlayUrl,
91    /// Hosted/network clone; `recursive` selects monorepo vs single-spool.
92    NetworkHosted { recursive: bool },
93}
94
95impl CloneMode {
96    /// Short label for unsupported-option error context.
97    pub fn kind_label(&self) -> &'static str {
98        match self {
99            Self::LocalHeddle { .. } => "local",
100            Self::LocalGitOverlay { .. } | Self::GitOverlayUrl => "git-overlay",
101            Self::NetworkHosted { recursive: true } => "monorepo",
102            Self::NetworkHosted { recursive: false } => "network",
103        }
104    }
105
106    pub fn is_network(&self) -> bool {
107        matches!(self, Self::NetworkHosted { .. })
108    }
109
110    pub fn is_git_overlay(&self) -> bool {
111        matches!(self, Self::LocalGitOverlay { .. } | Self::GitOverlayUrl)
112    }
113}
114
115/// Security flags assembled for network clone sessions (no connect performed).
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct CloneSecurityPreflight {
118    /// Pass to `HostedSession::with_allow_insecure` / client config.
119    pub allow_insecure: bool,
120    /// Caller must build a hosted session and validate TLS/auth before any
121    /// destination `create_dir_all` / `Repository::init`.
122    pub requires_network_session: bool,
123}
124
125/// Pure clone orchestration plan. CLI executes FS / hosted / git I/O from it.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct ClonePlan {
128    pub destination: PathBuf,
129    pub remote: String,
130    pub mode: CloneMode,
131    pub thread: Option<String>,
132    /// Normalized depth (`None` when absent or zero).
133    pub depth: Option<u32>,
134    pub lazy: bool,
135    pub filter: Option<String>,
136    pub recursive: bool,
137    /// Network effective lazy: `lazy || filter.is_some()`.
138    pub effective_lazy: bool,
139    pub security: CloneSecurityPreflight,
140}
141
142// ---------------------------------------------------------------------------
143// Clone errors
144// ---------------------------------------------------------------------------
145
146/// Flag that cannot be combined with the selected clone mode.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum UnsupportedCloneFlag {
149    Filter,
150    Lazy,
151    Depth,
152}
153
154impl UnsupportedCloneFlag {
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::Filter => "--filter",
158            Self::Lazy => "--lazy",
159            Self::Depth => "--depth",
160        }
161    }
162}
163
164/// Failures from pure clone planning.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum ClonePlanError {
167    /// Destination path already exists.
168    DestinationExists { path: PathBuf },
169    /// `--recursive` requires a hosted/network remote.
170    MonorepoRequiresHosted { remote: String },
171    /// Unparsed remote that looks like a local path (missing source).
172    RemoteLooksLikeMissingLocalPath { remote: String },
173    /// Unparsed remote that is neither local-shaped nor a git URL.
174    InvalidRemoteUrl { remote: String },
175    /// Option rejected for the selected mode.
176    UnsupportedOption {
177        flag: UnsupportedCloneFlag,
178        /// Mode label (`local`, `git-overlay`, `monorepo`, …).
179        mode: &'static str,
180        /// Optional filter value for messaging.
181        value: Option<String>,
182    },
183}
184
185impl std::fmt::Display for ClonePlanError {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        match self {
188            Self::DestinationExists { path } => {
189                write!(f, "local path '{}' already exists", path.display())
190            }
191            Self::MonorepoRequiresHosted { remote } => write!(
192                f,
193                "--recursive monorepo clone requires a hosted spool remote; '{remote}' is not one"
194            ),
195            Self::RemoteLooksLikeMissingLocalPath { remote } => {
196                write!(f, "remote repository '{remote}' does not exist")
197            }
198            Self::InvalidRemoteUrl { remote } => write!(f, "invalid remote URL: {remote}"),
199            Self::UnsupportedOption { flag, mode, value } => {
200                let flag_label = value
201                    .as_deref()
202                    .map(|v| format!("{} {v}", flag.as_str()))
203                    .unwrap_or_else(|| flag.as_str().to_string());
204                write!(f, "{flag_label} is not supported for {mode} clones")
205            }
206        }
207    }
208}
209
210impl std::error::Error for ClonePlanError {}
211
212// ---------------------------------------------------------------------------
213// Adopt options / plan / errors
214// ---------------------------------------------------------------------------
215
216/// Caller-supplied adopt inputs for pure path planning.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct AdoptPlanOptions {
219    /// Positional path argument.
220    pub path: Option<PathBuf>,
221    /// Global `--repo` / `-C` path when set.
222    pub repo_flag: Option<PathBuf>,
223    /// Process working directory (for relative → absolute resolution).
224    pub cwd: PathBuf,
225    /// Explicit `--ref` values (empty means import all local branches/tags).
226    pub refs: Vec<String>,
227}
228
229/// Pure adopt preflight plan. CLI discovers Git root, bootstraps, and imports.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct AdoptPlan {
232    /// Start path for Git discovery (not yet canonicalized).
233    pub start_path: PathBuf,
234    pub refs: Vec<String>,
235    /// True when no explicit `--ref` was supplied.
236    pub import_all_refs: bool,
237}
238
239/// Failures from pure adopt planning.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub enum AdoptPlanError {
242    /// Positional path and `--repo` disagree after absolute resolution.
243    PathConflict { positional: PathBuf, repo: PathBuf },
244}
245
246impl std::fmt::Display for AdoptPlanError {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        match self {
249            Self::PathConflict { positional, repo } => write!(
250                f,
251                "adopt path '{}' conflicts with --repo '{}'",
252                positional.display(),
253                repo.display()
254            ),
255        }
256    }
257}
258
259impl std::error::Error for AdoptPlanError {}
260
261// ---------------------------------------------------------------------------
262// Pure path helpers
263// ---------------------------------------------------------------------------
264
265/// Absolute-resolution policy: join relative paths against `cwd`.
266///
267/// Does not canonicalize or require the path to exist. Callers that need a
268/// stable on-disk identity may canonicalize after planning when the path
269/// exists.
270pub fn absolute_path(path: &Path, cwd: &Path) -> PathBuf {
271    if path.is_absolute() {
272        path.to_path_buf()
273    } else {
274        cwd.join(path)
275    }
276}
277
278/// Resolve a clone destination against `cwd` without requiring it to exist.
279pub fn resolve_clone_destination(local: &Path, cwd: &Path) -> PathBuf {
280    absolute_path(local, cwd)
281}
282
283/// Destination validation: refuse when the path already exists.
284pub fn validate_clone_destination(
285    destination: &Path,
286    destination_exists: bool,
287) -> Result<(), ClonePlanError> {
288    if destination_exists {
289        Err(ClonePlanError::DestinationExists {
290            path: destination.to_path_buf(),
291        })
292    } else {
293        Ok(())
294    }
295}
296
297/// Normalize `--depth`: `0` and missing mean full history (`None`).
298pub fn normalize_clone_depth(depth: Option<u32>) -> Option<u32> {
299    depth.filter(|depth| *depth > 0)
300}
301
302/// Whether an unparsed remote string looks like a filesystem path.
303///
304/// Matches CLI: absolute paths, `.` / `..`, `./` / `../`, and `~/`.
305pub fn looks_like_local_path(remote: &str) -> bool {
306    let path = Path::new(remote);
307    path.is_absolute()
308        || remote == "."
309        || remote == ".."
310        || remote.starts_with("./")
311        || remote.starts_with("../")
312        || remote.starts_with("~/")
313}
314
315/// Whether an unparsed remote string looks like a Git clone URL.
316///
317/// Matches CLI: any `://` scheme or SCP-style `git@` host.
318pub fn looks_like_git_overlay_url(remote: &str) -> bool {
319    remote.contains("://") || remote.starts_with("git@")
320}
321
322/// Resolve adopt start path from positional / `--repo` / cwd.
323///
324/// Pure policy (no canonicalize). CLI may canonicalize when the path exists.
325pub fn resolve_adopt_start_path(
326    positional: Option<&Path>,
327    repo_flag: Option<&Path>,
328    cwd: &Path,
329) -> Result<PathBuf, AdoptPlanError> {
330    match (positional, repo_flag) {
331        (Some(positional), Some(repo_path)) => {
332            if absolute_path(positional, cwd) != absolute_path(repo_path, cwd) {
333                return Err(AdoptPlanError::PathConflict {
334                    positional: positional.to_path_buf(),
335                    repo: repo_path.to_path_buf(),
336                });
337            }
338            Ok(positional.to_path_buf())
339        }
340        (Some(positional), None) => Ok(positional.to_path_buf()),
341        (None, Some(repo_path)) => Ok(repo_path.to_path_buf()),
342        (None, None) => Ok(cwd.to_path_buf()),
343    }
344}
345
346// ---------------------------------------------------------------------------
347// Security preflight assembly
348// ---------------------------------------------------------------------------
349
350/// Assemble security flags for the selected clone mode without connecting.
351pub fn assemble_clone_security_preflight(
352    mode: &CloneMode,
353    insecure: bool,
354) -> CloneSecurityPreflight {
355    if mode.is_network() {
356        CloneSecurityPreflight {
357            allow_insecure: insecure,
358            requires_network_session: true,
359        }
360    } else {
361        CloneSecurityPreflight {
362            allow_insecure: false,
363            requires_network_session: false,
364        }
365    }
366}
367
368// ---------------------------------------------------------------------------
369// Mode selection + option gates
370// ---------------------------------------------------------------------------
371
372/// Select clone mode from remote classification and flags.
373pub fn select_clone_mode(
374    remote: &str,
375    recursive: bool,
376    source: &CloneRemoteSource,
377) -> Result<CloneMode, ClonePlanError> {
378    match source {
379        CloneRemoteSource::Local {
380            path,
381            has_heddle,
382            is_git,
383        } => {
384            if recursive {
385                return Err(ClonePlanError::MonorepoRequiresHosted {
386                    remote: remote.to_string(),
387                });
388            }
389            if !has_heddle && *is_git {
390                Ok(CloneMode::LocalGitOverlay {
391                    remote_path: path.clone(),
392                })
393            } else {
394                Ok(CloneMode::LocalHeddle {
395                    remote_path: path.clone(),
396                })
397            }
398        }
399        CloneRemoteSource::Network { .. } => Ok(CloneMode::NetworkHosted { recursive }),
400        CloneRemoteSource::Unparsed => {
401            if recursive {
402                return Err(ClonePlanError::MonorepoRequiresHosted {
403                    remote: remote.to_string(),
404                });
405            }
406            if looks_like_local_path(remote) {
407                return Err(ClonePlanError::RemoteLooksLikeMissingLocalPath {
408                    remote: remote.to_string(),
409                });
410            }
411            if looks_like_git_overlay_url(remote) {
412                Ok(CloneMode::GitOverlayUrl)
413            } else {
414                Err(ClonePlanError::InvalidRemoteUrl {
415                    remote: remote.to_string(),
416                })
417            }
418        }
419    }
420}
421
422/// Reject flags that the selected mode cannot honor.
423pub fn validate_clone_mode_options(
424    mode: &CloneMode,
425    depth: Option<u32>,
426    lazy: bool,
427    filter: Option<&str>,
428) -> Result<(), ClonePlanError> {
429    match mode {
430        CloneMode::LocalGitOverlay { .. } | CloneMode::GitOverlayUrl => {
431            if let Some(value) = filter {
432                return Err(ClonePlanError::UnsupportedOption {
433                    flag: UnsupportedCloneFlag::Filter,
434                    mode: mode.kind_label(),
435                    value: Some(value.to_string()),
436                });
437            }
438            if lazy {
439                return Err(ClonePlanError::UnsupportedOption {
440                    flag: UnsupportedCloneFlag::Lazy,
441                    mode: mode.kind_label(),
442                    value: None,
443                });
444            }
445            if depth.is_some() {
446                return Err(ClonePlanError::UnsupportedOption {
447                    flag: UnsupportedCloneFlag::Depth,
448                    mode: mode.kind_label(),
449                    value: None,
450                });
451            }
452        }
453        CloneMode::LocalHeddle { .. } => {
454            if let Some(value) = filter {
455                return Err(ClonePlanError::UnsupportedOption {
456                    flag: UnsupportedCloneFlag::Filter,
457                    mode: mode.kind_label(),
458                    value: Some(value.to_string()),
459                });
460            }
461            if lazy {
462                return Err(ClonePlanError::UnsupportedOption {
463                    flag: UnsupportedCloneFlag::Lazy,
464                    mode: mode.kind_label(),
465                    value: Some("true".to_string()),
466                });
467            }
468        }
469        CloneMode::NetworkHosted { recursive: true } => {
470            if filter.is_some() {
471                return Err(ClonePlanError::UnsupportedOption {
472                    flag: UnsupportedCloneFlag::Filter,
473                    mode: mode.kind_label(),
474                    value: None,
475                });
476            }
477            if lazy {
478                return Err(ClonePlanError::UnsupportedOption {
479                    flag: UnsupportedCloneFlag::Lazy,
480                    mode: mode.kind_label(),
481                    value: None,
482                });
483            }
484            if depth.is_some() {
485                return Err(ClonePlanError::UnsupportedOption {
486                    flag: UnsupportedCloneFlag::Depth,
487                    mode: mode.kind_label(),
488                    value: None,
489                });
490            }
491        }
492        CloneMode::NetworkHosted { recursive: false } => {}
493    }
494    Ok(())
495}
496
497// ---------------------------------------------------------------------------
498// Top-level planners
499// ---------------------------------------------------------------------------
500
501/// Plan a clone from pure options and caller-gathered facts.
502///
503/// Does not create directories, open repositories, or perform network I/O.
504pub fn plan_clone(
505    options: &ClonePlanOptions,
506    facts: &ClonePlanFacts,
507) -> Result<ClonePlan, ClonePlanError> {
508    validate_clone_destination(&options.local, facts.destination_exists)?;
509
510    let mode = select_clone_mode(&options.remote, options.recursive, &facts.remote_source)?;
511    let depth = normalize_clone_depth(options.depth);
512    validate_clone_mode_options(&mode, depth, options.lazy, options.filter.as_deref())?;
513
514    let security = assemble_clone_security_preflight(&mode, options.insecure);
515    let effective_lazy = if mode.is_network() {
516        options.lazy || options.filter.is_some()
517    } else {
518        false
519    };
520
521    Ok(ClonePlan {
522        destination: options.local.clone(),
523        remote: options.remote.clone(),
524        mode,
525        thread: options.thread.clone(),
526        depth,
527        lazy: options.lazy,
528        filter: options.filter.clone(),
529        recursive: options.recursive,
530        effective_lazy,
531        security,
532    })
533}
534
535/// Why clone could not choose a thread to check out.
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub enum CloneThreadSelectError {
538    /// `--thread` named a ref the remote did not advertise.
539    RequestedNotAdvertised { requested: String },
540    /// Remote advertised no usable thread names.
541    NoAdvertisedThreads,
542}
543
544impl std::fmt::Display for CloneThreadSelectError {
545    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546        match self {
547            Self::RequestedNotAdvertised { requested } => {
548                write!(f, "thread '{requested}' is not advertised by the remote")
549            }
550            Self::NoAdvertisedThreads => {
551                write!(f, "remote advertised no threads to check out")
552            }
553        }
554    }
555}
556
557impl std::error::Error for CloneThreadSelectError {}
558
559fn short_clone_thread_name(name: &str) -> &str {
560    name.strip_prefix("refs/heads/").unwrap_or(name)
561}
562
563/// Choose the thread a clone must check out.
564///
565/// Priority: explicit `--thread` (must be advertised), then the remote's
566/// advertised HEAD / current thread, then `main`, then the first remaining
567/// short name. `refs/`-prefixed companion names are ignored. Fails closed
568/// when the requested thread is missing or nothing usable was advertised.
569pub fn select_clone_checkout_thread<'a>(
570    requested: Option<&str>,
571    advertised_head: Option<&str>,
572    advertised_threads: impl IntoIterator<Item = &'a str>,
573) -> Result<String, CloneThreadSelectError> {
574    let mut threads = advertised_threads
575        .into_iter()
576        .filter(|thread| !thread.starts_with("refs/"))
577        .filter(|thread| !thread.is_empty())
578        .map(str::to_string)
579        .collect::<Vec<_>>();
580    threads.sort();
581    threads.dedup();
582
583    if let Some(requested) = requested {
584        let requested = short_clone_thread_name(requested);
585        if threads.iter().any(|thread| thread == requested) {
586            return Ok(requested.to_string());
587        }
588        return Err(CloneThreadSelectError::RequestedNotAdvertised {
589            requested: requested.to_string(),
590        });
591    }
592
593    if let Some(head) = advertised_head {
594        let head = short_clone_thread_name(head);
595        if threads.iter().any(|thread| thread == head) {
596            return Ok(head.to_string());
597        }
598    }
599
600    if threads.iter().any(|thread| thread == "main") {
601        return Ok("main".to_string());
602    }
603
604    threads
605        .into_iter()
606        .next()
607        .ok_or(CloneThreadSelectError::NoAdvertisedThreads)
608}
609
610/// Plan adopt path preflight from pure options.
611///
612/// Does not open Git repositories or import history.
613pub fn plan_adopt(options: &AdoptPlanOptions) -> Result<AdoptPlan, AdoptPlanError> {
614    let start_path = resolve_adopt_start_path(
615        options.path.as_deref(),
616        options.repo_flag.as_deref(),
617        &options.cwd,
618    )?;
619    Ok(AdoptPlan {
620        start_path,
621        refs: options.refs.clone(),
622        import_all_refs: options.refs.is_empty(),
623    })
624}
625
626// ---------------------------------------------------------------------------
627// Monorepo clone planning (recursive hosted)
628// ---------------------------------------------------------------------------
629//
630// After the CLI calls ResolveMonorepo, it maps the transport tree into pure
631// [`MonorepoNodeFacts`] and invokes [`plan_monorepo_clone`]. Placement rules:
632// - Root node at relative path `""` (the clone destination itself).
633// - Each selected child edge mounts at `<parent_rel>/<mount_name>`.
634// - Edges with a child subtree are selected and walked; edges without a child
635//   are recorded as skipped (unreadable / cycle / depth-bounded / unspecified)
636//   and are never fatal.
637// - A node with no content state still yields a materialize step (empty
638//   checkout) so the monorepo layout stays coherent.
639// Work order is pre-order: a parent's node always precedes its children.
640// Hosted RPC and per-node materialize I/O stay CLI-owned.
641
642/// Why a monorepo child edge was not selected for materialization.
643///
644/// Transport-free mirror of hosted `EdgeSkip`. Labels are stable for JSON and
645/// human reporting.
646#[derive(Debug, Clone, Copy, PartialEq, Eq)]
647pub enum MonorepoEdgeSkipReason {
648    Unspecified,
649    Unreadable,
650    Cycle,
651    DepthBounded,
652}
653
654impl MonorepoEdgeSkipReason {
655    pub fn as_str(self) -> &'static str {
656        match self {
657            Self::Unspecified => "unspecified",
658            Self::Unreadable => "unreadable",
659            Self::Cycle => "cycle",
660            Self::DepthBounded => "depth-bounded",
661        }
662    }
663
664    /// Map wire `EdgeSkip` discriminant (proto i32) without generated API types.
665    ///
666    /// Proto layout: Unspecified=0, Unreadable=1, Cycle=2, DepthBounded=3.
667    /// Unknown values map to [`None`] so callers can fall back or omit.
668    pub fn from_wire_i32(value: i32) -> Option<Self> {
669        match value {
670            0 => Some(Self::Unspecified),
671            1 => Some(Self::Unreadable),
672            2 => Some(Self::Cycle),
673            3 => Some(Self::DepthBounded),
674            _ => None,
675        }
676    }
677}
678
679/// Relative path label for monorepo placement lines (`""` → `.`).
680pub fn monorepo_rel_display(rel_path: &Path) -> String {
681    if rel_path.as_os_str().is_empty() {
682        ".".to_string()
683    } else {
684        rel_path.display().to_string()
685    }
686}
687
688/// Machine-facing monorepo clone envelope fields (CLI wraps with serde_json).
689#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
690pub struct MonorepoCloneJsonReport {
691    pub output_kind: &'static str,
692    pub action: &'static str,
693    pub status: &'static str,
694    pub success: bool,
695    pub transport: &'static str,
696    pub local: String,
697    pub placed: Vec<MonorepoPlacedJsonRow>,
698    pub skipped: Vec<MonorepoSkippedJsonRow>,
699}
700
701/// One placed node in monorepo clone JSON.
702#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
703pub struct MonorepoPlacedJsonRow {
704    pub spool_id: String,
705    pub path: String,
706    pub content_state: Option<String>,
707}
708
709/// One skipped edge in monorepo clone JSON.
710#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
711pub struct MonorepoSkippedJsonRow {
712    pub child_spool_id: String,
713    pub mount_name: String,
714    pub path: String,
715    pub reason: String,
716}
717
718/// Pure JSON-oriented report from a monorepo result summary + local path.
719pub fn assemble_monorepo_clone_json_report(
720    local_path: &Path,
721    summary: &MonorepoCloneResultSummary,
722) -> MonorepoCloneJsonReport {
723    let placed = summary
724        .placed
725        .iter()
726        .map(|node| MonorepoPlacedJsonRow {
727            spool_id: node.spool_id.clone(),
728            path: node.rel_path.display().to_string(),
729            content_state: node.content_state.map(|s| s.to_string()),
730        })
731        .collect();
732    let skipped = summary
733        .skipped
734        .iter()
735        .map(|sk| MonorepoSkippedJsonRow {
736            child_spool_id: sk.child_spool_id.clone(),
737            mount_name: sk.mount_name.clone(),
738            path: sk.rel_path.display().to_string(),
739            reason: sk.reason_label().to_string(),
740        })
741        .collect();
742    MonorepoCloneJsonReport {
743        output_kind: "clone_monorepo",
744        action: "clone",
745        status: "cloned",
746        success: true,
747        transport: "heddle",
748        local: local_path.display().to_string(),
749        placed,
750        skipped,
751    }
752}
753
754/// Pure facts for one edge under a monorepo node (caller-mapped from ResolveMonorepo).
755#[derive(Debug, Clone, PartialEq, Eq)]
756pub struct MonorepoEdgeFacts {
757    /// Mount name inside the parent (directory segment under the parent path).
758    pub mount_name: String,
759    /// Child spool id the edge points at.
760    pub child_spool_id: String,
761    /// When `Some`, the edge is selected and the subtree is walked. When `None`,
762    /// the edge is withheld (see [`skip_reason`]).
763    pub child: Option<MonorepoNodeFacts>,
764    /// Reason recorded when `child` is `None`. Ignored when `child` is present.
765    /// Missing reason with no child maps to [`MonorepoEdgeSkipReason::Unspecified`].
766    pub skip_reason: Option<MonorepoEdgeSkipReason>,
767}
768
769/// Pure facts for one resolved monorepo node (no hosted/network types).
770#[derive(Debug, Clone, PartialEq, Eq)]
771pub struct MonorepoNodeFacts {
772    pub spool_id: String,
773    /// Content-facet state to materialize. `None` = empty checkout at the mount.
774    /// For the root this is the spool's content head; for descendants the server
775    /// already pins the parent's edge-anchored state into this field.
776    pub content_state: Option<StateId>,
777    pub edges: Vec<MonorepoEdgeFacts>,
778}
779
780/// One per-node materialize step in monorepo work order.
781#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct MonorepoNodePlan {
783    pub spool_id: String,
784    pub content_state: Option<StateId>,
785    /// Destination path relative to the clone root. Root is `""`.
786    pub rel_path: PathBuf,
787}
788
789impl MonorepoNodePlan {
790    /// Absolute destination for this node given the clone root.
791    pub fn dest_path(&self, clone_root: &Path) -> PathBuf {
792        if self.rel_path.as_os_str().is_empty() {
793            clone_root.to_path_buf()
794        } else {
795            clone_root.join(&self.rel_path)
796        }
797    }
798}
799
800/// A child edge that was not selected, with the reason. Reported; never fatal.
801#[derive(Debug, Clone, PartialEq, Eq)]
802pub struct MonorepoSkippedChild {
803    pub child_spool_id: String,
804    pub mount_name: String,
805    /// Path the child would have mounted at (relative to clone root).
806    pub rel_path: PathBuf,
807    pub reason: MonorepoEdgeSkipReason,
808}
809
810impl MonorepoSkippedChild {
811    pub fn reason_label(&self) -> &'static str {
812        self.reason.as_str()
813    }
814}
815
816/// Ordered monorepo clone plan: selected nodes (pre-order) plus withheld edges.
817#[derive(Debug, Clone, PartialEq, Eq, Default)]
818pub struct MonorepoClonePlan {
819    /// Selected nodes in pre-order (root first). Parent always precedes children.
820    pub nodes: Vec<MonorepoNodePlan>,
821    /// Child edges recorded but not descended.
822    pub skipped: Vec<MonorepoSkippedChild>,
823}
824
825/// A remote monorepo edge supplied a mount that cannot be placed safely.
826#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
827pub enum MonorepoClonePlanError {
828    #[error(
829        "invalid monorepo mount name '{mount_name}' for child '{child_spool_id}': mount names must be exactly one relative path component"
830    )]
831    InvalidMountName {
832        child_spool_id: String,
833        mount_name: String,
834    },
835}
836
837/// Reject `--depth` / `--lazy` / `--filter` for recursive monorepo clones.
838///
839/// These knobs change single-spool pull semantics and do not compose across the
840/// anchored-state monorepo walk in the first cut.
841pub fn validate_monorepo_clone_options(
842    depth: Option<u32>,
843    lazy: bool,
844    filter: Option<&str>,
845) -> Result<(), ClonePlanError> {
846    validate_clone_mode_options(
847        &CloneMode::NetworkHosted { recursive: true },
848        depth,
849        lazy,
850        filter,
851    )
852}
853
854/// Plan monorepo materialize order from pure child-tree facts.
855///
856/// Applies path anchoring and child selection rules. Does not perform hosted
857/// RPC or write to disk.
858pub fn plan_monorepo_clone(
859    root: &MonorepoNodeFacts,
860) -> Result<MonorepoClonePlan, MonorepoClonePlanError> {
861    let mut plan = MonorepoClonePlan::default();
862    walk_monorepo_node(&mut plan, root, PathBuf::new())?;
863    Ok(plan)
864}
865
866fn walk_monorepo_node(
867    plan: &mut MonorepoClonePlan,
868    node: &MonorepoNodeFacts,
869    rel_path: PathBuf,
870) -> Result<(), MonorepoClonePlanError> {
871    // Always emit a node plan (including empty content) so the mount exists.
872    plan.nodes.push(MonorepoNodePlan {
873        spool_id: node.spool_id.clone(),
874        content_state: node.content_state,
875        rel_path: rel_path.clone(),
876    });
877
878    for edge in &node.edges {
879        validate_monorepo_mount_name(edge)?;
880        let child_rel = rel_path.join(&edge.mount_name);
881        match &edge.child {
882            Some(child) => walk_monorepo_node(plan, child, child_rel)?,
883            None => {
884                let reason = edge
885                    .skip_reason
886                    .unwrap_or(MonorepoEdgeSkipReason::Unspecified);
887                plan.skipped.push(MonorepoSkippedChild {
888                    child_spool_id: edge.child_spool_id.clone(),
889                    mount_name: edge.mount_name.clone(),
890                    rel_path: child_rel,
891                    reason,
892                });
893            }
894        }
895    }
896    Ok(())
897}
898
899fn validate_monorepo_mount_name(edge: &MonorepoEdgeFacts) -> Result<(), MonorepoClonePlanError> {
900    let mut components = Path::new(&edge.mount_name).components();
901    let is_one_normal_component =
902        matches!(components.next(), Some(std::path::Component::Normal(_)))
903            && components.next().is_none()
904            && !edge.mount_name.contains(['/', '\\']);
905    if is_one_normal_component {
906        return Ok(());
907    }
908    Err(MonorepoClonePlanError::InvalidMountName {
909        child_spool_id: edge.child_spool_id.clone(),
910        mount_name: edge.mount_name.clone(),
911    })
912}
913
914// ---------------------------------------------------------------------------
915// Monorepo per-node execution scaffolding (pure step list)
916// ---------------------------------------------------------------------------
917//
918// [`plan_monorepo_clone`] decides *which* nodes to place and *where*. The
919// helpers below decide *how* each selected node is materialized as an ordered
920// list of pure steps. CLI matches on each step and performs FS / hosted I/O
921// (create dirs, `Repository::init`, fetch_state, goto, origin mapping).
922
923/// One pure execution step for materializing a single monorepo node.
924///
925/// Order is fixed by [`plan_monorepo_node_steps`]. Fetch/materialize carry the
926/// content-state payload so the CLI does not re-branch on `Option`.
927#[derive(Debug, Clone, PartialEq, Eq)]
928pub enum MonorepoNodeExecutionStep {
929    /// Ensure mount destination is usable (parent dirs, create dest path).
930    ValidateDest,
931    /// Initialize a Heddle repository at the mount (`Repository::init`).
932    InitRepo,
933    /// Hosted fetch of the node's content-state object closure.
934    FetchContent { state: StateId },
935    /// Materialize worktree from the fetched state
936    /// (`goto_from_materialized_state`).
937    MaterializeState { state: StateId },
938    /// Seed origin/remote mapping so the placed spool tracks its upstream.
939    RecordMapping,
940}
941
942impl MonorepoNodeExecutionStep {
943    /// Stable short label for tests and diagnostics.
944    pub fn as_str(&self) -> &'static str {
945        match self {
946            Self::ValidateDest => "validate_dest",
947            Self::InitRepo => "init_repo",
948            Self::FetchContent { .. } => "fetch_content",
949            Self::MaterializeState { .. } => "materialize_state",
950            Self::RecordMapping => "record_mapping",
951        }
952    }
953}
954
955/// Mode flags that gate optional per-node monorepo materialize steps.
956///
957/// Fetch/materialize are gated by [`MonorepoNodePlan::content_state`] (not by
958/// these flags). First cut only toggles origin mapping.
959#[derive(Debug, Clone, Copy, PartialEq, Eq)]
960pub struct MonorepoNodeStepOptions {
961    /// When true, emit [`MonorepoNodeExecutionStep::RecordMapping`] (default).
962    pub record_mapping: bool,
963}
964
965impl Default for MonorepoNodeStepOptions {
966    fn default() -> Self {
967        Self {
968            record_mapping: true,
969        }
970    }
971}
972
973/// One selected monorepo node plus its ordered pure execution steps.
974#[derive(Debug, Clone, PartialEq, Eq)]
975pub struct MonorepoNodeExecution {
976    pub node: MonorepoNodePlan,
977    pub steps: Vec<MonorepoNodeExecutionStep>,
978}
979
980/// Aggregate monorepo execution plan: per-node steps in pre-order + skipped edges.
981///
982/// Built from a [`MonorepoClonePlan`] via [`plan_monorepo_execution`]. Preserves
983/// work order: parent node steps always complete before a child's.
984#[derive(Debug, Clone, PartialEq, Eq, Default)]
985pub struct MonorepoExecutionPlan {
986    /// Selected nodes with steps, same pre-order as [`MonorepoClonePlan::nodes`].
987    pub nodes: Vec<MonorepoNodeExecution>,
988    /// Child edges recorded but not descended (copied from the clone plan).
989    pub skipped: Vec<MonorepoSkippedChild>,
990}
991
992impl MonorepoExecutionPlan {
993    /// Number of selected nodes (placement count).
994    pub fn node_count(&self) -> usize {
995        self.nodes.len()
996    }
997}
998
999/// Plan ordered pure steps for one monorepo node.
1000///
1001/// Always emits ValidateDest → InitRepo. When `node.content_state` is set,
1002/// appends FetchContent then MaterializeState with that state. When
1003/// `options.record_mapping` is true (default), appends RecordMapping.
1004/// Empty content still produces ValidateDest + InitRepo (+ optional mapping)
1005/// so the mount is an initialized empty repo.
1006pub fn plan_monorepo_node_steps(
1007    node: &MonorepoNodePlan,
1008    options: &MonorepoNodeStepOptions,
1009) -> Vec<MonorepoNodeExecutionStep> {
1010    let mut steps = vec![
1011        MonorepoNodeExecutionStep::ValidateDest,
1012        MonorepoNodeExecutionStep::InitRepo,
1013    ];
1014    if let Some(state) = node.content_state {
1015        steps.push(MonorepoNodeExecutionStep::FetchContent { state });
1016        steps.push(MonorepoNodeExecutionStep::MaterializeState { state });
1017    }
1018    if options.record_mapping {
1019        steps.push(MonorepoNodeExecutionStep::RecordMapping);
1020    }
1021    steps
1022}
1023
1024/// Expand a monorepo clone worklist into per-node pure execution steps.
1025///
1026/// Does not perform I/O. Skipped edges are copied through unchanged.
1027pub fn plan_monorepo_execution(
1028    clone_plan: &MonorepoClonePlan,
1029    options: &MonorepoNodeStepOptions,
1030) -> MonorepoExecutionPlan {
1031    MonorepoExecutionPlan {
1032        nodes: clone_plan
1033            .nodes
1034            .iter()
1035            .map(|node| MonorepoNodeExecution {
1036                node: node.clone(),
1037                steps: plan_monorepo_node_steps(node, options),
1038            })
1039            .collect(),
1040        skipped: clone_plan.skipped.clone(),
1041    }
1042}
1043
1044// ---------------------------------------------------------------------------
1045// Monorepo step validation, progress labels, result summary (pure)
1046// ---------------------------------------------------------------------------
1047//
1048// [`plan_monorepo_node_steps`] emits ordered steps; the helpers below check
1049// ordering invariants before I/O, name unstyled progress labels for a step
1050// inside a multi-node walk, and assemble placed/skipped counts for the
1051// clone result. CLI still owns FS / hosted RPC and TTY styling.
1052
1053/// Failures from pure monorepo node step ordering validation.
1054#[derive(Debug, Clone, PartialEq, Eq)]
1055pub enum MonorepoNodeExecutionError {
1056    /// Step list is empty (planner always emits at least ValidateDest + InitRepo).
1057    EmptySteps,
1058    /// Required scaffold step missing.
1059    MissingStep { step: &'static str },
1060    /// A step appeared before its prerequisites or after a later-ranked step.
1061    OutOfOrder {
1062        step: &'static str,
1063        detail: &'static str,
1064    },
1065    /// [`MonorepoNodeExecutionStep::MaterializeState`] without a prior Fetch.
1066    MaterializeWithoutFetch,
1067    /// [`MonorepoNodeExecutionStep::FetchContent`] not followed by Materialize.
1068    FetchWithoutMaterialize,
1069    /// Fetch and Materialize carry different content-state ids.
1070    FetchMaterializeStateMismatch {
1071        fetch: StateId,
1072        materialize: StateId,
1073    },
1074}
1075
1076impl std::fmt::Display for MonorepoNodeExecutionError {
1077    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1078        match self {
1079            Self::EmptySteps => write!(f, "monorepo node execution steps are empty"),
1080            Self::MissingStep { step } => {
1081                write!(f, "monorepo node execution missing required step '{step}'")
1082            }
1083            Self::OutOfOrder { step, detail } => {
1084                write!(
1085                    f,
1086                    "monorepo node execution step '{step}' out of order: {detail}"
1087                )
1088            }
1089            Self::MaterializeWithoutFetch => {
1090                write!(f, "monorepo MaterializeState requires FetchContent first")
1091            }
1092            Self::FetchWithoutMaterialize => write!(
1093                f,
1094                "monorepo FetchContent requires a following MaterializeState"
1095            ),
1096            Self::FetchMaterializeStateMismatch { fetch, materialize } => write!(
1097                f,
1098                "monorepo FetchContent state {fetch} does not match MaterializeState {materialize}"
1099            ),
1100        }
1101    }
1102}
1103
1104impl std::error::Error for MonorepoNodeExecutionError {}
1105
1106/// Rank used only for ordering checks (lower must not follow higher).
1107fn monorepo_step_rank(step: &MonorepoNodeExecutionStep) -> u8 {
1108    match step {
1109        MonorepoNodeExecutionStep::ValidateDest => 0,
1110        MonorepoNodeExecutionStep::InitRepo => 1,
1111        MonorepoNodeExecutionStep::FetchContent { .. } => 2,
1112        MonorepoNodeExecutionStep::MaterializeState { .. } => 3,
1113        MonorepoNodeExecutionStep::RecordMapping => 4,
1114    }
1115}
1116
1117/// Validate ordering invariants for one node's pure monorepo steps.
1118///
1119/// Invariants:
1120/// - Non-empty; must include ValidateDest then InitRepo (scaffold).
1121/// - Steps appear at most once and in rank order (ValidateDest → InitRepo →
1122///   optional FetchContent → optional MaterializeState → optional RecordMapping).
1123/// - InitRepo precedes Fetch / Materialize / RecordMapping.
1124/// - FetchContent and MaterializeState are paired with the same [`StateId`].
1125///
1126/// Does not perform I/O. Plans from [`plan_monorepo_node_steps`] always pass.
1127pub fn validate_monorepo_node_execution(
1128    steps: &[MonorepoNodeExecutionStep],
1129) -> Result<(), MonorepoNodeExecutionError> {
1130    if steps.is_empty() {
1131        return Err(MonorepoNodeExecutionError::EmptySteps);
1132    }
1133
1134    let mut seen_validate = false;
1135    let mut seen_init = false;
1136    let mut pending_fetch: Option<StateId> = None;
1137    let mut last_rank: Option<u8> = None;
1138
1139    for step in steps {
1140        let rank = monorepo_step_rank(step);
1141        if let Some(prev) = last_rank
1142            && rank <= prev
1143        {
1144            return Err(MonorepoNodeExecutionError::OutOfOrder {
1145                step: step.as_str(),
1146                detail: "steps must be unique and strictly increasing in rank",
1147            });
1148        }
1149        last_rank = Some(rank);
1150
1151        match step {
1152            MonorepoNodeExecutionStep::ValidateDest => {
1153                seen_validate = true;
1154            }
1155            MonorepoNodeExecutionStep::InitRepo => {
1156                if !seen_validate {
1157                    return Err(MonorepoNodeExecutionError::OutOfOrder {
1158                        step: step.as_str(),
1159                        detail: "InitRepo requires ValidateDest first",
1160                    });
1161                }
1162                seen_init = true;
1163            }
1164            MonorepoNodeExecutionStep::FetchContent { state } => {
1165                if !seen_init {
1166                    return Err(MonorepoNodeExecutionError::OutOfOrder {
1167                        step: step.as_str(),
1168                        detail: "Init before Fetch",
1169                    });
1170                }
1171                pending_fetch = Some(*state);
1172            }
1173            MonorepoNodeExecutionStep::MaterializeState { state } => {
1174                if !seen_init {
1175                    return Err(MonorepoNodeExecutionError::OutOfOrder {
1176                        step: step.as_str(),
1177                        detail: "Init before Materialize",
1178                    });
1179                }
1180                match pending_fetch {
1181                    None => return Err(MonorepoNodeExecutionError::MaterializeWithoutFetch),
1182                    Some(fetch) if fetch != *state => {
1183                        return Err(MonorepoNodeExecutionError::FetchMaterializeStateMismatch {
1184                            fetch,
1185                            materialize: *state,
1186                        });
1187                    }
1188                    Some(_) => {
1189                        pending_fetch = None;
1190                    }
1191                }
1192            }
1193            MonorepoNodeExecutionStep::RecordMapping => {
1194                if !seen_init {
1195                    return Err(MonorepoNodeExecutionError::OutOfOrder {
1196                        step: step.as_str(),
1197                        detail: "Init before RecordMapping",
1198                    });
1199                }
1200                if pending_fetch.is_some() {
1201                    return Err(MonorepoNodeExecutionError::FetchWithoutMaterialize);
1202                }
1203            }
1204        }
1205    }
1206
1207    if !seen_validate {
1208        return Err(MonorepoNodeExecutionError::MissingStep {
1209            step: MonorepoNodeExecutionStep::ValidateDest.as_str(),
1210        });
1211    }
1212    if !seen_init {
1213        return Err(MonorepoNodeExecutionError::MissingStep {
1214            step: MonorepoNodeExecutionStep::InitRepo.as_str(),
1215        });
1216    }
1217    if pending_fetch.is_some() {
1218        return Err(MonorepoNodeExecutionError::FetchWithoutMaterialize);
1219    }
1220
1221    Ok(())
1222}
1223
1224/// Validate every selected node's step list in a monorepo execution plan.
1225pub fn validate_monorepo_execution(
1226    plan: &MonorepoExecutionPlan,
1227) -> Result<(), MonorepoNodeExecutionError> {
1228    for node_exec in &plan.nodes {
1229        validate_monorepo_node_execution(&node_exec.steps)?;
1230    }
1231    Ok(())
1232}
1233
1234/// Pure progress label for one step inside a multi-node monorepo clone walk.
1235///
1236/// CLI owns TTY styling; this is unstyled display data only.
1237#[derive(Debug, Clone, PartialEq, Eq)]
1238pub struct MonorepoExecutionProgress {
1239    /// 0-based index into [`MonorepoExecutionPlan::nodes`].
1240    pub node_index: usize,
1241    /// Total selected nodes in the plan.
1242    pub total_nodes: usize,
1243    /// 1-based human node ordinal (`node_index + 1`, floored at 1 when total is 0).
1244    pub node_display: usize,
1245    /// Stable step id from [`MonorepoNodeExecutionStep::as_str`].
1246    pub step: &'static str,
1247}
1248
1249impl MonorepoExecutionProgress {
1250    /// Compact unstyled label, e.g. `[1/3] init_repo`.
1251    pub fn label(&self) -> String {
1252        format!("[{}/{}] {}", self.node_display, self.total_nodes, self.step)
1253    }
1254}
1255
1256/// Build pure display labels for a monorepo node step at `node_index` of `total`.
1257///
1258/// `node_index` is 0-based. `total` is the plan's selected node count.
1259pub fn monorepo_execution_progress(
1260    node_index: usize,
1261    total: usize,
1262    step: &MonorepoNodeExecutionStep,
1263) -> MonorepoExecutionProgress {
1264    MonorepoExecutionProgress {
1265        node_index,
1266        total_nodes: total,
1267        node_display: node_index.saturating_add(1),
1268        step: step.as_str(),
1269    }
1270}
1271
1272/// One successfully planned placement for monorepo clone result assembly.
1273#[derive(Debug, Clone, PartialEq, Eq)]
1274pub struct MonorepoPlacedNodeSummary {
1275    pub spool_id: String,
1276    /// Destination path relative to the clone root. Root is `""`.
1277    pub rel_path: PathBuf,
1278    pub content_state: Option<StateId>,
1279    /// True when the node plan included fetch + materialize (had content).
1280    pub materialized_content: bool,
1281}
1282
1283/// Aggregate placed/skipped summary for a monorepo clone result (pure).
1284///
1285/// Assembled from a validated execution plan after all selected nodes succeed.
1286/// Skipped edges are never fatal; they are reported here for text/JSON output.
1287#[derive(Debug, Clone, PartialEq, Eq, Default)]
1288pub struct MonorepoCloneResultSummary {
1289    pub placed_count: usize,
1290    pub skipped_count: usize,
1291    pub placed: Vec<MonorepoPlacedNodeSummary>,
1292    pub skipped: Vec<MonorepoSkippedChild>,
1293}
1294
1295impl MonorepoCloneResultSummary {
1296    /// Unstyled headline, e.g. `Cloned monorepo org/root (2 spools placed).`
1297    pub fn headline(&self, root_path: &str) -> String {
1298        let unit = if self.placed_count == 1 {
1299            "spool"
1300        } else {
1301            "spools"
1302        };
1303        format!(
1304            "Cloned monorepo {root_path} ({} {unit} placed).",
1305            self.placed_count
1306        )
1307    }
1308
1309    /// Unstyled skip section header when any edges were withheld; `None` if empty.
1310    pub fn skipped_header(&self) -> Option<String> {
1311        if self.skipped_count == 0 {
1312            None
1313        } else {
1314            Some(format!(
1315                "{} child spool(s) skipped (not part of your coherent slice):",
1316                self.skipped_count
1317            ))
1318        }
1319    }
1320}
1321
1322/// Assemble placed/skipped summary from a monorepo execution plan (no I/O).
1323///
1324/// Call after every selected node has been materialized successfully. Counts
1325/// reflect plan size (success path), not partial progress mid-walk.
1326pub fn assemble_monorepo_clone_result_summary(
1327    plan: &MonorepoExecutionPlan,
1328) -> MonorepoCloneResultSummary {
1329    let placed: Vec<MonorepoPlacedNodeSummary> = plan
1330        .nodes
1331        .iter()
1332        .map(|node_exec| {
1333            let materialized_content = node_exec
1334                .steps
1335                .iter()
1336                .any(|step| matches!(step, MonorepoNodeExecutionStep::MaterializeState { .. }));
1337            MonorepoPlacedNodeSummary {
1338                spool_id: node_exec.node.spool_id.clone(),
1339                rel_path: node_exec.node.rel_path.clone(),
1340                content_state: node_exec.node.content_state,
1341                materialized_content,
1342            }
1343        })
1344        .collect();
1345    MonorepoCloneResultSummary {
1346        placed_count: placed.len(),
1347        skipped_count: plan.skipped.len(),
1348        placed,
1349        skipped: plan.skipped.clone(),
1350    }
1351}
1352
1353// ---------------------------------------------------------------------------
1354// Tests
1355// ---------------------------------------------------------------------------
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360
1361    fn base_clone_options(remote: &str, local: &str) -> ClonePlanOptions {
1362        ClonePlanOptions {
1363            remote: remote.to_string(),
1364            local: PathBuf::from(local),
1365            thread: None,
1366            depth: None,
1367            lazy: false,
1368            filter: None,
1369            recursive: false,
1370            insecure: false,
1371        }
1372    }
1373
1374    #[test]
1375    fn absolute_path_joins_relative_against_cwd() {
1376        let cwd = Path::new("/work");
1377        assert_eq!(
1378            absolute_path(Path::new("dest"), cwd),
1379            PathBuf::from("/work/dest")
1380        );
1381        assert_eq!(
1382            absolute_path(Path::new("/abs/dest"), cwd),
1383            PathBuf::from("/abs/dest")
1384        );
1385    }
1386
1387    #[test]
1388    fn resolve_clone_destination_uses_absolute_policy() {
1389        let cwd = Path::new("/tmp/repo");
1390        assert_eq!(
1391            resolve_clone_destination(Path::new("clone-here"), cwd),
1392            PathBuf::from("/tmp/repo/clone-here")
1393        );
1394    }
1395
1396    #[test]
1397    fn validate_clone_destination_refuses_existing() {
1398        assert!(matches!(
1399            validate_clone_destination(Path::new("/tmp/x"), true),
1400            Err(ClonePlanError::DestinationExists { .. })
1401        ));
1402        assert!(validate_clone_destination(Path::new("/tmp/x"), false).is_ok());
1403    }
1404
1405    #[test]
1406    fn normalize_clone_depth_drops_zero() {
1407        assert_eq!(normalize_clone_depth(None), None);
1408        assert_eq!(normalize_clone_depth(Some(0)), None);
1409        assert_eq!(normalize_clone_depth(Some(1)), Some(1));
1410        assert_eq!(normalize_clone_depth(Some(5)), Some(5));
1411    }
1412
1413    #[test]
1414    fn looks_like_local_path_shapes() {
1415        assert!(looks_like_local_path("/abs/path"));
1416        assert!(looks_like_local_path("."));
1417        assert!(looks_like_local_path(".."));
1418        assert!(looks_like_local_path("./rel"));
1419        assert!(looks_like_local_path("../up"));
1420        assert!(looks_like_local_path("~/home"));
1421        assert!(!looks_like_local_path("host:8421/repo"));
1422        assert!(!looks_like_local_path("https://example.com/repo.git"));
1423    }
1424
1425    #[test]
1426    fn looks_like_git_overlay_url_shapes() {
1427        assert!(looks_like_git_overlay_url("https://example.com/repo.git"));
1428        assert!(looks_like_git_overlay_url("git@github.com:org/repo.git"));
1429        assert!(looks_like_git_overlay_url("ssh://git@host/repo.git"));
1430        assert!(!looks_like_git_overlay_url("localhost:8421/acme/heddle"));
1431        assert!(!looks_like_git_overlay_url("/local/path"));
1432    }
1433
1434    #[test]
1435    fn plan_clone_refuses_existing_destination() {
1436        let opts = base_clone_options("file:///src", "/dest");
1437        let err = plan_clone(
1438            &opts,
1439            &ClonePlanFacts {
1440                destination_exists: true,
1441                remote_source: CloneRemoteSource::Local {
1442                    path: PathBuf::from("/src"),
1443                    has_heddle: true,
1444                    is_git: false,
1445                },
1446            },
1447        )
1448        .unwrap_err();
1449        assert!(matches!(err, ClonePlanError::DestinationExists { .. }));
1450    }
1451
1452    #[test]
1453    fn plan_clone_local_heddle_vs_git_overlay() {
1454        let opts = base_clone_options("file:///src", "/dest");
1455        let heddle = plan_clone(
1456            &opts,
1457            &ClonePlanFacts {
1458                destination_exists: false,
1459                remote_source: CloneRemoteSource::Local {
1460                    path: PathBuf::from("/src"),
1461                    has_heddle: true,
1462                    is_git: true,
1463                },
1464            },
1465        )
1466        .unwrap();
1467        assert!(matches!(heddle.mode, CloneMode::LocalHeddle { .. }));
1468        assert!(!heddle.security.requires_network_session);
1469
1470        let git = plan_clone(
1471            &opts,
1472            &ClonePlanFacts {
1473                destination_exists: false,
1474                remote_source: CloneRemoteSource::Local {
1475                    path: PathBuf::from("/src"),
1476                    has_heddle: false,
1477                    is_git: true,
1478                },
1479            },
1480        )
1481        .unwrap();
1482        assert!(matches!(git.mode, CloneMode::LocalGitOverlay { .. }));
1483    }
1484
1485    #[test]
1486    fn plan_clone_network_security_and_effective_lazy() {
1487        let mut opts = base_clone_options("heddle://host:1/repo", "/dest");
1488        opts.insecure = true;
1489        opts.lazy = false;
1490        opts.filter = Some("blob:none".into());
1491        opts.depth = Some(0);
1492
1493        let plan = plan_clone(
1494            &opts,
1495            &ClonePlanFacts {
1496                destination_exists: false,
1497                remote_source: CloneRemoteSource::Network {
1498                    has_repo_path: true,
1499                },
1500            },
1501        )
1502        .unwrap();
1503
1504        assert_eq!(plan.mode, CloneMode::NetworkHosted { recursive: false });
1505        assert!(plan.security.requires_network_session);
1506        assert!(plan.security.allow_insecure);
1507        assert!(plan.effective_lazy);
1508        assert_eq!(plan.depth, None);
1509    }
1510
1511    #[test]
1512    fn plan_clone_monorepo_requires_hosted() {
1513        let mut opts = base_clone_options("/local/repo", "/dest");
1514        opts.recursive = true;
1515        let err = plan_clone(
1516            &opts,
1517            &ClonePlanFacts {
1518                destination_exists: false,
1519                remote_source: CloneRemoteSource::Local {
1520                    path: PathBuf::from("/local/repo"),
1521                    has_heddle: true,
1522                    is_git: false,
1523                },
1524            },
1525        )
1526        .unwrap_err();
1527        assert!(matches!(err, ClonePlanError::MonorepoRequiresHosted { .. }));
1528
1529        let mut opts = base_clone_options("https://example.com/r.git", "/dest");
1530        opts.recursive = true;
1531        let err = plan_clone(
1532            &opts,
1533            &ClonePlanFacts {
1534                destination_exists: false,
1535                remote_source: CloneRemoteSource::Unparsed,
1536            },
1537        )
1538        .unwrap_err();
1539        assert!(matches!(err, ClonePlanError::MonorepoRequiresHosted { .. }));
1540    }
1541
1542    #[test]
1543    fn plan_clone_unparsed_git_url_and_invalid() {
1544        let plan = plan_clone(
1545            &base_clone_options("https://example.com/r.git", "/dest"),
1546            &ClonePlanFacts {
1547                destination_exists: false,
1548                remote_source: CloneRemoteSource::Unparsed,
1549            },
1550        )
1551        .unwrap();
1552        assert_eq!(plan.mode, CloneMode::GitOverlayUrl);
1553
1554        let err = plan_clone(
1555            &base_clone_options("not-a-remote", "/dest"),
1556            &ClonePlanFacts {
1557                destination_exists: false,
1558                remote_source: CloneRemoteSource::Unparsed,
1559            },
1560        )
1561        .unwrap_err();
1562        assert!(matches!(err, ClonePlanError::InvalidRemoteUrl { .. }));
1563
1564        let err = plan_clone(
1565            &base_clone_options("./missing", "/dest"),
1566            &ClonePlanFacts {
1567                destination_exists: false,
1568                remote_source: CloneRemoteSource::Unparsed,
1569            },
1570        )
1571        .unwrap_err();
1572        assert!(matches!(
1573            err,
1574            ClonePlanError::RemoteLooksLikeMissingLocalPath { .. }
1575        ));
1576    }
1577
1578    #[test]
1579    fn plan_clone_rejects_unsupported_mode_options() {
1580        let mut opts = base_clone_options("https://example.com/r.git", "/dest");
1581        opts.depth = Some(1);
1582        let err = plan_clone(
1583            &opts,
1584            &ClonePlanFacts {
1585                destination_exists: false,
1586                remote_source: CloneRemoteSource::Unparsed,
1587            },
1588        )
1589        .unwrap_err();
1590        assert!(matches!(
1591            err,
1592            ClonePlanError::UnsupportedOption {
1593                flag: UnsupportedCloneFlag::Depth,
1594                mode: "git-overlay",
1595                ..
1596            }
1597        ));
1598
1599        let mut opts = base_clone_options("file:///src", "/dest");
1600        opts.lazy = true;
1601        let err = plan_clone(
1602            &opts,
1603            &ClonePlanFacts {
1604                destination_exists: false,
1605                remote_source: CloneRemoteSource::Local {
1606                    path: PathBuf::from("/src"),
1607                    has_heddle: true,
1608                    is_git: false,
1609                },
1610            },
1611        )
1612        .unwrap_err();
1613        assert!(matches!(
1614            err,
1615            ClonePlanError::UnsupportedOption {
1616                flag: UnsupportedCloneFlag::Lazy,
1617                mode: "local",
1618                ..
1619            }
1620        ));
1621
1622        let mut opts = base_clone_options("heddle://h:1/r", "/dest");
1623        opts.recursive = true;
1624        opts.filter = Some("blob:none".into());
1625        let err = plan_clone(
1626            &opts,
1627            &ClonePlanFacts {
1628                destination_exists: false,
1629                remote_source: CloneRemoteSource::Network {
1630                    has_repo_path: true,
1631                },
1632            },
1633        )
1634        .unwrap_err();
1635        assert!(matches!(
1636            err,
1637            ClonePlanError::UnsupportedOption {
1638                flag: UnsupportedCloneFlag::Filter,
1639                mode: "monorepo",
1640                ..
1641            }
1642        ));
1643    }
1644
1645    #[test]
1646    fn select_clone_checkout_thread_priority_and_fail_closed() {
1647        assert_eq!(
1648            select_clone_checkout_thread(Some("feature"), None, ["main", "feature"]).unwrap(),
1649            "feature"
1650        );
1651        assert_eq!(
1652            select_clone_checkout_thread(None, Some("trunk"), ["alpha", "main", "trunk"]).unwrap(),
1653            "trunk"
1654        );
1655        assert_eq!(
1656            select_clone_checkout_thread(None, None, ["master", "main"]).unwrap(),
1657            "main"
1658        );
1659        assert_eq!(
1660            select_clone_checkout_thread(None, None, ["refs/heads/trunk", "trunk"]).unwrap(),
1661            "trunk"
1662        );
1663        assert_eq!(
1664            select_clone_checkout_thread(
1665                Some("refs/heads/feature"),
1666                Some("main"),
1667                ["feature", "main"]
1668            )
1669            .unwrap(),
1670            "feature"
1671        );
1672        assert!(matches!(
1673            select_clone_checkout_thread(Some("missing"), None, ["main"]),
1674            Err(CloneThreadSelectError::RequestedNotAdvertised { requested })
1675                if requested == "missing"
1676        ));
1677        assert!(matches!(
1678            select_clone_checkout_thread(None, None, ["refs/heads/only"]),
1679            Err(CloneThreadSelectError::NoAdvertisedThreads)
1680        ));
1681    }
1682
1683    #[test]
1684    fn plan_adopt_path_resolution_and_conflict() {
1685        let cwd = PathBuf::from("/work");
1686        let plan = plan_adopt(&AdoptPlanOptions {
1687            path: None,
1688            repo_flag: None,
1689            cwd: cwd.clone(),
1690            refs: vec![],
1691        })
1692        .unwrap();
1693        assert_eq!(plan.start_path, cwd);
1694        assert!(plan.import_all_refs);
1695
1696        let plan = plan_adopt(&AdoptPlanOptions {
1697            path: Some(PathBuf::from("repo")),
1698            repo_flag: None,
1699            cwd: PathBuf::from("/work"),
1700            refs: vec!["main".into()],
1701        })
1702        .unwrap();
1703        assert_eq!(plan.start_path, PathBuf::from("repo"));
1704        assert!(!plan.import_all_refs);
1705
1706        let plan = plan_adopt(&AdoptPlanOptions {
1707            path: Some(PathBuf::from("repo")),
1708            repo_flag: Some(PathBuf::from("/work/repo")),
1709            cwd: PathBuf::from("/work"),
1710            refs: vec![],
1711        })
1712        .unwrap();
1713        assert_eq!(plan.start_path, PathBuf::from("repo"));
1714
1715        let err = plan_adopt(&AdoptPlanOptions {
1716            path: Some(PathBuf::from("a")),
1717            repo_flag: Some(PathBuf::from("b")),
1718            cwd: PathBuf::from("/work"),
1719            refs: vec![],
1720        })
1721        .unwrap_err();
1722        assert!(matches!(err, AdoptPlanError::PathConflict { .. }));
1723    }
1724
1725    #[test]
1726    fn assemble_security_only_for_network() {
1727        let local = assemble_clone_security_preflight(
1728            &CloneMode::LocalHeddle {
1729                remote_path: PathBuf::from("/s"),
1730            },
1731            true,
1732        );
1733        assert!(!local.allow_insecure);
1734        assert!(!local.requires_network_session);
1735
1736        let net =
1737            assemble_clone_security_preflight(&CloneMode::NetworkHosted { recursive: false }, true);
1738        assert!(net.allow_insecure);
1739        assert!(net.requires_network_session);
1740    }
1741
1742    // ---- monorepo pure planning ----
1743
1744    fn cid(seed: u8) -> StateId {
1745        StateId::from_bytes([seed; 32])
1746    }
1747
1748    fn leaf(spool_id: &str, content: u8) -> MonorepoNodeFacts {
1749        MonorepoNodeFacts {
1750            spool_id: spool_id.to_string(),
1751            content_state: Some(cid(content)),
1752            edges: vec![],
1753        }
1754    }
1755
1756    fn selected_edge(mount: &str, child_id: &str, child: MonorepoNodeFacts) -> MonorepoEdgeFacts {
1757        MonorepoEdgeFacts {
1758            mount_name: mount.to_string(),
1759            child_spool_id: child_id.to_string(),
1760            child: Some(child),
1761            skip_reason: None,
1762        }
1763    }
1764
1765    fn skipped_edge(
1766        mount: &str,
1767        child_id: &str,
1768        reason: MonorepoEdgeSkipReason,
1769    ) -> MonorepoEdgeFacts {
1770        MonorepoEdgeFacts {
1771            mount_name: mount.to_string(),
1772            child_spool_id: child_id.to_string(),
1773            child: None,
1774            skip_reason: Some(reason),
1775        }
1776    }
1777
1778    /// root (c1)
1779    ///  ├─ libs/  -> child-a (c2)
1780    ///  │            └─ vendor/ -> grandchild (c3)
1781    ///  └─ secret/ -> child-b  [SKIPPED: unreadable]
1782    fn fixture_tree() -> MonorepoNodeFacts {
1783        let grandchild = leaf("acme/grandchild", 3);
1784        let child_a = MonorepoNodeFacts {
1785            spool_id: "acme/child-a".to_string(),
1786            content_state: Some(cid(2)),
1787            edges: vec![selected_edge("vendor", "acme/grandchild", grandchild)],
1788        };
1789        MonorepoNodeFacts {
1790            spool_id: "acme/root".to_string(),
1791            content_state: Some(cid(1)),
1792            edges: vec![
1793                selected_edge("libs", "acme/child-a", child_a),
1794                skipped_edge("secret", "acme/child-b", MonorepoEdgeSkipReason::Unreadable),
1795            ],
1796        }
1797    }
1798
1799    #[test]
1800    fn plan_monorepo_places_nodes_at_mount_paths_in_preorder() {
1801        let plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
1802
1803        assert_eq!(plan.nodes.len(), 3, "root + child-a + grandchild");
1804
1805        assert_eq!(plan.nodes[0].spool_id, "acme/root");
1806        assert_eq!(plan.nodes[0].rel_path, PathBuf::new());
1807        assert_eq!(plan.nodes[0].content_state, Some(cid(1)));
1808
1809        assert_eq!(plan.nodes[1].spool_id, "acme/child-a");
1810        assert_eq!(plan.nodes[1].rel_path, PathBuf::from("libs"));
1811        assert_eq!(plan.nodes[1].content_state, Some(cid(2)));
1812
1813        assert_eq!(plan.nodes[2].spool_id, "acme/grandchild");
1814        assert_eq!(plan.nodes[2].rel_path, PathBuf::from("libs").join("vendor"));
1815        assert_eq!(plan.nodes[2].content_state, Some(cid(3)));
1816    }
1817
1818    #[test]
1819    fn plan_monorepo_records_skipped_children_and_does_not_select_them() {
1820        let plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
1821
1822        assert_eq!(plan.skipped.len(), 1);
1823        let sk = &plan.skipped[0];
1824        assert_eq!(sk.child_spool_id, "acme/child-b");
1825        assert_eq!(sk.mount_name, "secret");
1826        assert_eq!(sk.rel_path, PathBuf::from("secret"));
1827        assert_eq!(sk.reason, MonorepoEdgeSkipReason::Unreadable);
1828        assert_eq!(sk.reason_label(), "unreadable");
1829
1830        assert!(
1831            plan.nodes.iter().all(|n| n.spool_id != "acme/child-b"),
1832            "skipped child must not appear as a materialize node"
1833        );
1834    }
1835
1836    #[test]
1837    fn monorepo_node_dest_path_joins_root() {
1838        let plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
1839        let root = Path::new("/tmp/mono");
1840
1841        assert_eq!(plan.nodes[0].dest_path(root), PathBuf::from("/tmp/mono"));
1842        assert_eq!(
1843            plan.nodes[1].dest_path(root),
1844            PathBuf::from("/tmp/mono/libs")
1845        );
1846        assert_eq!(
1847            plan.nodes[2].dest_path(root),
1848            PathBuf::from("/tmp/mono/libs/vendor")
1849        );
1850    }
1851
1852    #[test]
1853    fn plan_monorepo_empty_content_still_walks_children() {
1854        let child = leaf("acme/child", 5);
1855        let root = MonorepoNodeFacts {
1856            spool_id: "acme/root".to_string(),
1857            content_state: None,
1858            edges: vec![selected_edge("sub", "acme/child", child)],
1859        };
1860        let plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
1861
1862        assert_eq!(plan.nodes.len(), 2);
1863        assert_eq!(plan.nodes[0].spool_id, "acme/root");
1864        assert_eq!(plan.nodes[0].content_state, None);
1865        assert_eq!(plan.nodes[1].spool_id, "acme/child");
1866        assert_eq!(plan.nodes[1].rel_path, PathBuf::from("sub"));
1867        assert_eq!(plan.nodes[1].content_state, Some(cid(5)));
1868    }
1869
1870    #[test]
1871    fn plan_monorepo_rejects_mounts_that_are_not_one_relative_component() {
1872        for mount in [
1873            "",
1874            ".",
1875            "..",
1876            "../victim",
1877            "/tmp/victim",
1878            "libs/child",
1879            r"libs\child",
1880        ] {
1881            let root = MonorepoNodeFacts {
1882                spool_id: "acme/root".to_string(),
1883                content_state: Some(cid(1)),
1884                edges: vec![selected_edge(mount, "acme/child", leaf("acme/child", 2))],
1885            };
1886            assert!(
1887                matches!(
1888                    plan_monorepo_clone(&root),
1889                    Err(MonorepoClonePlanError::InvalidMountName {
1890                        child_spool_id,
1891                        mount_name,
1892                    }) if child_spool_id == "acme/child" && mount_name == mount
1893                ),
1894                "mount {mount:?} must fail before clone I/O"
1895            );
1896        }
1897    }
1898
1899    #[test]
1900    fn plan_monorepo_missing_skip_reason_defaults_to_unspecified() {
1901        let root = MonorepoNodeFacts {
1902            spool_id: "root".to_string(),
1903            content_state: Some(cid(1)),
1904            edges: vec![MonorepoEdgeFacts {
1905                mount_name: "m".into(),
1906                child_spool_id: "child".into(),
1907                child: None,
1908                skip_reason: None,
1909            }],
1910        };
1911        let plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
1912        assert_eq!(plan.skipped.len(), 1);
1913        assert_eq!(plan.skipped[0].reason, MonorepoEdgeSkipReason::Unspecified);
1914        assert_eq!(plan.skipped[0].reason_label(), "unspecified");
1915    }
1916
1917    #[test]
1918    fn monorepo_rel_display_and_wire_skip() {
1919        assert_eq!(monorepo_rel_display(Path::new("")), ".");
1920        assert_eq!(monorepo_rel_display(Path::new("libs")), "libs");
1921        assert_eq!(
1922            MonorepoEdgeSkipReason::from_wire_i32(1),
1923            Some(MonorepoEdgeSkipReason::Unreadable)
1924        );
1925        assert_eq!(MonorepoEdgeSkipReason::from_wire_i32(99), None);
1926    }
1927
1928    #[test]
1929    fn monorepo_edge_skip_labels_are_stable() {
1930        for (reason, label) in [
1931            (MonorepoEdgeSkipReason::Unreadable, "unreadable"),
1932            (MonorepoEdgeSkipReason::Cycle, "cycle"),
1933            (MonorepoEdgeSkipReason::DepthBounded, "depth-bounded"),
1934        ] {
1935            assert_eq!(reason.as_str(), label);
1936            let root = MonorepoNodeFacts {
1937                spool_id: "root".to_string(),
1938                content_state: Some(cid(1)),
1939                edges: vec![skipped_edge("m", "child", reason)],
1940            };
1941            let plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
1942            assert_eq!(plan.skipped[0].reason_label(), label);
1943        }
1944    }
1945
1946    #[test]
1947    fn validate_monorepo_clone_options_refuses_filter_lazy_depth() {
1948        assert!(validate_monorepo_clone_options(None, false, None).is_ok());
1949
1950        assert!(matches!(
1951            validate_monorepo_clone_options(None, false, Some("blob:none")),
1952            Err(ClonePlanError::UnsupportedOption {
1953                flag: UnsupportedCloneFlag::Filter,
1954                mode: "monorepo",
1955                ..
1956            })
1957        ));
1958        assert!(matches!(
1959            validate_monorepo_clone_options(None, true, None),
1960            Err(ClonePlanError::UnsupportedOption {
1961                flag: UnsupportedCloneFlag::Lazy,
1962                mode: "monorepo",
1963                ..
1964            })
1965        ));
1966        assert!(matches!(
1967            validate_monorepo_clone_options(Some(1), false, None),
1968            Err(ClonePlanError::UnsupportedOption {
1969                flag: UnsupportedCloneFlag::Depth,
1970                mode: "monorepo",
1971                ..
1972            })
1973        ));
1974    }
1975
1976    #[test]
1977    fn selected_edge_with_skip_reason_still_descends() {
1978        // Selection is driven by presence of child facts, not skip_reason.
1979        let child = leaf("acme/child", 2);
1980        let root = MonorepoNodeFacts {
1981            spool_id: "root".to_string(),
1982            content_state: Some(cid(1)),
1983            edges: vec![MonorepoEdgeFacts {
1984                mount_name: "sub".into(),
1985                child_spool_id: "acme/child".into(),
1986                child: Some(child),
1987                skip_reason: Some(MonorepoEdgeSkipReason::Unreadable),
1988            }],
1989        };
1990        let plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
1991        assert_eq!(plan.nodes.len(), 2);
1992        assert!(plan.skipped.is_empty());
1993        assert_eq!(plan.nodes[1].spool_id, "acme/child");
1994    }
1995
1996    // ---- monorepo per-node execution scaffolding ----
1997
1998    #[test]
1999    fn plan_monorepo_node_steps_full_content_order() {
2000        let node = MonorepoNodePlan {
2001            spool_id: "acme/root".into(),
2002            content_state: Some(cid(1)),
2003            rel_path: PathBuf::new(),
2004        };
2005        let steps = plan_monorepo_node_steps(&node, &MonorepoNodeStepOptions::default());
2006        assert_eq!(
2007            steps
2008                .iter()
2009                .map(MonorepoNodeExecutionStep::as_str)
2010                .collect::<Vec<_>>(),
2011            [
2012                "validate_dest",
2013                "init_repo",
2014                "fetch_content",
2015                "materialize_state",
2016                "record_mapping",
2017            ]
2018        );
2019        assert_eq!(
2020            steps[2],
2021            MonorepoNodeExecutionStep::FetchContent { state: cid(1) }
2022        );
2023        assert_eq!(
2024            steps[3],
2025            MonorepoNodeExecutionStep::MaterializeState { state: cid(1) }
2026        );
2027    }
2028
2029    #[test]
2030    fn plan_monorepo_node_steps_empty_content_skips_fetch_and_materialize() {
2031        let node = MonorepoNodePlan {
2032            spool_id: "acme/empty".into(),
2033            content_state: None,
2034            rel_path: PathBuf::from("libs"),
2035        };
2036        let steps = plan_monorepo_node_steps(&node, &MonorepoNodeStepOptions::default());
2037        assert_eq!(
2038            steps,
2039            vec![
2040                MonorepoNodeExecutionStep::ValidateDest,
2041                MonorepoNodeExecutionStep::InitRepo,
2042                MonorepoNodeExecutionStep::RecordMapping,
2043            ]
2044        );
2045    }
2046
2047    #[test]
2048    fn plan_monorepo_node_steps_can_omit_record_mapping() {
2049        let node = MonorepoNodePlan {
2050            spool_id: "acme/root".into(),
2051            content_state: Some(cid(9)),
2052            rel_path: PathBuf::new(),
2053        };
2054        let steps = plan_monorepo_node_steps(
2055            &node,
2056            &MonorepoNodeStepOptions {
2057                record_mapping: false,
2058            },
2059        );
2060        assert_eq!(
2061            steps
2062                .iter()
2063                .map(MonorepoNodeExecutionStep::as_str)
2064                .collect::<Vec<_>>(),
2065            [
2066                "validate_dest",
2067                "init_repo",
2068                "fetch_content",
2069                "materialize_state",
2070            ]
2071        );
2072        assert!(
2073            !steps
2074                .iter()
2075                .any(|s| matches!(s, MonorepoNodeExecutionStep::RecordMapping))
2076        );
2077    }
2078
2079    #[test]
2080    fn plan_monorepo_execution_preserves_preorder_and_skipped() {
2081        let clone_plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
2082        let exec = plan_monorepo_execution(&clone_plan, &MonorepoNodeStepOptions::default());
2083
2084        assert_eq!(exec.node_count(), 3);
2085        assert_eq!(exec.nodes.len(), clone_plan.nodes.len());
2086        assert_eq!(exec.skipped, clone_plan.skipped);
2087
2088        // Pre-order preserved: root → libs → libs/vendor
2089        assert_eq!(exec.nodes[0].node.spool_id, "acme/root");
2090        assert_eq!(exec.nodes[1].node.spool_id, "acme/child-a");
2091        assert_eq!(exec.nodes[2].node.spool_id, "acme/grandchild");
2092        assert_eq!(
2093            exec.nodes[2].node.rel_path,
2094            PathBuf::from("libs").join("vendor")
2095        );
2096
2097        // Every content-bearing node gets the full five-step sequence.
2098        for node_exec in &exec.nodes {
2099            assert_eq!(
2100                node_exec
2101                    .steps
2102                    .iter()
2103                    .map(MonorepoNodeExecutionStep::as_str)
2104                    .collect::<Vec<_>>(),
2105                [
2106                    "validate_dest",
2107                    "init_repo",
2108                    "fetch_content",
2109                    "materialize_state",
2110                    "record_mapping",
2111                ]
2112            );
2113        }
2114    }
2115
2116    #[test]
2117    fn plan_monorepo_execution_empty_root_still_emits_scaffold_steps() {
2118        let child = leaf("acme/child", 5);
2119        let root = MonorepoNodeFacts {
2120            spool_id: "acme/root".to_string(),
2121            content_state: None,
2122            edges: vec![selected_edge("sub", "acme/child", child)],
2123        };
2124        let clone_plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
2125        let exec = plan_monorepo_execution(&clone_plan, &MonorepoNodeStepOptions::default());
2126
2127        assert_eq!(exec.nodes[0].node.content_state, None);
2128        assert_eq!(
2129            exec.nodes[0].steps,
2130            vec![
2131                MonorepoNodeExecutionStep::ValidateDest,
2132                MonorepoNodeExecutionStep::InitRepo,
2133                MonorepoNodeExecutionStep::RecordMapping,
2134            ]
2135        );
2136        // Child with content still gets fetch + materialize after parent.
2137        assert!(
2138            exec.nodes[1]
2139                .steps
2140                .iter()
2141                .any(|s| matches!(s, MonorepoNodeExecutionStep::FetchContent { .. }))
2142        );
2143    }
2144
2145    // ---- monorepo step validation / progress / result summary ----
2146
2147    #[test]
2148    fn validate_monorepo_node_execution_accepts_planner_output() {
2149        let full = MonorepoNodePlan {
2150            spool_id: "acme/root".into(),
2151            content_state: Some(cid(1)),
2152            rel_path: PathBuf::new(),
2153        };
2154        let empty = MonorepoNodePlan {
2155            spool_id: "acme/empty".into(),
2156            content_state: None,
2157            rel_path: PathBuf::from("libs"),
2158        };
2159        assert!(
2160            validate_monorepo_node_execution(&plan_monorepo_node_steps(
2161                &full,
2162                &MonorepoNodeStepOptions::default()
2163            ))
2164            .is_ok()
2165        );
2166        assert!(
2167            validate_monorepo_node_execution(&plan_monorepo_node_steps(
2168                &empty,
2169                &MonorepoNodeStepOptions::default()
2170            ))
2171            .is_ok()
2172        );
2173        assert!(
2174            validate_monorepo_node_execution(&plan_monorepo_node_steps(
2175                &full,
2176                &MonorepoNodeStepOptions {
2177                    record_mapping: false
2178                }
2179            ))
2180            .is_ok()
2181        );
2182    }
2183
2184    #[test]
2185    fn validate_monorepo_node_execution_rejects_empty_and_missing_scaffold() {
2186        assert_eq!(
2187            validate_monorepo_node_execution(&[]),
2188            Err(MonorepoNodeExecutionError::EmptySteps)
2189        );
2190        assert_eq!(
2191            validate_monorepo_node_execution(&[MonorepoNodeExecutionStep::InitRepo]),
2192            Err(MonorepoNodeExecutionError::OutOfOrder {
2193                step: "init_repo",
2194                detail: "InitRepo requires ValidateDest first",
2195            })
2196        );
2197        assert_eq!(
2198            validate_monorepo_node_execution(&[MonorepoNodeExecutionStep::ValidateDest]),
2199            Err(MonorepoNodeExecutionError::MissingStep { step: "init_repo" })
2200        );
2201    }
2202
2203    #[test]
2204    fn validate_monorepo_node_execution_requires_init_before_fetch() {
2205        let steps = vec![
2206            MonorepoNodeExecutionStep::ValidateDest,
2207            MonorepoNodeExecutionStep::FetchContent { state: cid(1) },
2208            MonorepoNodeExecutionStep::MaterializeState { state: cid(1) },
2209        ];
2210        assert_eq!(
2211            validate_monorepo_node_execution(&steps),
2212            Err(MonorepoNodeExecutionError::OutOfOrder {
2213                step: "fetch_content",
2214                detail: "Init before Fetch",
2215            })
2216        );
2217    }
2218
2219    #[test]
2220    fn validate_monorepo_node_execution_pairs_fetch_and_materialize() {
2221        let fetch_only = vec![
2222            MonorepoNodeExecutionStep::ValidateDest,
2223            MonorepoNodeExecutionStep::InitRepo,
2224            MonorepoNodeExecutionStep::FetchContent { state: cid(1) },
2225        ];
2226        assert_eq!(
2227            validate_monorepo_node_execution(&fetch_only),
2228            Err(MonorepoNodeExecutionError::FetchWithoutMaterialize)
2229        );
2230
2231        let materialize_only = vec![
2232            MonorepoNodeExecutionStep::ValidateDest,
2233            MonorepoNodeExecutionStep::InitRepo,
2234            MonorepoNodeExecutionStep::MaterializeState { state: cid(1) },
2235        ];
2236        assert_eq!(
2237            validate_monorepo_node_execution(&materialize_only),
2238            Err(MonorepoNodeExecutionError::MaterializeWithoutFetch)
2239        );
2240
2241        let mismatch = vec![
2242            MonorepoNodeExecutionStep::ValidateDest,
2243            MonorepoNodeExecutionStep::InitRepo,
2244            MonorepoNodeExecutionStep::FetchContent { state: cid(1) },
2245            MonorepoNodeExecutionStep::MaterializeState { state: cid(2) },
2246        ];
2247        assert_eq!(
2248            validate_monorepo_node_execution(&mismatch),
2249            Err(MonorepoNodeExecutionError::FetchMaterializeStateMismatch {
2250                fetch: cid(1),
2251                materialize: cid(2),
2252            })
2253        );
2254    }
2255
2256    #[test]
2257    fn validate_monorepo_node_execution_rejects_duplicate_or_reordered_steps() {
2258        let dup = vec![
2259            MonorepoNodeExecutionStep::ValidateDest,
2260            MonorepoNodeExecutionStep::InitRepo,
2261            MonorepoNodeExecutionStep::InitRepo,
2262        ];
2263        assert!(matches!(
2264            validate_monorepo_node_execution(&dup),
2265            Err(MonorepoNodeExecutionError::OutOfOrder {
2266                step: "init_repo",
2267                ..
2268            })
2269        ));
2270
2271        let reordered = vec![
2272            MonorepoNodeExecutionStep::InitRepo,
2273            MonorepoNodeExecutionStep::ValidateDest,
2274        ];
2275        assert!(matches!(
2276            validate_monorepo_node_execution(&reordered),
2277            Err(MonorepoNodeExecutionError::OutOfOrder { .. })
2278        ));
2279    }
2280
2281    #[test]
2282    fn validate_monorepo_execution_accepts_full_plan() {
2283        let clone_plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
2284        let exec = plan_monorepo_execution(&clone_plan, &MonorepoNodeStepOptions::default());
2285        assert!(validate_monorepo_execution(&exec).is_ok());
2286    }
2287
2288    #[test]
2289    fn monorepo_execution_progress_labels_are_stable() {
2290        let step = MonorepoNodeExecutionStep::InitRepo;
2291        let progress = monorepo_execution_progress(0, 3, &step);
2292        assert_eq!(progress.node_index, 0);
2293        assert_eq!(progress.total_nodes, 3);
2294        assert_eq!(progress.node_display, 1);
2295        assert_eq!(progress.step, "init_repo");
2296        assert_eq!(progress.label(), "[1/3] init_repo");
2297
2298        let fetch = MonorepoNodeExecutionStep::FetchContent { state: cid(9) };
2299        let p2 = monorepo_execution_progress(2, 3, &fetch);
2300        assert_eq!(p2.label(), "[3/3] fetch_content");
2301    }
2302
2303    #[test]
2304    fn assemble_monorepo_clone_result_summary_counts_placed_and_skipped() {
2305        let clone_plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
2306        let exec = plan_monorepo_execution(&clone_plan, &MonorepoNodeStepOptions::default());
2307        let summary = assemble_monorepo_clone_result_summary(&exec);
2308
2309        assert_eq!(summary.placed_count, 3);
2310        assert_eq!(summary.skipped_count, 1);
2311        assert_eq!(summary.placed.len(), 3);
2312        assert_eq!(summary.skipped.len(), 1);
2313        assert_eq!(summary.placed[0].spool_id, "acme/root");
2314        assert!(summary.placed[0].materialized_content);
2315        assert_eq!(summary.skipped[0].child_spool_id, "acme/child-b");
2316
2317        assert_eq!(
2318            summary.headline("acme/root"),
2319            "Cloned monorepo acme/root (3 spools placed)."
2320        );
2321        assert_eq!(
2322            summary.skipped_header().as_deref(),
2323            Some("1 child spool(s) skipped (not part of your coherent slice):")
2324        );
2325
2326        // Singular headline.
2327        let single = MonorepoCloneResultSummary {
2328            placed_count: 1,
2329            skipped_count: 0,
2330            placed: vec![],
2331            skipped: vec![],
2332        };
2333        assert_eq!(
2334            single.headline("solo"),
2335            "Cloned monorepo solo (1 spool placed)."
2336        );
2337        assert!(single.skipped_header().is_none());
2338    }
2339
2340    #[test]
2341    fn assemble_summary_marks_empty_content_nodes_not_materialized() {
2342        let root = MonorepoNodeFacts {
2343            spool_id: "acme/root".to_string(),
2344            content_state: None,
2345            edges: vec![],
2346        };
2347        let exec = plan_monorepo_execution(
2348            &plan_monorepo_clone(&root).expect("plan monorepo clone"),
2349            &MonorepoNodeStepOptions::default(),
2350        );
2351        let summary = assemble_monorepo_clone_result_summary(&exec);
2352        assert_eq!(summary.placed_count, 1);
2353        assert!(!summary.placed[0].materialized_content);
2354        assert_eq!(summary.placed[0].content_state, None);
2355    }
2356}