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::io;
9use std::path::PathBuf;
10
11use thiserror::Error;
12
13use crate::git::GitError;
14
15/// MSRV-safe ENOTDIR detection. `io::ErrorKind::NotADirectory` stabilised
16/// in Rust 1.83 but the workspace MSRV is pinned at 1.79. Detect via the
17/// raw OS error code instead: POSIX `ENOTDIR` = 20, Windows
18/// `ERROR_DIRECTORY` = 267.
19///
20/// Used by the manifest loader to route OS-level "not a directory"
21/// failures into [`TreeError::ManifestNotADir`] without requiring a MSRV
22/// bump.
23#[must_use]
24pub fn is_not_a_directory(err: &io::Error) -> bool {
25 match err.raw_os_error() {
26 #[cfg(unix)]
27 Some(20) => true,
28 #[cfg(windows)]
29 Some(267) => true,
30 _ => false,
31 }
32}
33
34/// Errors raised during a pack-tree walk.
35///
36/// Marked `#[non_exhaustive]` so later slices (credentials, submodules,
37/// partial walks) can add variants without breaking consumers.
38#[non_exhaustive]
39#[derive(Debug, Error)]
40pub enum TreeError {
41 /// The walker expected a `pack.yaml` at the given location but could not
42 /// find one (or its enclosing `.grex/` directory was missing).
43 #[error("pack manifest not found at `{0}`")]
44 ManifestNotFound(PathBuf),
45
46 /// The manifest file existed but could not be read from disk.
47 ///
48 /// Catch-all fallback for `io::ErrorKind` cases that do not match a
49 /// categorised variant. Prefer [`TreeError::ManifestPermissionDenied`],
50 /// [`TreeError::ManifestNotADir`], or [`TreeError::ManifestIo`] when
51 /// the producer can route via `io::Error::kind()` /
52 /// [`is_not_a_directory`]. Retained for back-compat: v1.2.0+
53 /// downstream consumers may have matched this variant explicitly.
54 #[error("failed to read pack manifest: {0}")]
55 ManifestRead(String),
56
57 /// Manifest existed but the OS denied read access (POSIX `EACCES` /
58 /// Windows `ERROR_ACCESS_DENIED`). Operator-actionable: chmod /
59 /// icacls to grant the running user read on the file.
60 #[error("permission denied reading pack manifest at `{path}`")]
61 ManifestPermissionDenied {
62 /// On-disk location of the unreadable manifest.
63 path: PathBuf,
64 },
65
66 /// Manifest path resolved to a non-directory entry where a directory
67 /// was expected (or the parent of the manifest path is not a
68 /// directory). Distinct from [`TreeError::ManifestNotFound`] — the
69 /// path exists but has the wrong type. Surfaces as `ENOTDIR` /
70 /// `ERROR_DIRECTORY` on the producer side; detection routed through
71 /// [`is_not_a_directory`] for MSRV 1.79 compatibility.
72 #[error("manifest path `{path}` is not a directory (or has wrong type)")]
73 ManifestNotADir {
74 /// On-disk location whose type prevented manifest read.
75 path: PathBuf,
76 },
77
78 /// Generic IO failure reading a manifest, preserving the underlying
79 /// [`io::Error`] for log routing without forcing the caller to
80 /// re-open the file. The catch-all path before the loader falls
81 /// through to [`TreeError::ManifestRead`] for kinds that don't match
82 /// a categorised variant.
83 #[error("I/O error reading pack manifest at `{path}`: {source}")]
84 ManifestIo {
85 /// On-disk location of the manifest whose read failed.
86 path: PathBuf,
87 /// Underlying OS error preserved for the [`std::error::Error`]
88 /// source chain.
89 #[source]
90 source: io::Error,
91 },
92
93 /// The manifest file was read but did not parse as a valid `pack.yaml`.
94 #[error("failed to parse pack manifest at `{path}`: {detail}")]
95 ManifestParse {
96 /// On-disk location of the manifest that failed to parse.
97 path: PathBuf,
98 /// Backend-provided failure detail.
99 detail: String,
100 },
101
102 /// A git operation (clone, fetch, checkout, …) failed while hydrating a
103 /// child pack. The underlying [`GitError`] is preserved in full.
104 #[error("git error during walk: {0}")]
105 Git(#[from] GitError),
106
107 /// A cycle was detected during the walk. `chain` lists the pack URLs (or
108 /// paths for the root) from the outermost pack down to the recurrence.
109 #[error("{}", display_cycle_detected(chain))]
110 CycleDetected {
111 /// Ordered chain of pack identities that forms the cycle.
112 chain: Vec<String>,
113 },
114
115 /// A cloned child's `pack.yaml` declared a `name` that does not match
116 /// what the parent pack expected for that `children:` entry.
117 #[error("pack name `{got}` does not match expected `{expected}` for child at `{path}`")]
118 PackNameMismatch {
119 /// Name declared in the child's own manifest.
120 got: String,
121 /// Name the parent expected (derived from the child entry's
122 /// effective path).
123 expected: String,
124 /// On-disk location of the offending child.
125 path: PathBuf,
126 },
127
128 /// A `children[].path` (or URL-derived tail) violated the bare-name
129 /// rule. Surfaced by the walker BEFORE any clone of the offending
130 /// child fires so a malicious `path: ../escape` in a parent pack
131 /// cannot materialise a directory outside the pack root. This is a
132 /// security boundary, not a soft validation concern — see
133 /// `crates/grex-core/src/pack/validate/child_path.rs` for the shared
134 /// rejection logic.
135 #[error("pack child `{child_name}` has invalid path `{path}`: {reason}")]
136 ChildPathInvalid {
137 /// Label of the offending child (the explicit `path:` value, or
138 /// the URL-derived tail when `path:` is omitted).
139 child_name: String,
140 /// The rejected literal value.
141 path: String,
142 /// One-line explanation of which sub-rule failed.
143 reason: String,
144 },
145
146 /// A v1.1.1-shape lockfile was encountered without the
147 /// `--migrate-lockfile` opt-in. v1.2.0 changed the on-disk lockfile
148 /// schema; the operator must explicitly run the migrator to convert
149 /// pre-existing lockfiles. Emitted by Stage 1.h walker entry-point
150 /// before any pack-tree work begins. Dormant until 1.h wires the
151 /// detector.
152 #[error("v1.1.1 lockfile detected at {path}, run grex migrate-lockfile")]
153 LegacyLockfileDetected {
154 /// On-disk location of the legacy lockfile.
155 path: PathBuf,
156 },
157
158 /// One or more declared children own a `.git/` directory but lack a
159 /// `.grex/pack.yaml`, and the v1.2.0 nested-children semantics
160 /// preclude the v1.1.1 "synthesize plain-git pack" fallback (e.g.
161 /// because a sibling explicitly opted out, or the parent manifest
162 /// disabled synthesis). Aggregated by Stage 1.e Phase 1; the walker
163 /// reports every offender in one go so the operator can fix the
164 /// manifest with a single pass.
165 #[error(
166 "untracked git repositories found: {}; either register them as packs or remove from manifest",
167 paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
168 )]
169 UntrackedGitRepos {
170 /// Paths (relative to the parent pack root) of every offender.
171 paths: Vec<PathBuf>,
172 },
173
174 /// Stage 1.f Phase 2 prune refused to remove a destination because
175 /// the recursive consent walk returned a non-Clean verdict.
176 /// `kind` discriminates the specific safety violation so the CLI
177 /// can suggest the correct override flag.
178 #[error("{}", display_dirty_tree_refusal(path, kind))]
179 DirtyTreeRefusal {
180 /// Pack-tree-relative path of the destination the walker
181 /// refused to prune.
182 path: PathBuf,
183 /// Specific consent violation that triggered the refusal.
184 kind: DirtyTreeRefusalKind,
185 },
186
187 /// Stage 1.c validator rejected a child manifest segment that
188 /// resolved outside the parent pack root. Distinct from
189 /// [`TreeError::ChildPathInvalid`] — that variant rejects the
190 /// literal `path:` syntax (slashes, dots, absolute paths);
191 /// `ManifestPathEscape` is the post-resolution boundary check that
192 /// catches symlink-driven and platform-specific escapes.
193 #[error("manifest path '{path}' escapes parent boundary: {reason}")]
194 ManifestPathEscape {
195 /// The literal manifest path that resolved out of bounds.
196 path: String,
197 /// One-line explanation of which rule the resolved path
198 /// violated.
199 reason: String,
200 },
201}
202
203/// Discriminator for [`TreeError::DirtyTreeRefusal`]. Each kind has its
204/// own operator-facing Display string; consult the variant docs for the
205/// exact wording.
206#[non_exhaustive]
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum DirtyTreeRefusalKind {
209 /// Working tree has tracked-modified or untracked-non-ignored
210 /// content. Default refusal — operator must commit, stash, or
211 /// remove the changes manually.
212 DirtyTree,
213 /// Working tree is clean of tracked changes but holds ignored
214 /// files. Override available via `--force-prune-with-ignored`.
215 DirtyTreeWithIgnored,
216 /// `.git/` carries a rebase / merge / cherry-pick state directory.
217 /// Operator must finish or abort the operation before pruning.
218 GitInProgress,
219 /// The destination is itself a meta-repo (sub-pack-tree) and the
220 /// recursive consent walk found at least one of its descendants is
221 /// dirty. Operator must clean the descendant first.
222 SubMetaWithDirtyChildren,
223}
224
225/// Format a [`TreeError::CycleDetected`] message. Renders the chain
226/// arrow-joined for operator legibility (`a → b → c → a`) instead of
227/// the debug-vec rendering. Defensive on empty chains so a malformed
228/// caller cannot panic the error path.
229fn display_cycle_detected(chain: &[String]) -> String {
230 if chain.is_empty() {
231 return "cycle detected in pack graph (empty chain)".to_string();
232 }
233 format!("cycle detected in pack graph: {}", chain.join(" → "))
234}
235
236/// Format a [`TreeError::DirtyTreeRefusal`] message. Extracted so the
237/// `#[error]` attribute can reference a function call instead of a
238/// trailing match expression.
239fn display_dirty_tree_refusal(path: &std::path::Path, kind: &DirtyTreeRefusalKind) -> String {
240 match kind {
241 DirtyTreeRefusalKind::DirtyTree => {
242 format!("refusing to prune {}: working tree dirty", path.display())
243 }
244 DirtyTreeRefusalKind::DirtyTreeWithIgnored => format!(
245 "refusing to prune {}: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
246 path.display()
247 ),
248 DirtyTreeRefusalKind::GitInProgress => format!(
249 "refusing to prune {}: in-progress git operation (rebase/merge/cherry-pick)",
250 path.display()
251 ),
252 DirtyTreeRefusalKind::SubMetaWithDirtyChildren => format!(
253 "refusing to prune {}: nested meta-repo has dirty children",
254 path.display()
255 ),
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 //! v1.2.0 Stage 1.k — error-variant Display assertions.
262 //!
263 //! Pure construction + `to_string()` checks. Variants are dormant
264 //! until later stages (1.c validator, 1.e walker Phase 1, 1.f Phase 2
265 //! prune-safety, 1.h migrator) wire them into producers.
266 use super::*;
267
268 #[test]
269 fn test_tree_error_legacy_lockfile_detected_display() {
270 let err = TreeError::LegacyLockfileDetected {
271 path: PathBuf::from("/repos/code/.grex/lock.yaml"),
272 };
273 assert_eq!(
274 err.to_string(),
275 "v1.1.1 lockfile detected at /repos/code/.grex/lock.yaml, run grex migrate-lockfile",
276 );
277 }
278
279 #[test]
280 fn test_tree_error_untracked_git_repos_display_single() {
281 let err = TreeError::UntrackedGitRepos { paths: vec![PathBuf::from("alpha")] };
282 assert_eq!(
283 err.to_string(),
284 "untracked git repositories found: alpha; either register them as packs or remove from manifest",
285 );
286 }
287
288 #[test]
289 fn test_tree_error_untracked_git_repos_display_multiple() {
290 let err = TreeError::UntrackedGitRepos {
291 paths: vec![PathBuf::from("alpha"), PathBuf::from("beta"), PathBuf::from("gamma")],
292 };
293 assert_eq!(
294 err.to_string(),
295 "untracked git repositories found: alpha, beta, gamma; either register them as packs or remove from manifest",
296 );
297 }
298
299 #[test]
300 fn test_tree_error_dirty_tree_refusal_display_dirty_tree() {
301 let err = TreeError::DirtyTreeRefusal {
302 path: PathBuf::from("alpha"),
303 kind: DirtyTreeRefusalKind::DirtyTree,
304 };
305 assert_eq!(err.to_string(), "refusing to prune alpha: working tree dirty");
306 }
307
308 #[test]
309 fn test_tree_error_dirty_tree_refusal_display_dirty_with_ignored() {
310 let err = TreeError::DirtyTreeRefusal {
311 path: PathBuf::from("alpha"),
312 kind: DirtyTreeRefusalKind::DirtyTreeWithIgnored,
313 };
314 assert_eq!(
315 err.to_string(),
316 "refusing to prune alpha: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
317 );
318 }
319
320 #[test]
321 fn test_tree_error_dirty_tree_refusal_display_git_in_progress() {
322 let err = TreeError::DirtyTreeRefusal {
323 path: PathBuf::from("alpha"),
324 kind: DirtyTreeRefusalKind::GitInProgress,
325 };
326 assert_eq!(
327 err.to_string(),
328 "refusing to prune alpha: in-progress git operation (rebase/merge/cherry-pick)",
329 );
330 }
331
332 #[test]
333 fn test_tree_error_dirty_tree_refusal_display_sub_meta_dirty() {
334 let err = TreeError::DirtyTreeRefusal {
335 path: PathBuf::from("alpha"),
336 kind: DirtyTreeRefusalKind::SubMetaWithDirtyChildren,
337 };
338 assert_eq!(err.to_string(), "refusing to prune alpha: nested meta-repo has dirty children",);
339 }
340
341 #[test]
342 fn test_tree_error_manifest_path_escape_display() {
343 let err = TreeError::ManifestPathEscape {
344 path: "../escape".into(),
345 reason: "child path escapes parent root".into(),
346 };
347 assert_eq!(
348 err.to_string(),
349 "manifest path '../escape' escapes parent boundary: child path escapes parent root",
350 );
351 }
352
353 #[test]
354 fn test_tree_error_manifest_permission_denied_display() {
355 let err = TreeError::ManifestPermissionDenied {
356 path: PathBuf::from("/repos/code/.grex/pack.yaml"),
357 };
358 assert_eq!(
359 err.to_string(),
360 "permission denied reading pack manifest at `/repos/code/.grex/pack.yaml`",
361 );
362 }
363
364 #[test]
365 fn test_tree_error_manifest_not_a_dir_display() {
366 let err = TreeError::ManifestNotADir { path: PathBuf::from("/repos/code/.grex/pack.yaml") };
367 assert_eq!(
368 err.to_string(),
369 "manifest path `/repos/code/.grex/pack.yaml` is not a directory (or has wrong type)",
370 );
371 }
372
373 #[test]
374 fn test_tree_error_manifest_io_display_and_source() {
375 use std::error::Error as _;
376
377 let underlying = io::Error::other("disk on fire");
378 let err = TreeError::ManifestIo {
379 path: PathBuf::from("/repos/code/.grex/pack.yaml"),
380 source: underlying,
381 };
382 assert_eq!(
383 err.to_string(),
384 "I/O error reading pack manifest at `/repos/code/.grex/pack.yaml`: disk on fire",
385 );
386 // The `#[source]` attribute MUST preserve the underlying io::Error
387 // so consumers can walk the chain and recover the original kind.
388 let source = err.source().expect("ManifestIo carries a source");
389 let downcast = source.downcast_ref::<io::Error>().expect("source downcasts to io::Error");
390 assert_eq!(downcast.kind(), io::ErrorKind::Other);
391 }
392
393 #[test]
394 fn test_is_not_a_directory_helper_matches_platform_code() {
395 // Platform-specific raw_os_error() codes for ENOTDIR. The helper
396 // is the MSRV-1.79-safe substitute for io::ErrorKind::NotADirectory
397 // (stabilised only in 1.83).
398 #[cfg(unix)]
399 {
400 let e = io::Error::from_raw_os_error(20);
401 assert!(is_not_a_directory(&e), "POSIX ENOTDIR (20) must be detected");
402 }
403 #[cfg(windows)]
404 {
405 let e = io::Error::from_raw_os_error(267);
406 assert!(is_not_a_directory(&e), "Windows ERROR_DIRECTORY (267) must be detected");
407 }
408 }
409
410 #[test]
411 fn test_is_not_a_directory_helper_rejects_unrelated_codes() {
412 // PermissionDenied and NotFound must not be misclassified as
413 // ENOTDIR — the loader routes them to other variants.
414 let perm = io::Error::from(io::ErrorKind::PermissionDenied);
415 assert!(!is_not_a_directory(&perm));
416 let nf = io::Error::from(io::ErrorKind::NotFound);
417 assert!(!is_not_a_directory(&nf));
418 // Synthetic io::Error without any raw_os_error must be rejected.
419 let other = io::Error::other("no os code");
420 assert!(!is_not_a_directory(&other));
421 }
422}