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/omne.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, `dist/` 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 /// Windows-only: creating a directory symlink failed with
100 /// ERROR_PRIVILEGE_NOT_HELD (1314). Tells the user the two ways to
101 /// unlock symlink creation so they can re-run.
102 #[error(
103 "symlink privilege required on Windows\n\
104 \n\
105 Creating .claude/skills/ requires directory symlinks. Windows\n\
106 blocks this for non-elevated processes unless Developer Mode\n\
107 is enabled. Re-run `omne init` one of these ways:\n\
108 \n\
109 • Elevated PowerShell: right-click → Run as Administrator\n\
110 • Enable Developer Mode: Settings → Privacy & Security → For developers"
111 )]
112 SymlinkPrivilegeRequired,
113
114 /// Advisory lock on the ULID allocator file was not released within
115 /// the acquire budget (default 5s). Produced by `run` when composing
116 /// the per-run identifier via `crate::ulid::allocate`.
117 #[error("ULID allocator lock {path} timed out")]
118 UlidLockTimeout { path: PathBuf },
119
120 /// Wraps `ulid::Error` for ULID allocator failures that are not
121 /// lock timeouts (I/O errors on the lock file, malformed persisted
122 /// state). Separate variant from `UlidLockTimeout` so callers can
123 /// discriminate lock contention from data corruption.
124 #[error(transparent)]
125 Ulid(crate::ulid::Error),
126
127 /// Wraps `worktree::Error` for `git worktree add / remove / list`
128 /// failures surfaced by the runner preflight, executor teardown,
129 /// and status enumeration.
130 #[error(transparent)]
131 Worktree(#[from] crate::worktree::Error),
132
133 /// Wraps `event_log::Error` for per-run `events.jsonl` open /
134 /// append / read / enumerate failures, including the advisory
135 /// lock timeout surfaced by `EventLog::append`.
136 #[error(transparent)]
137 EventLog(#[from] crate::event_log::Error),
138
139 /// Wraps `claude_proc::Error` for `claude -p` subprocess client
140 /// failures (HostMissing, Spawn, Timeout, ExitedNonZero, stream
141 /// I/O, capture-file I/O). Surfaced by Unit 11's executor when a
142 /// prompt node's subprocess misbehaves.
143 #[error(transparent)]
144 ClaudeProc(#[from] crate::claude_proc::Error),
145
146 /// Wraps `executor::DispatchError` for infrastructure failures
147 /// surfaced while dispatching a single node (event-log I/O, spawn
148 /// / wait errors, malformed pipe data slipping past the
149 /// validator). Distinct from `NodeOutcome::Failed`, which is a
150 /// *recorded* node failure — a `Dispatch` error means the
151 /// executor couldn't even decide the outcome.
152 #[error(transparent)]
153 Dispatch(#[from] crate::executor::DispatchError),
154
155 /// Wraps `std::io::Error` for filesystem and cwd operations.
156 #[error("{0}")]
157 Io(String),
158
159 /// `omne status <run_id>` for a run that does not exist.
160 #[error("run not found: {0}")]
161 RunNotFound(String),
162
163 /// A run completed with at least one `Failed` or `Blocked` node.
164 /// The event log has already recorded `pipe.aborted`; this error
165 /// is the CLI-surface representation so `main.rs` exits non-zero
166 /// without re-deriving the state.
167 #[error("pipe {run_id} aborted: {reason}")]
168 PipeAborted { run_id: String, reason: String },
169}
170
171// Manual From — intentionally not `#[from]` because LockTimeout routes
172// to a dedicated variant for user-facing messaging.
173impl From<crate::ulid::Error> for CliError {
174 fn from(err: crate::ulid::Error) -> Self {
175 match err {
176 crate::ulid::Error::LockTimeout { path } => CliError::UlidLockTimeout { path },
177 other => CliError::Ulid(other),
178 }
179 }
180}
181
182impl From<std::io::Error> for CliError {
183 fn from(err: std::io::Error) -> Self {
184 CliError::Io(err.to_string())
185 }
186}
187
188impl CliError {
189 /// Exit code mapping for the process. All logical errors map to 1;
190 /// clap parser failures exit with 2 via clap itself. Matches the
191 /// Python CLI's exit code contract.
192 pub fn exit_code(&self) -> i32 {
193 match self {
194 CliError::NotAVolume
195 | CliError::VolumeAlreadyExists { .. }
196 | CliError::ValidationFailed { .. }
197 | CliError::Distro(_)
198 | CliError::Manifest(_)
199 | CliError::Github(_)
200 | CliError::Tarball(_)
201 | CliError::TarballLayoutMismatch { .. }
202 | CliError::Python(_)
203 | CliError::UnsafeTarget { .. }
204 | CliError::UlidLockTimeout { .. }
205 | CliError::Ulid(_)
206 | CliError::Worktree(_)
207 | CliError::EventLog(_)
208 | CliError::ClaudeProc(_)
209 | CliError::Dispatch(_)
210 | CliError::Io(_)
211 | CliError::RunNotFound(_)
212 | CliError::PipeAborted { .. } => 1,
213 CliError::SymlinkPrivilegeRequired => 7,
214 }
215 }
216}