Skip to main content

backhopper_core/
errors.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5use std::io;
6use std::path::PathBuf;
7
8use thiserror::Error;
9
10use crate::config::HostConfigError;
11
12pub type Result<T, E = Error> = std::result::Result<T, E>;
13
14#[derive(Debug, Error)]
15pub enum Error {
16    #[error("snapshot error: {0}")]
17    Snapshot(#[from] SnapshotError),
18
19    #[error("store error: {0}")]
20    Store(#[from] StoreError),
21
22    #[error("config error: {0}")]
23    Config(#[from] ConfigError),
24
25    #[error("patch error: {0}")]
26    Patch(#[from] PatchError),
27
28    #[error("name error: {0}")]
29    Name(#[from] NameError),
30
31    #[error("i/o error: {0}")]
32    Io(#[from] io::Error),
33}
34
35#[derive(Debug, Error)]
36pub enum NameError {
37    #[error("empty value where {kind} was expected")]
38    Empty { kind: &'static str },
39
40    #[error("{kind} too long: {len} > {max}")]
41    TooLong {
42        kind: &'static str,
43        len: usize,
44        max: usize,
45    },
46
47    #[error("invalid character {ch:?} in {kind}: {value}")]
48    InvalidCharacter {
49        kind: &'static str,
50        ch: char,
51        value: String,
52    },
53
54    #[error("{kind} {value:?} does not match required pattern {pattern}")]
55    PatternMismatch {
56        kind: &'static str,
57        value: String,
58        pattern: &'static str,
59    },
60
61    #[error("invalid commit sha {value:?}: must be exactly 40 lowercase hex characters")]
62    InvalidCommitSha { value: String },
63
64    #[error("invalid verdict fingerprint {value:?}: must be exactly 32 lowercase hex characters")]
65    InvalidVerdictFingerprint { value: String },
66
67    #[error("commit sha prefix is empty")]
68    EmptyCommitShaPrefix,
69
70    #[error("invalid commit sha prefix {value:?}: too short ({len} chars; needs 7 to 40)")]
71    CommitShaPrefixTooShort { value: String, len: usize },
72
73    #[error("invalid commit sha prefix {value:?}: too long ({len} chars; needs 7 to 40)")]
74    CommitShaPrefixTooLong { value: String, len: usize },
75
76    #[error("invalid commit sha prefix {value:?}: non-hex character {ch:?} at position {position}")]
77    CommitShaPrefixNonHex {
78        value: String,
79        ch: char,
80        position: usize,
81    },
82
83    #[error("invalid arity: {raw:?} could not be parsed as an integer in 0..=255")]
84    InvalidArityParse { raw: String },
85
86    #[error("invalid arity {value}: must be 0..=255")]
87    InvalidArity { value: i64 },
88
89    #[error("invalid mfa {value:?}: expected 'mod:fun/arity'")]
90    InvalidMfa { value: String },
91
92    #[error("invalid {kind} {value:?}: {reason}")]
93    Invalid {
94        kind: &'static str,
95        value: String,
96        reason: &'static str,
97    },
98}
99
100#[derive(Debug, Error)]
101pub enum SnapshotError {
102    #[error("malformed header at line {line}: {detail}")]
103    MalformedHeader { line: usize, detail: String },
104
105    #[error("unknown header key {key:?} at line {line}")]
106    UnknownHeaderKey { line: usize, key: String },
107
108    #[error("missing required header key {key:?}")]
109    MissingHeaderKey { key: &'static str },
110
111    #[error("unknown format-version {found}: expected {expected}")]
112    UnknownFormatVersion { found: String, expected: u32 },
113
114    #[error("unexpected token at line {line}: {detail}")]
115    UnexpectedToken { line: usize, detail: String },
116
117    #[error("non-canonical input at line {line}: {detail}")]
118    NotCanonical { line: usize, detail: String },
119
120    /// The JSON reader's twin of `NotCanonical`: a deserialized value has
121    /// no source line to point at.
122    #[error("non-canonical snapshot: {detail}")]
123    NotCanonicalValue { detail: String },
124
125    #[error("snapshot cannot be written: {detail}")]
126    Unwritable { detail: String },
127
128    #[error("invalid utf-8 at byte offset {offset}")]
129    InvalidUtf8 { offset: usize },
130
131    #[error("input exceeds size limit ({size} > {limit} bytes)")]
132    SizeLimit { size: usize, limit: usize },
133
134    #[error("name error: {0}")]
135    Name(#[from] NameError),
136
137    #[error("i/o error: {0}")]
138    Io(#[from] io::Error),
139}
140
141#[derive(Debug, Error)]
142pub enum StoreError {
143    #[error("snapshot store root does not exist: {0}")]
144    RootMissing(PathBuf),
145
146    #[error("snapshot not found: project {project}, tag {tag}")]
147    SnapshotNotFound { project: String, tag: String },
148
149    #[error("snapshot already exists: {0}")]
150    AlreadyExists(PathBuf),
151
152    #[error("path escape: {0:?}")]
153    PathEscape(PathBuf),
154
155    #[error("snapshot error: {0}")]
156    Snapshot(#[from] SnapshotError),
157
158    #[error("i/o error: {0}")]
159    Io(#[from] io::Error),
160}
161
162/// A `vocabulary!` enum's `FromStr` refusal: the noun names which
163/// vocabulary, `rejected` is the text `FromStr` was given, and `accepted`
164/// lists every label the vocabulary admits.
165#[derive(Debug, Error)]
166#[error("unknown {noun} {rejected:?}; expected one of {accepted}")]
167pub struct UnknownLabel {
168    noun: &'static str,
169    rejected: String,
170    accepted: String,
171}
172
173impl UnknownLabel {
174    pub fn new(
175        noun: &'static str,
176        rejected: &str,
177        labels: impl IntoIterator<Item = &'static str>,
178    ) -> Self {
179        Self {
180            noun,
181            rejected: rejected.to_owned(),
182            accepted: labels.into_iter().collect::<Vec<_>>().join(", "),
183        }
184    }
185}
186
187#[derive(Debug, Error)]
188pub enum ConfigError {
189    #[error("config file not found: {0}")]
190    NotFound(PathBuf),
191
192    #[error("malformed toml: {0}")]
193    Toml(#[from] toml::de::Error),
194
195    #[error("unknown config-version {0}")]
196    UnknownConfigVersion(u32),
197
198    #[error("project {0:?} referenced but not configured")]
199    UnknownProject(String),
200
201    #[error("series {0:?} referenced but not configured")]
202    UnknownSeries(String),
203
204    #[error("host {0:?} referenced but not configured")]
205    UnknownHost(String),
206
207    #[error(transparent)]
208    Host(#[from] HostConfigError),
209
210    #[error("series {series:?} pins unknown project {project:?}")]
211    SeriesPinsUnknownProject { series: String, project: String },
212
213    #[error("pin pattern {pattern:?} for project {project:?} matched no snapshot tag")]
214    PinPatternNoMatch { project: String, pattern: String },
215
216    #[error("snapshot for pin {project} @ {tag} is not on disk at {expected_path}")]
217    PinSnapshotMissing {
218        project: String,
219        tag: String,
220        expected_path: String,
221    },
222
223    #[error(
224        "pin pattern {pattern:?} for project {project:?} could not be resolved: \
225         snapshot store error: {detail}"
226    )]
227    PinPatternStore {
228        project: String,
229        pattern: String,
230        detail: String,
231    },
232
233    #[error("pin `select` must be \"latest\" or \"oldest\", got {0:?}")]
234    PinUnknownSelect(String),
235
236    #[error("{0}")]
237    UnknownLabel(#[from] UnknownLabel),
238
239    #[error("project {0:?} has kind=\"self\" but also specifies git_url; remove one")]
240    SelfProjectHasGitUrl(String),
241
242    #[error("project {0:?} requires git_url (external kind, the default)")]
243    ExternalProjectMissingGitUrl(String),
244
245    #[error("config defines more than one project with kind=\"self\": {projects:?}")]
246    MultipleSelfProjects { projects: Vec<String> },
247
248    #[error("self-project pin {project} @ {git_ref} requires `--repo-dir-path` for resolution")]
249    SelfPinNeedsRepoDirPath { project: String, git_ref: String },
250
251    #[error(
252        "series pins {project} as a self-pin (branch/sha form) but {project} is configured as kind=\"external\""
253    )]
254    SelfPinReferencesExternalProject { project: String },
255
256    #[error("project {project:?} has layout {layout:?} but no `app_roots` defaulted or set")]
257    LayoutWithoutAppRoots { project: String, layout: String },
258
259    #[error("suite_rule[{rule_index}]: invalid regex: {detail}")]
260    SuiteRuleRegex { rule_index: usize, detail: String },
261
262    #[error("suite_rule[{rule_index}]: template references unknown capture group {placeholder:?}")]
263    SuiteRuleUnknownPlaceholder {
264        rule_index: usize,
265        placeholder: String,
266    },
267
268    #[error(
269        "suite_rule[{rule_index}]: include_suite_for_dep_modules requires a named capture `dep` from the path or line regex"
270    )]
271    SuiteRuleMissingDepCapture { rule_index: usize },
272
273    #[error("path_translation file not found: {0:?}")]
274    PathTranslationFileMissing(PathBuf),
275
276    #[error("path_translation: name must be non-empty")]
277    PathTranslationEmptyName,
278
279    #[error("path_translation[{name:?}]: {field} must be non-empty")]
280    PathTranslationEmptyPrefix { name: String, field: &'static str },
281
282    #[error("path_translation[{name:?}]: {field} {value:?} must end in '/'")]
283    PathTranslationPrefixNoTrailingSlash {
284        name: String,
285        field: &'static str,
286        value: String,
287    },
288
289    #[error("path_translation[{name:?}]: unknown direction {direction:?}")]
290    PathTranslationUnknownDirection { name: String, direction: String },
291
292    #[error("path_translation: duplicate name {0:?}")]
293    PathTranslationDuplicateName(String),
294
295    #[error("path_translation: duplicate source_prefix {0:?}")]
296    PathTranslationDuplicateSource(String),
297
298    #[error(
299        "path_translation: source_prefix overlap: {a:?} and {b:?} (one is a strict prefix of the other)"
300    )]
301    PathTranslationPrefixOverlap { a: String, b: String },
302
303    #[error("name error: {0}")]
304    Name(#[from] NameError),
305
306    #[error("i/o error: {0}")]
307    Io(#[from] io::Error),
308}
309
310#[derive(Debug, Error)]
311pub enum PatchError {
312    #[error("malformed unified diff at line {line}: {detail}")]
313    Malformed { line: usize, detail: String },
314
315    #[error("input exceeds size limit ({size} > {limit} bytes)")]
316    SizeLimit { size: usize, limit: usize },
317
318    #[error("patch is not valid UTF-8 (first invalid byte at offset {offset})")]
319    InvalidUtf8 { offset: usize },
320
321    #[error("unsupported file type: {0}")]
322    UnsupportedFileType(String),
323
324    #[error("binary patch is not analyzable")]
325    BinaryPatch,
326}