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/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    /// 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    /// Wraps `std::io::Error` for filesystem and cwd operations.
115    #[error("{0}")]
116    Io(String),
117}
118
119impl From<std::io::Error> for CliError {
120    fn from(err: std::io::Error) -> Self {
121        CliError::Io(err.to_string())
122    }
123}
124
125impl CliError {
126    /// Exit code mapping for the process. All logical errors map to 1;
127    /// clap parser failures exit with 2 via clap itself. Matches the
128    /// Python CLI's exit code contract.
129    pub fn exit_code(&self) -> i32 {
130        match self {
131            CliError::NotAVolume
132            | CliError::VolumeAlreadyExists { .. }
133            | CliError::ValidationFailed { .. }
134            | CliError::Distro(_)
135            | CliError::Manifest(_)
136            | CliError::Github(_)
137            | CliError::Tarball(_)
138            | CliError::TarballLayoutMismatch { .. }
139            | CliError::Python(_)
140            | CliError::UnsafeTarget { .. }
141            | CliError::Io(_) => 1,
142            CliError::SymlinkPrivilegeRequired => 7,
143        }
144    }
145}