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}