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