Skip to main content

grex_core/tree/
error.rs

1//! Error taxonomy for the [`crate::tree`] walker.
2//!
3//! Errors carry `PathBuf` and `String` detail fields rather than boxing
4//! underlying loader or parser errors. Keeping leaky types out of the public
5//! surface means adding a new loader backend (IPC, in-memory, http) in a
6//! future slice stays non-breaking.
7
8use std::path::PathBuf;
9
10use thiserror::Error;
11
12use crate::git::GitError;
13
14/// Errors raised during a pack-tree walk.
15///
16/// Marked `#[non_exhaustive]` so later slices (credentials, submodules,
17/// partial walks) can add variants without breaking consumers.
18#[non_exhaustive]
19#[derive(Debug, Error)]
20pub enum TreeError {
21    /// The walker expected a `pack.yaml` at the given location but could not
22    /// find one (or its enclosing `.grex/` directory was missing).
23    #[error("pack manifest not found at `{0}`")]
24    ManifestNotFound(PathBuf),
25
26    /// The manifest file existed but could not be read from disk.
27    #[error("failed to read pack manifest: {0}")]
28    ManifestRead(String),
29
30    /// The manifest file was read but did not parse as a valid `pack.yaml`.
31    #[error("failed to parse pack manifest at `{path}`: {detail}")]
32    ManifestParse {
33        /// On-disk location of the manifest that failed to parse.
34        path: PathBuf,
35        /// Backend-provided failure detail.
36        detail: String,
37    },
38
39    /// A git operation (clone, fetch, checkout, …) failed while hydrating a
40    /// child pack. The underlying [`GitError`] is preserved in full.
41    #[error("git error during walk: {0}")]
42    Git(#[from] GitError),
43
44    /// A cycle was detected during the walk. `chain` lists the pack URLs (or
45    /// paths for the root) from the outermost pack down to the recurrence.
46    #[error("cycle detected in pack graph: {chain:?}")]
47    CycleDetected {
48        /// Ordered chain of pack identities that forms the cycle.
49        chain: Vec<String>,
50    },
51
52    /// A cloned child's `pack.yaml` declared a `name` that does not match
53    /// what the parent pack expected for that `children:` entry.
54    #[error("pack name `{got}` does not match expected `{expected}` for child at `{path}`")]
55    PackNameMismatch {
56        /// Name declared in the child's own manifest.
57        got: String,
58        /// Name the parent expected (derived from the child entry's
59        /// effective path).
60        expected: String,
61        /// On-disk location of the offending child.
62        path: PathBuf,
63    },
64
65    /// A `children[].path` (or URL-derived tail) violated the bare-name
66    /// rule. Surfaced by the walker BEFORE any clone of the offending
67    /// child fires so a malicious `path: ../escape` in a parent pack
68    /// cannot materialise a directory outside the pack root. This is a
69    /// security boundary, not a soft validation concern — see
70    /// `crates/grex-core/src/pack/validate/child_path.rs` for the shared
71    /// rejection logic.
72    #[error("pack child `{child_name}` has invalid path `{path}`: {reason}")]
73    ChildPathInvalid {
74        /// Label of the offending child (the explicit `path:` value, or
75        /// the URL-derived tail when `path:` is omitted).
76        child_name: String,
77        /// The rejected literal value.
78        path: String,
79        /// One-line explanation of which sub-rule failed.
80        reason: String,
81    },
82
83    /// A v1.1.1-shape lockfile was encountered without the
84    /// `--migrate-lockfile` opt-in. v1.2.0 changed the on-disk lockfile
85    /// schema; the operator must explicitly run the migrator to convert
86    /// pre-existing lockfiles. Emitted by Stage 1.h walker entry-point
87    /// before any pack-tree work begins. Dormant until 1.h wires the
88    /// detector.
89    #[error("v1.1.1 lockfile detected at {path}, run grex migrate-lockfile")]
90    LegacyLockfileDetected {
91        /// On-disk location of the legacy lockfile.
92        path: PathBuf,
93    },
94
95    /// One or more declared children own a `.git/` directory but lack a
96    /// `.grex/pack.yaml`, and the v1.2.0 nested-children semantics
97    /// preclude the v1.1.1 "synthesize plain-git pack" fallback (e.g.
98    /// because a sibling explicitly opted out, or the parent manifest
99    /// disabled synthesis). Aggregated by Stage 1.e Phase 1; the walker
100    /// reports every offender in one go so the operator can fix the
101    /// manifest with a single pass.
102    #[error(
103        "untracked git repositories found: {}; either register them as packs or remove from manifest",
104        paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
105    )]
106    UntrackedGitRepos {
107        /// Paths (relative to the parent pack root) of every offender.
108        paths: Vec<PathBuf>,
109    },
110
111    /// Stage 1.f Phase 2 prune refused to remove a destination because
112    /// the recursive consent walk returned a non-Clean verdict.
113    /// `kind` discriminates the specific safety violation so the CLI
114    /// can suggest the correct override flag.
115    #[error("{}", display_dirty_tree_refusal(path, kind))]
116    DirtyTreeRefusal {
117        /// Pack-tree-relative path of the destination the walker
118        /// refused to prune.
119        path: PathBuf,
120        /// Specific consent violation that triggered the refusal.
121        kind: DirtyTreeRefusalKind,
122    },
123
124    /// Stage 1.c validator rejected a child manifest segment that
125    /// resolved outside the parent pack root. Distinct from
126    /// [`TreeError::ChildPathInvalid`] — that variant rejects the
127    /// literal `path:` syntax (slashes, dots, absolute paths);
128    /// `ManifestPathEscape` is the post-resolution boundary check that
129    /// catches symlink-driven and platform-specific escapes.
130    #[error("manifest path '{path}' escapes parent boundary: {reason}")]
131    ManifestPathEscape {
132        /// The literal manifest path that resolved out of bounds.
133        path: String,
134        /// One-line explanation of which rule the resolved path
135        /// violated.
136        reason: String,
137    },
138}
139
140/// Discriminator for [`TreeError::DirtyTreeRefusal`]. Each kind has its
141/// own operator-facing Display string; consult the variant docs for the
142/// exact wording.
143#[non_exhaustive]
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum DirtyTreeRefusalKind {
146    /// Working tree has tracked-modified or untracked-non-ignored
147    /// content. Default refusal — operator must commit, stash, or
148    /// remove the changes manually.
149    DirtyTree,
150    /// Working tree is clean of tracked changes but holds ignored
151    /// files. Override available via `--force-prune-with-ignored`.
152    DirtyTreeWithIgnored,
153    /// `.git/` carries a rebase / merge / cherry-pick state directory.
154    /// Operator must finish or abort the operation before pruning.
155    GitInProgress,
156    /// The destination is itself a meta-repo (sub-pack-tree) and the
157    /// recursive consent walk found at least one of its descendants is
158    /// dirty. Operator must clean the descendant first.
159    SubMetaWithDirtyChildren,
160}
161
162/// Format a [`TreeError::DirtyTreeRefusal`] message. Extracted so the
163/// `#[error]` attribute can reference a function call instead of a
164/// trailing match expression.
165fn display_dirty_tree_refusal(path: &std::path::Path, kind: &DirtyTreeRefusalKind) -> String {
166    match kind {
167        DirtyTreeRefusalKind::DirtyTree => {
168            format!("refusing to prune {}: working tree dirty", path.display())
169        }
170        DirtyTreeRefusalKind::DirtyTreeWithIgnored => format!(
171            "refusing to prune {}: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
172            path.display()
173        ),
174        DirtyTreeRefusalKind::GitInProgress => format!(
175            "refusing to prune {}: in-progress git operation (rebase/merge/cherry-pick)",
176            path.display()
177        ),
178        DirtyTreeRefusalKind::SubMetaWithDirtyChildren => format!(
179            "refusing to prune {}: nested meta-repo has dirty children",
180            path.display()
181        ),
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    //! v1.2.0 Stage 1.k — error-variant Display assertions.
188    //!
189    //! Pure construction + `to_string()` checks. Variants are dormant
190    //! until later stages (1.c validator, 1.e walker Phase 1, 1.f Phase 2
191    //! prune-safety, 1.h migrator) wire them into producers.
192    use super::*;
193
194    #[test]
195    fn test_tree_error_legacy_lockfile_detected_display() {
196        let err = TreeError::LegacyLockfileDetected {
197            path: PathBuf::from("/repos/code/.grex/lock.yaml"),
198        };
199        assert_eq!(
200            err.to_string(),
201            "v1.1.1 lockfile detected at /repos/code/.grex/lock.yaml, run grex migrate-lockfile",
202        );
203    }
204
205    #[test]
206    fn test_tree_error_untracked_git_repos_display_single() {
207        let err = TreeError::UntrackedGitRepos { paths: vec![PathBuf::from("alpha")] };
208        assert_eq!(
209            err.to_string(),
210            "untracked git repositories found: alpha; either register them as packs or remove from manifest",
211        );
212    }
213
214    #[test]
215    fn test_tree_error_untracked_git_repos_display_multiple() {
216        let err = TreeError::UntrackedGitRepos {
217            paths: vec![PathBuf::from("alpha"), PathBuf::from("beta"), PathBuf::from("gamma")],
218        };
219        assert_eq!(
220            err.to_string(),
221            "untracked git repositories found: alpha, beta, gamma; either register them as packs or remove from manifest",
222        );
223    }
224
225    #[test]
226    fn test_tree_error_dirty_tree_refusal_display_dirty_tree() {
227        let err = TreeError::DirtyTreeRefusal {
228            path: PathBuf::from("alpha"),
229            kind: DirtyTreeRefusalKind::DirtyTree,
230        };
231        assert_eq!(err.to_string(), "refusing to prune alpha: working tree dirty");
232    }
233
234    #[test]
235    fn test_tree_error_dirty_tree_refusal_display_dirty_with_ignored() {
236        let err = TreeError::DirtyTreeRefusal {
237            path: PathBuf::from("alpha"),
238            kind: DirtyTreeRefusalKind::DirtyTreeWithIgnored,
239        };
240        assert_eq!(
241            err.to_string(),
242            "refusing to prune alpha: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
243        );
244    }
245
246    #[test]
247    fn test_tree_error_dirty_tree_refusal_display_git_in_progress() {
248        let err = TreeError::DirtyTreeRefusal {
249            path: PathBuf::from("alpha"),
250            kind: DirtyTreeRefusalKind::GitInProgress,
251        };
252        assert_eq!(
253            err.to_string(),
254            "refusing to prune alpha: in-progress git operation (rebase/merge/cherry-pick)",
255        );
256    }
257
258    #[test]
259    fn test_tree_error_dirty_tree_refusal_display_sub_meta_dirty() {
260        let err = TreeError::DirtyTreeRefusal {
261            path: PathBuf::from("alpha"),
262            kind: DirtyTreeRefusalKind::SubMetaWithDirtyChildren,
263        };
264        assert_eq!(err.to_string(), "refusing to prune alpha: nested meta-repo has dirty children",);
265    }
266
267    #[test]
268    fn test_tree_error_manifest_path_escape_display() {
269        let err = TreeError::ManifestPathEscape {
270            path: "../escape".into(),
271            reason: "child path escapes parent root".into(),
272        };
273        assert_eq!(
274            err.to_string(),
275            "manifest path '../escape' escapes parent boundary: child path escapes parent root",
276        );
277    }
278}