Skip to main content

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}