omne_cli/error.rs
1//! Top-level CLI error type.
2//!
3//! `CliError` is a thin composition enum: it holds a small number of
4//! cross-cutting variants (`NotAVolume`, `VolumeAlreadyExists`,
5//! `ValidationFailed`) directly, plus `#[from]` conversions for the
6//! per-module error types that are wired in Phase 1: `manifest::Error`
7//! and `distro::Error`. Additional module error types (`github::Error`,
8//! `tarball::Error`, `python::Error`) will be composed in as those
9//! modules come online in subsequent units. The `volume` module returns
10//! `Option` from `find_omne_root` rather than `Result`, so it has no
11//! `Error` type yet — one will be added in Unit 9 if volume operations
12//! start returning `Result`.
13//!
14//! The variants here are the union of cross-cutting errors the command
15//! layer surfaces to `main.rs`; module-owned errors live in their own
16//! modules and are wrapped at this layer. Keeping the top-level enum thin
17//! avoids the god-type coupling pattern where one enum depends on every
18//! crate (chrono, ureq, PathBuf, ...) through its error imports.
19
20use std::path::PathBuf;
21
22use thiserror::Error;
23
24/// Error variant categories map to process exit codes at `main.rs`:
25/// logical errors (cross-cutting variants and module-wrapper variants)
26/// exit with code 1; clap argument-parse errors exit with code 2 via
27/// clap's own machinery. Successful runs exit with code 0.
28///
29/// The variants below are reserved for the command handlers that land in
30/// Units 8a, 9, and 10. The earlier units only wire up the dispatcher,
31/// stub handlers, and leaf modules, so none of the variants are
32/// constructed yet — the `#[allow(dead_code)]` is scoped to the enum and
33/// will be removed automatically as each variant picks up a first call
34/// site.
35#[allow(dead_code)]
36#[derive(Debug, Error)]
37pub enum CliError {
38 /// Walk-up from the current directory did not find a `.omne/` root.
39 /// Produced by `upgrade` and `validate` when invoked outside a volume.
40 #[error(".omne/ not found — not an omne volume")]
41 NotAVolume,
42
43 /// Produced by `init` when the current directory already contains a
44 /// `.omne/` directory. The precheck deliberately does not walk up,
45 /// per R13: `init` creates in the current directory only.
46 #[error(".omne/ already exists at {path}")]
47 VolumeAlreadyExists { path: PathBuf },
48
49 /// Produced by `validate` after collecting one or more issues.
50 /// The issue strings are carried in the variant so callers and
51 /// tests can introspect them structurally; `main.rs` prints the
52 /// header and each issue line.
53 #[error("validation failed with {} issue(s)", issues.len())]
54 ValidationFailed { issues: Vec<String> },
55
56 /// Wraps `distro::Error` (e.g. `UnsupportedSpec` for `file://` or
57 /// non-github.com hosts). Produced by `init` at argument parse time.
58 #[error(transparent)]
59 Distro(#[from] crate::distro::Error),
60
61 /// Wraps `manifest::Error` (missing frontmatter, missing required
62 /// field, YAML parse failure). Produced by `upgrade` and `validate`
63 /// when reading `.omne/MANIFEST.md`.
64 #[error(transparent)]
65 Manifest(#[from] crate::manifest::Error),
66
67 /// Wraps `github::Error` (rate limit, auth failure, no release found,
68 /// no tarball asset, sanitized HTTP error). Produced by `init` and
69 /// `upgrade` when fetching releases.
70 #[error(transparent)]
71 Github(#[from] crate::github::Error),
72
73 /// Wraps `tarball::Error` (path traversal, unsupported entry type,
74 /// pre-planted symlink, I/O). Produced during tarball extraction in
75 /// `init` and `upgrade`.
76 #[error(transparent)]
77 Tarball(#[from] crate::tarball::Error),
78
79 /// The extracted tarball did not contain the expected top-level
80 /// directory (e.g. `core/` for kernel, `image/` for distro).
81 /// Indicates the upstream release workflow changed its tarball layout.
82 #[error("tarball layout mismatch: expected '{expected}/' but found {found:?}")]
83 TarballLayoutMismatch {
84 expected: String,
85 found: Vec<String>,
86 },
87
88 /// Wraps `python::Error` (gate runner failure, timeout, interpreter
89 /// invocation failure). Produced by `validate` when running the gate
90 /// runner script.
91 #[error(transparent)]
92 Python(#[from] crate::python::Error),
93
94 /// The target directory for upgrade contains or is reached via a
95 /// symlink. Refusing to `remove_dir_all` to prevent symlink traversal.
96 #[error("unsafe target: {path} contains or is a symlink — refusing to remove")]
97 UnsafeTarget { path: PathBuf },
98
99 /// Wraps `std::io::Error` for filesystem and cwd operations.
100 #[error("{0}")]
101 Io(String),
102}
103
104impl From<std::io::Error> for CliError {
105 fn from(err: std::io::Error) -> Self {
106 CliError::Io(err.to_string())
107 }
108}
109
110impl CliError {
111 /// Exit code mapping for the process. All logical errors map to 1;
112 /// clap parser failures exit with 2 via clap itself. Matches the
113 /// Python CLI's exit code contract.
114 pub fn exit_code(&self) -> i32 {
115 match self {
116 CliError::NotAVolume
117 | CliError::VolumeAlreadyExists { .. }
118 | CliError::ValidationFailed { .. }
119 | CliError::Distro(_)
120 | CliError::Manifest(_)
121 | CliError::Github(_)
122 | CliError::Tarball(_)
123 | CliError::TarballLayoutMismatch { .. }
124 | CliError::Python(_)
125 | CliError::UnsafeTarget { .. }
126 | CliError::Io(_) => 1,
127 }
128 }
129}