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