Skip to main content

hh_core/
error.rs

1//! Error types for hh-core.
2//!
3//! Per CLAUDE.md / NFR-5, library errors use [`thiserror`]. All user-facing
4//! errors are actionable: they describe what failed, why, and a suggested fix
5//! via the [`enum@Error`] variants' `Display` impl.
6
7use std::path::PathBuf;
8use thiserror::Error;
9
10/// Top-level hh-core error.
11///
12/// `#[non_exhaustive]`: this and the other error enums below are expected to
13/// gain variants over time (this PR added [`StorageError::StillRecording`]);
14/// non-exhaustive keeps that additive under `cargo-semver-checks
15/// --release-type minor` instead of registering as a break — a downstream
16/// `match` must already carry a wildcard arm, matching CLAUDE.md's v1.0.0
17/// addendum ("additive changes ... not breaking").
18#[derive(Debug, Error)]
19#[non_exhaustive]
20pub enum Error {
21    /// SQLite storage failure (IO, constraint, or migration).
22    #[error("storage error: {0}\n  hint: check that the data directory is writable and not on a full disk")]
23    Storage(#[from] StorageError),
24
25    /// Blob store failure (IO, compression, or hash mismatch).
26    #[error("blob store error: {0}")]
27    Blob(#[from] BlobError),
28
29    /// Configuration parsing failure. Per SRS §4.2 config never fails to start
30    /// the program on unknown keys (those warn); this is only raised for a
31    /// malformed TOML file or a value that cannot be interpreted.
32    #[error("config error: {0}")]
33    Config(#[from] ConfigError),
34
35    /// Portable session bundle (`hh export --bundle` / `hh import`) build or
36    /// parse failure.
37    #[error("bundle error: {0}")]
38    Bundle(#[from] BundleError),
39}
40
41/// Storage-layer error.
42#[derive(Debug, Error)]
43#[non_exhaustive]
44pub enum StorageError {
45    /// SQLite returned an error.
46    #[error("sqlite: {0}")]
47    Sqlite(#[from] rusqlite::Error),
48
49    /// The database file could not be opened or created.
50    #[error("cannot open database at {path:?}: {source}")]
51    Open {
52        /// Path that failed.
53        path: PathBuf,
54        /// Underlying IO error.
55        source: std::io::Error,
56    },
57
58    /// A migration could not be applied.
59    #[error("migration {version} failed: {source}")]
60    Migration {
61        /// Migration version that failed.
62        version: i64,
63        /// Underlying SQLite error.
64        source: rusqlite::Error,
65    },
66
67    /// A session id did not resolve to exactly one session.
68    #[error("session not resolvable: {0}")]
69    Resolve(#[from] ResolveError),
70
71    /// A session id was not found.
72    #[error("session {0} not found\n  hint: run `hh list` to see recorded sessions")]
73    NotFound(String),
74
75    /// A blob referenced by an event does not exist on disk.
76    #[error("blob {0} referenced by event but missing from disk")]
77    MissingBlob(String),
78
79    /// `redact_session` was asked to rewrite a session that is still being
80    /// recorded (a live writer could re-insert plaintext mid-rewrite).
81    #[error("session {0} is still recording\n  hint: wait for the recording to finish (`hh list` shows status), then re-run `hh redact`")]
82    StillRecording(String),
83
84    /// The single-writer task is no longer reachable (closed or panicked).
85    #[error("the writer task is closed (it may have crashed; check stderr for prior errors)")]
86    WriterClosed,
87
88    /// The single-writer task panicked while handling a request.
89    #[error("the writer task panicked")]
90    WriterPanic,
91}
92
93/// Failure to resolve a session id to exactly one session (FR-3.1).
94#[derive(Debug, Error)]
95#[non_exhaustive]
96pub enum ResolveError {
97    /// The id prefix matched more than one session.
98    #[error("ambiguous session id `{prefix}` matches {count} sessions:\n{candidates}\n  hint: use a longer prefix or the full id")]
99    Ambiguous {
100        /// The prefix the user supplied.
101        prefix: String,
102        /// Number of matching sessions.
103        count: usize,
104        /// One line per candidate (short id + started_at), already formatted.
105        candidates: String,
106    },
107    /// `last` was requested but no sessions exist.
108    #[error("no sessions recorded yet\n  hint: run `hh run -- <command>` to record one")]
109    Empty,
110}
111
112/// Blob-store error.
113#[derive(Debug, Error)]
114#[non_exhaustive]
115pub enum BlobError {
116    /// IO failure reading or writing a blob.
117    #[error("blob io error at {path:?}: {source}")]
118    Io {
119        /// Blob path.
120        path: PathBuf,
121        /// Underlying error.
122        source: std::io::Error,
123    },
124
125    /// zstd compression/decompression failure.
126    #[error("zstd: {0}")]
127    Zstd(String),
128
129    /// A blob hash did not match its content (corruption).
130    #[error("blob hash mismatch: expected {expected}, got {actual}")]
131    HashMismatch {
132        /// Expected BLAKE3 hex.
133        expected: String,
134        /// Actual BLAKE3 hex.
135        actual: String,
136    },
137}
138
139/// Configuration error.
140#[derive(Debug, Error)]
141#[non_exhaustive]
142pub enum ConfigError {
143    /// The TOML file could not be parsed.
144    #[error("cannot parse config file {path:?}: {source}")]
145    Parse {
146        /// Path that failed.
147        path: PathBuf,
148        /// Underlying TOML error.
149        source: toml::de::Error,
150    },
151
152    /// A config value could not be interpreted (e.g. a bad byte size).
153    #[error("invalid config value: {0}")]
154    Value(String),
155
156    /// The config file could not be read.
157    #[error("cannot read config file {path:?}: {source}")]
158    Read {
159        /// Path that failed.
160        path: PathBuf,
161        /// Underlying IO error.
162        source: std::io::Error,
163    },
164}
165
166/// Portable session bundle (`hh-core::bundle`) build/parse error. Every
167/// variant here is reachable from untrusted input (`hh import file.hh`) and
168/// must carry enough detail to act on — "corrupt bundle" alone is not
169/// actionable (v1.0.0 addendum: parsers over untrusted input must never
170/// panic and must report precisely what failed).
171#[derive(Debug, Error)]
172#[non_exhaustive]
173pub enum BundleError {
174    /// The bundle's zstd stream could not be decompressed.
175    #[error("could not decompress bundle: {0}\n  hint: the file may be truncated or not an `hh` bundle at all")]
176    Zstd(String),
177
178    /// The decompressed bundle is not a valid tar archive, or contains an
179    /// entry outside the allow-list (unexpected path, symlink, hardlink,
180    /// absolute path, or `..` component).
181    #[error("malformed bundle archive: {0}")]
182    Tar(String),
183
184    /// `manifest.json` is missing, unreadable, or not valid JSON.
185    #[error("could not read bundle manifest: {0}")]
186    Manifest(String),
187
188    /// The bundle's `format_version` is newer than this build of `hh`
189    /// understands.
190    #[error("bundle format version {found} is newer than the highest version this build of hh supports ({max})\n  hint: upgrade hh, then retry `hh import`")]
191    UnsupportedVersion {
192        /// The bundle's declared format version.
193        found: u32,
194        /// The highest format version this build supports.
195        max: u32,
196    },
197
198    /// `events.ndjson` is missing or one of its lines is not valid JSON.
199    #[error("could not read bundle events: {0}")]
200    Events(String),
201
202    /// A blob's actual content hash did not match its expected hash (from
203    /// its tar path or from an event/file_change reference).
204    #[error("bundle blob hash mismatch: expected {expected}, got {actual}\n  hint: the bundle is corrupt or was tampered with — re-export it")]
205    HashMismatch {
206        /// Expected BLAKE3 hex.
207        expected: String,
208        /// Actual BLAKE3 hex.
209        actual: String,
210    },
211
212    /// An event or file_change references a blob hash that the bundle does
213    /// not carry.
214    #[error("bundle is missing blob {0}, referenced by an event\n  hint: the bundle is corrupt or incomplete — re-export it")]
215    MissingBlob(String),
216
217    /// The bundle's `events.ndjson` digest did not match `manifest.integrity`.
218    #[error("bundle events.ndjson does not match its recorded integrity hash\n  hint: the bundle is corrupt or was tampered with — re-export it")]
219    IntegrityMismatch,
220}
221
222/// Result alias for hh-core.
223pub type Result<T> = std::result::Result<T, Error>;
224
225// Manual `From` impls that route leaf errors through the appropriate variant.
226// thiserror's `#[from]` only generates one hop (e.g. `rusqlite::Error` →
227// `StorageError`); it does not chain a second hop to `Error`. Without these,
228// `?` on a `rusqlite::Error` inside a function returning `Result<_, Error>`
229// would not compile. Routing through `Storage` keeps the layered model intact.
230
231impl From<rusqlite::Error> for Error {
232    fn from(e: rusqlite::Error) -> Self {
233        Self::Storage(StorageError::from(e))
234    }
235}
236
237impl From<ResolveError> for Error {
238    fn from(e: ResolveError) -> Self {
239        Self::Storage(StorageError::from(e))
240    }
241}