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("{}", display_cycle_detected(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::CycleDetected`] message. Renders the chain
163/// arrow-joined for operator legibility (`a → b → c → a`) instead of
164/// the debug-vec rendering. Defensive on empty chains so a malformed
165/// caller cannot panic the error path.
166fn display_cycle_detected(chain: &[String]) -> String {
167 if chain.is_empty() {
168 return "cycle detected in pack graph (empty chain)".to_string();
169 }
170 format!("cycle detected in pack graph: {}", chain.join(" → "))
171}
172
173/// Format a [`TreeError::DirtyTreeRefusal`] message. Extracted so the
174/// `#[error]` attribute can reference a function call instead of a
175/// trailing match expression.
176fn display_dirty_tree_refusal(path: &std::path::Path, kind: &DirtyTreeRefusalKind) -> String {
177 match kind {
178 DirtyTreeRefusalKind::DirtyTree => {
179 format!("refusing to prune {}: working tree dirty", path.display())
180 }
181 DirtyTreeRefusalKind::DirtyTreeWithIgnored => format!(
182 "refusing to prune {}: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
183 path.display()
184 ),
185 DirtyTreeRefusalKind::GitInProgress => format!(
186 "refusing to prune {}: in-progress git operation (rebase/merge/cherry-pick)",
187 path.display()
188 ),
189 DirtyTreeRefusalKind::SubMetaWithDirtyChildren => format!(
190 "refusing to prune {}: nested meta-repo has dirty children",
191 path.display()
192 ),
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 //! v1.2.0 Stage 1.k — error-variant Display assertions.
199 //!
200 //! Pure construction + `to_string()` checks. Variants are dormant
201 //! until later stages (1.c validator, 1.e walker Phase 1, 1.f Phase 2
202 //! prune-safety, 1.h migrator) wire them into producers.
203 use super::*;
204
205 #[test]
206 fn test_tree_error_legacy_lockfile_detected_display() {
207 let err = TreeError::LegacyLockfileDetected {
208 path: PathBuf::from("/repos/code/.grex/lock.yaml"),
209 };
210 assert_eq!(
211 err.to_string(),
212 "v1.1.1 lockfile detected at /repos/code/.grex/lock.yaml, run grex migrate-lockfile",
213 );
214 }
215
216 #[test]
217 fn test_tree_error_untracked_git_repos_display_single() {
218 let err = TreeError::UntrackedGitRepos { paths: vec![PathBuf::from("alpha")] };
219 assert_eq!(
220 err.to_string(),
221 "untracked git repositories found: alpha; either register them as packs or remove from manifest",
222 );
223 }
224
225 #[test]
226 fn test_tree_error_untracked_git_repos_display_multiple() {
227 let err = TreeError::UntrackedGitRepos {
228 paths: vec![PathBuf::from("alpha"), PathBuf::from("beta"), PathBuf::from("gamma")],
229 };
230 assert_eq!(
231 err.to_string(),
232 "untracked git repositories found: alpha, beta, gamma; either register them as packs or remove from manifest",
233 );
234 }
235
236 #[test]
237 fn test_tree_error_dirty_tree_refusal_display_dirty_tree() {
238 let err = TreeError::DirtyTreeRefusal {
239 path: PathBuf::from("alpha"),
240 kind: DirtyTreeRefusalKind::DirtyTree,
241 };
242 assert_eq!(err.to_string(), "refusing to prune alpha: working tree dirty");
243 }
244
245 #[test]
246 fn test_tree_error_dirty_tree_refusal_display_dirty_with_ignored() {
247 let err = TreeError::DirtyTreeRefusal {
248 path: PathBuf::from("alpha"),
249 kind: DirtyTreeRefusalKind::DirtyTreeWithIgnored,
250 };
251 assert_eq!(
252 err.to_string(),
253 "refusing to prune alpha: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
254 );
255 }
256
257 #[test]
258 fn test_tree_error_dirty_tree_refusal_display_git_in_progress() {
259 let err = TreeError::DirtyTreeRefusal {
260 path: PathBuf::from("alpha"),
261 kind: DirtyTreeRefusalKind::GitInProgress,
262 };
263 assert_eq!(
264 err.to_string(),
265 "refusing to prune alpha: in-progress git operation (rebase/merge/cherry-pick)",
266 );
267 }
268
269 #[test]
270 fn test_tree_error_dirty_tree_refusal_display_sub_meta_dirty() {
271 let err = TreeError::DirtyTreeRefusal {
272 path: PathBuf::from("alpha"),
273 kind: DirtyTreeRefusalKind::SubMetaWithDirtyChildren,
274 };
275 assert_eq!(err.to_string(), "refusing to prune alpha: nested meta-repo has dirty children",);
276 }
277
278 #[test]
279 fn test_tree_error_manifest_path_escape_display() {
280 let err = TreeError::ManifestPathEscape {
281 path: "../escape".into(),
282 reason: "child path escapes parent root".into(),
283 };
284 assert_eq!(
285 err.to_string(),
286 "manifest path '../escape' escapes parent boundary: child path escapes parent root",
287 );
288 }
289}