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::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    /// v1.3.1 (B4) — a child's resolved on-disk destination has no
203    /// usable UTF-8 `file_name` component. Surfaces during dry-run
204    /// record construction when `dest.file_name()` returns `None` (e.g.
205    /// the path ends in `..` or is a filesystem root) or the component
206    /// is not UTF-8. Recording the child with an empty `id` would
207    /// silently corrupt the dry-run plan, so the walker pushes this
208    /// error into `SyncMetaReport.errors` instead and continues.
209    #[error("invalid destination path `{path}`: {reason}")]
210    InvalidDestination {
211        /// On-disk destination path that lacked a usable file_name.
212        path: PathBuf,
213        /// One-line explanation of which rule the path violated.
214        reason: String,
215    },
216}
217
218/// Discriminator for [`TreeError::DirtyTreeRefusal`]. Each kind has its
219/// own operator-facing Display string; consult the variant docs for the
220/// exact wording.
221#[non_exhaustive]
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum DirtyTreeRefusalKind {
224    /// Working tree has tracked-modified or untracked-non-ignored
225    /// content. Default refusal — operator must commit, stash, or
226    /// remove the changes manually.
227    DirtyTree,
228    /// Working tree is clean of tracked changes but holds ignored
229    /// files. Override available via `--force-prune-with-ignored`.
230    DirtyTreeWithIgnored,
231    /// `.git/` carries a rebase / merge / cherry-pick state directory.
232    /// Operator must finish or abort the operation before pruning.
233    GitInProgress,
234    /// The destination is itself a meta-repo (sub-pack-tree) and the
235    /// recursive consent walk found at least one of its descendants is
236    /// dirty. Operator must clean the descendant first.
237    SubMetaWithDirtyChildren,
238}
239
240/// Format a [`TreeError::CycleDetected`] message. Renders the chain
241/// arrow-joined for operator legibility (`a → b → c → a`) instead of
242/// the debug-vec rendering. Defensive on empty chains so a malformed
243/// caller cannot panic the error path.
244fn display_cycle_detected(chain: &[String]) -> String {
245    if chain.is_empty() {
246        return "cycle detected in pack graph (empty chain)".to_string();
247    }
248    format!("cycle detected in pack graph: {}", chain.join(" → "))
249}
250
251/// Format a [`TreeError::DirtyTreeRefusal`] message. Extracted so the
252/// `#[error]` attribute can reference a function call instead of a
253/// trailing match expression.
254fn display_dirty_tree_refusal(path: &std::path::Path, kind: &DirtyTreeRefusalKind) -> String {
255    match kind {
256        DirtyTreeRefusalKind::DirtyTree => {
257            format!("refusing to prune {}: working tree dirty", path.display())
258        }
259        DirtyTreeRefusalKind::DirtyTreeWithIgnored => format!(
260            "refusing to prune {}: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
261            path.display()
262        ),
263        DirtyTreeRefusalKind::GitInProgress => format!(
264            "refusing to prune {}: in-progress git operation (rebase/merge/cherry-pick)",
265            path.display()
266        ),
267        DirtyTreeRefusalKind::SubMetaWithDirtyChildren => format!(
268            "refusing to prune {}: nested meta-repo has dirty children",
269            path.display()
270        ),
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    //! v1.2.0 Stage 1.k — error-variant Display assertions.
277    //!
278    //! Pure construction + `to_string()` checks. Variants are dormant
279    //! until later stages (1.c validator, 1.e walker Phase 1, 1.f Phase 2
280    //! prune-safety, 1.h migrator) wire them into producers.
281    use super::*;
282
283    #[test]
284    fn test_tree_error_legacy_lockfile_detected_display() {
285        let err = TreeError::LegacyLockfileDetected {
286            path: PathBuf::from("/repos/code/.grex/lock.yaml"),
287        };
288        assert_eq!(
289            err.to_string(),
290            "v1.1.1 lockfile detected at /repos/code/.grex/lock.yaml, run grex migrate-lockfile",
291        );
292    }
293
294    #[test]
295    fn test_tree_error_untracked_git_repos_display_single() {
296        let err = TreeError::UntrackedGitRepos { paths: vec![PathBuf::from("alpha")] };
297        assert_eq!(
298            err.to_string(),
299            "untracked git repositories found: alpha; either register them as packs or remove from manifest",
300        );
301    }
302
303    #[test]
304    fn test_tree_error_untracked_git_repos_display_multiple() {
305        let err = TreeError::UntrackedGitRepos {
306            paths: vec![PathBuf::from("alpha"), PathBuf::from("beta"), PathBuf::from("gamma")],
307        };
308        assert_eq!(
309            err.to_string(),
310            "untracked git repositories found: alpha, beta, gamma; either register them as packs or remove from manifest",
311        );
312    }
313
314    #[test]
315    fn test_tree_error_dirty_tree_refusal_display_dirty_tree() {
316        let err = TreeError::DirtyTreeRefusal {
317            path: PathBuf::from("alpha"),
318            kind: DirtyTreeRefusalKind::DirtyTree,
319        };
320        assert_eq!(err.to_string(), "refusing to prune alpha: working tree dirty");
321    }
322
323    #[test]
324    fn test_tree_error_dirty_tree_refusal_display_dirty_with_ignored() {
325        let err = TreeError::DirtyTreeRefusal {
326            path: PathBuf::from("alpha"),
327            kind: DirtyTreeRefusalKind::DirtyTreeWithIgnored,
328        };
329        assert_eq!(
330            err.to_string(),
331            "refusing to prune alpha: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
332        );
333    }
334
335    #[test]
336    fn test_tree_error_dirty_tree_refusal_display_git_in_progress() {
337        let err = TreeError::DirtyTreeRefusal {
338            path: PathBuf::from("alpha"),
339            kind: DirtyTreeRefusalKind::GitInProgress,
340        };
341        assert_eq!(
342            err.to_string(),
343            "refusing to prune alpha: in-progress git operation (rebase/merge/cherry-pick)",
344        );
345    }
346
347    #[test]
348    fn test_tree_error_dirty_tree_refusal_display_sub_meta_dirty() {
349        let err = TreeError::DirtyTreeRefusal {
350            path: PathBuf::from("alpha"),
351            kind: DirtyTreeRefusalKind::SubMetaWithDirtyChildren,
352        };
353        assert_eq!(err.to_string(), "refusing to prune alpha: nested meta-repo has dirty children",);
354    }
355
356    #[test]
357    fn test_tree_error_manifest_path_escape_display() {
358        let err = TreeError::ManifestPathEscape {
359            path: "../escape".into(),
360            reason: "child path escapes parent root".into(),
361        };
362        assert_eq!(
363            err.to_string(),
364            "manifest path '../escape' escapes parent boundary: child path escapes parent root",
365        );
366    }
367
368    #[test]
369    fn test_tree_error_manifest_permission_denied_display() {
370        let err = TreeError::ManifestPermissionDenied {
371            path: PathBuf::from("/repos/code/.grex/pack.yaml"),
372        };
373        assert_eq!(
374            err.to_string(),
375            "permission denied reading pack manifest at `/repos/code/.grex/pack.yaml`",
376        );
377    }
378
379    #[test]
380    fn test_tree_error_manifest_not_a_dir_display() {
381        let err = TreeError::ManifestNotADir { path: PathBuf::from("/repos/code/.grex/pack.yaml") };
382        assert_eq!(
383            err.to_string(),
384            "manifest path `/repos/code/.grex/pack.yaml` is not a directory (or has wrong type)",
385        );
386    }
387
388    #[test]
389    fn test_tree_error_manifest_io_display_and_source() {
390        use std::error::Error as _;
391
392        let underlying = io::Error::other("disk on fire");
393        let err = TreeError::ManifestIo {
394            path: PathBuf::from("/repos/code/.grex/pack.yaml"),
395            source: underlying,
396        };
397        assert_eq!(
398            err.to_string(),
399            "I/O error reading pack manifest at `/repos/code/.grex/pack.yaml`: disk on fire",
400        );
401        // The `#[source]` attribute MUST preserve the underlying io::Error
402        // so consumers can walk the chain and recover the original kind.
403        let source = err.source().expect("ManifestIo carries a source");
404        let downcast = source.downcast_ref::<io::Error>().expect("source downcasts to io::Error");
405        assert_eq!(downcast.kind(), io::ErrorKind::Other);
406    }
407
408    #[test]
409    fn test_is_not_a_directory_helper_matches_platform_code() {
410        // Platform-specific raw_os_error() codes for ENOTDIR. The helper
411        // is the MSRV-1.79-safe substitute for io::ErrorKind::NotADirectory
412        // (stabilised only in 1.83).
413        #[cfg(unix)]
414        {
415            let e = io::Error::from_raw_os_error(20);
416            assert!(is_not_a_directory(&e), "POSIX ENOTDIR (20) must be detected");
417        }
418        #[cfg(windows)]
419        {
420            let e = io::Error::from_raw_os_error(267);
421            assert!(is_not_a_directory(&e), "Windows ERROR_DIRECTORY (267) must be detected");
422        }
423    }
424
425    #[test]
426    fn test_is_not_a_directory_helper_rejects_unrelated_codes() {
427        // PermissionDenied and NotFound must not be misclassified as
428        // ENOTDIR — the loader routes them to other variants.
429        let perm = io::Error::from(io::ErrorKind::PermissionDenied);
430        assert!(!is_not_a_directory(&perm));
431        let nf = io::Error::from(io::ErrorKind::NotFound);
432        assert!(!is_not_a_directory(&nf));
433        // Synthetic io::Error without any raw_os_error must be rejected.
434        let other = io::Error::other("no os code");
435        assert!(!is_not_a_directory(&other));
436    }
437}