devset_core/errors.rs
1//! Why devset did not go through: a path, a source, a profile, a target, a variable, or a merge.
2
3use core::fmt::Display;
4use core::ops::Range;
5use core::result;
6use std::io;
7
8use camino::Utf8PathBuf;
9use thiserror::Error;
10
11use crate::part::Scope;
12use crate::path::RelPath;
13use crate::source::Oid;
14use crate::vars::{Question, VarName};
15
16/// A [`Result`](result::Result) that fails with an [`Error`](enum@Error) unless told otherwise.
17pub type Result<T, E = Error> = result::Result<T, E>;
18
19/// Why a devset operation did not go through.
20///
21/// Each domain has its own error, so a caller matches as narrowly as it needs to:
22/// `Error::Target(TargetError::Busy)` is one lock, [`Error::Target`] every target failure.
23#[derive(Debug, Error)]
24#[non_exhaustive]
25#[expect(
26 clippy::error_impl_error,
27 reason = "the crate's one umbrella error, named as `io::Error` and `serde_json::Error` are"
28)]
29pub enum Error {
30 /// Filesystem or process I/O failed; the message names the path.
31 #[error(transparent)]
32 Io(#[from] io::Error),
33 /// A file devset reads does not parse.
34 #[error(transparent)]
35 Parse(Box<ParseError>),
36 /// A string is not a [`RelPath`].
37 #[error(transparent)]
38 Path(#[from] PathError),
39 /// A source is misconfigured, or git failed on it.
40 #[error(transparent)]
41 Source(#[from] SourceError),
42 /// A profile, or the layers together, do not resolve.
43 #[error(transparent)]
44 Profile(#[from] ProfileError),
45 /// The target cannot be found, read or written.
46 #[error(transparent)]
47 Target(#[from] TargetError),
48 /// A variable is unanswered, unknown, or breaks a template.
49 #[error(transparent)]
50 Var(#[from] VarError),
51 /// A merge cannot run, or its resolution cannot be installed.
52 #[error(transparent)]
53 Merge(#[from] MergeError),
54}
55
56impl From<ParseError> for Error {
57 fn from(error: ParseError) -> Self {
58 Self::Parse(Box::new(error))
59 }
60}
61
62/// A file that does not parse, with the text to point into.
63#[derive(Debug, Error, Clone, PartialEq, Eq)]
64#[error("{file}: {message}")]
65pub struct ParseError {
66 /// The file, as users know it.
67 pub file: String,
68 /// Its text; empty when it is not UTF-8.
69 pub text: String,
70 /// The offending bytes of `text`, when the parser knows them.
71 pub span: Option<Range<usize>>,
72 /// What is wrong.
73 pub message: String,
74}
75
76/// Why a string is not a [`RelPath`].
77#[derive(Debug, Error, Clone, PartialEq, Eq)]
78#[error("invalid path {path:?}: {rule}")]
79pub struct PathError {
80 /// The rejected input.
81 pub(crate) path: String,
82 /// The rule it breaks.
83 pub(crate) rule: &'static str,
84}
85
86/// Why a source cannot be read.
87#[derive(Debug, Error, Clone, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum SourceError {
90 /// Neither `git` nor `path` is set.
91 #[error("invalid source: needs `git` or `path`")]
92 NoLocation,
93 /// A local `path` is empty.
94 #[error("invalid source: `path` is empty")]
95 EmptyPath,
96 /// `tag`, `branch` or `rev` is set without `git`.
97 #[error("invalid source: `tag`, `branch` and `rev` need `git`")]
98 RefWithoutGit,
99 /// More than one of `tag`, `branch` and `rev` is set.
100 #[error("invalid source: at most one of `tag`, `branch` and `rev`")]
101 TwoRefs,
102 /// `git` is empty, or would reach git as an option.
103 #[error("invalid source: `git` must be a repository URL, not {url:?}")]
104 Url {
105 /// As configured.
106 url: String,
107 },
108 /// `rev` is not a full commit id.
109 #[error("invalid source: `rev` must be a full commit id, not {rev:?}")]
110 Rev {
111 /// As configured.
112 rev: String,
113 },
114 /// `path` within a repository is not a [`RelPath`].
115 #[error(transparent)]
116 Path(#[from] PathError),
117 /// A file the profile lists is a symlink, a directory or a submodule.
118 #[error("{file} is not a regular file")]
119 NotAFile {
120 /// The file, as a path or `<commit>:<path>`.
121 file: String,
122 },
123 /// `git` is not on `PATH`.
124 #[error("git sources need `git`, which is not on PATH")]
125 NoGit,
126 /// The remote has no such branch or tag, or no default branch.
127 #[error("{url} has no {reference}")]
128 NoRef {
129 /// The remote, as configured.
130 url: String,
131 /// What was asked for: `branch <name>`, `tag <name>` or `a default branch`.
132 reference: String,
133 },
134 /// The remote has no commit with the pinned id.
135 #[error("{url} has no commit {rev}")]
136 NoCommit {
137 /// The remote, as configured.
138 url: String,
139 /// The commit asked for.
140 rev: Oid,
141 },
142 /// The remote needs credentials that git could not get.
143 #[error("{url} needs credentials, and git could not get them")]
144 Credentials {
145 /// The remote, as configured.
146 url: String,
147 },
148 /// A `git` command failed.
149 #[error("git {command} failed: {}", stderr.lines().next().unwrap_or("with no message"))]
150 Git {
151 /// The subcommand, and the remote as configured when it reached one.
152 command: String,
153 /// What git reported, one message per line.
154 stderr: String,
155 },
156}
157
158/// Why a profile, or the layers together, do not resolve.
159#[derive(Debug, Error, Clone, PartialEq, Eq)]
160#[non_exhaustive]
161pub enum ProfileError {
162 /// The source has no `profile.toml`.
163 #[error("{location} is not a profile: it has no profile.toml")]
164 NotAProfile {
165 /// The source, as configured.
166 location: String,
167 /// The directories that do hold one, as `path` would name them.
168 profiles: Vec<String>,
169 },
170 /// The profile's `devset` requirement excludes this build.
171 #[error("profile {profile} requires devset {requires}; this is {version}", version = crate::VERSION)]
172 Incompatible {
173 /// The profile's name.
174 profile: String,
175 /// Its requirement.
176 requires: String,
177 },
178 /// `[files]` lists a path that `files/` does not hold.
179 #[error("profile {profile} lists {path}, but files/{path} does not exist")]
180 MissingPayload {
181 /// The profile's name.
182 profile: String,
183 /// The listed path.
184 path: RelPath,
185 },
186 /// Several layers provide one path, and the target has not chosen.
187 #[error("{path} is provided by {}", list(layers))]
188 Collision {
189 /// The path.
190 path: RelPath,
191 /// Every layer providing it.
192 layers: Vec<String>,
193 },
194 /// An override's `from` names a layer that does not provide the path.
195 #[error("{from} does not provide {path}")]
196 NotProvided {
197 /// The path.
198 path: RelPath,
199 /// The layer named.
200 from: String,
201 /// The layers that do provide it.
202 layers: Vec<String>,
203 },
204 /// Two paths name one file on a case-insensitive filesystem.
205 #[error("{first} and {second} are the same file on case-insensitive filesystems")]
206 FoldCollision {
207 /// The path met first.
208 first: RelPath,
209 /// The path that folds onto it.
210 second: RelPath,
211 },
212 /// Requirements lead back to a profile already being expanded.
213 #[error("requirements form a cycle: {}", chain.join(" → "))]
214 Cycle {
215 /// Each source on the way round, the repeated one last.
216 chain: Vec<String>,
217 },
218 /// Requirements nest deeper than devset follows.
219 #[error("requirements nest too deep: {}", chain.join(" → "))]
220 TooDeep {
221 /// Each source on the way down.
222 chain: Vec<String>,
223 },
224 /// One profile is required at two refs, which would provide one set of files twice.
225 #[error("{first} and {second} are one profile at two refs")]
226 Diverged {
227 /// The source met first.
228 first: String,
229 /// The source met second.
230 second: String,
231 },
232 /// A sibling requirement points outside the requiring profile's source.
233 #[error("profile {profile} requires {path}, which is outside its source")]
234 Escapes {
235 /// The requiring profile's name.
236 profile: String,
237 /// The requirement, as written.
238 path: String,
239 },
240 /// Keys are owned in a file devset does not read as TOML, JSON or YAML.
241 #[error("{path} is not read as TOML, JSON or YAML, so a profile cannot own its keys")]
242 NoKeys {
243 /// The file.
244 path: RelPath,
245 },
246 /// A block is owned in a file whose comment syntax devset does not know.
247 #[error("devset knows no comment syntax for {path}, which a block's markers need")]
248 NoComment {
249 /// The file.
250 path: RelPath,
251 },
252 /// Two layers own overlapping keys of one file.
253 #[error("{first} and {second} both own {key} in {path}")]
254 Overlap {
255 /// The file.
256 path: RelPath,
257 /// The key, as people write it.
258 key: String,
259 /// The layer met first.
260 first: String,
261 /// The layer that overlaps it.
262 second: String,
263 },
264 /// Layers own one file in different scopes: one whole and another in part, or one by keys
265 /// and another by a block.
266 #[error("{path} is owned in different scopes: {}", scopes(layers))]
267 Scopes {
268 /// The file.
269 path: RelPath,
270 /// Each layer, with its scope.
271 layers: Vec<(String, Scope)>,
272 },
273 /// Two layers' profiles have one name, which must tell layers apart.
274 #[error("two layers are named {name}: {first} and {second}")]
275 SameName {
276 /// The name.
277 name: String,
278 /// The first layer's source.
279 first: String,
280 /// The second layer's source.
281 second: String,
282 },
283 /// Two layers set one setting differently, and the target does not decide.
284 #[error("{first} and {second} set {key} differently")]
285 Setting {
286 /// The setting, as `table.key`.
287 key: String,
288 /// The layer that set it first.
289 first: String,
290 /// The layer that disagrees.
291 second: String,
292 },
293}
294
295/// Why a target cannot be found, read or written.
296#[derive(Debug, Error, Clone, PartialEq, Eq)]
297#[non_exhaustive]
298pub enum TargetError {
299 /// No directory from here up holds `.devset/config.toml`.
300 #[error("no devset target in {dir} or any parent")]
301 NotFound {
302 /// Where the search began.
303 dir: Utf8PathBuf,
304 },
305 /// A new target would lie inside another.
306 #[error("{dir} is inside the devset target at {root}")]
307 Nested {
308 /// The directory asked for.
309 dir: Utf8PathBuf,
310 /// The enclosing target's root.
311 root: Utf8PathBuf,
312 },
313 /// `config.toml` already lists the layer.
314 #[error("the layer {layer} is already applied")]
315 DuplicateLayer {
316 /// The layer's source.
317 layer: String,
318 },
319 /// No layer's profile has the name asked for.
320 #[error("no layer is named {name}")]
321 NoSuchLayer {
322 /// The name asked for.
323 name: String,
324 /// Every layer's name.
325 layers: Vec<String>,
326 },
327 /// A path named on the command line is not a file devset manages.
328 #[error("{path} is not a file devset manages")]
329 NotManaged {
330 /// The path, as given.
331 path: String,
332 /// Every managed path.
333 managed: Vec<RelPath>,
334 },
335 /// A layer named where only a configured one will do is one another profile requires.
336 #[error("{name} is required by {by}, not configured by this target")]
337 Required {
338 /// The layer asked for.
339 name: String,
340 /// The profile that requires it.
341 by: String,
342 },
343 /// `config.toml` overrides a path no layer provides.
344 #[error(".devset/config.toml overrides {path}, which no layer provides")]
345 StaleOverride {
346 /// The overridden path.
347 path: RelPath,
348 /// Every path the layers provide.
349 provided: Vec<RelPath>,
350 },
351 /// A managed path on disk is a symlink, a directory or a special file.
352 #[error("{path} is {kind}, not a regular file")]
353 NotAFile {
354 /// The managed path.
355 path: RelPath,
356 /// What is there instead: `a symlink`, `a directory` or `a special file`.
357 kind: &'static str,
358 },
359 /// Another devset holds the target's lock.
360 #[error("another devset is running in this target")]
361 Busy,
362 /// `state.toml` changed while this devset ran.
363 #[error(".devset/state.toml changed while devset ran")]
364 Concurrent,
365}
366
367/// Why the layers' variables cannot be answered or rendered.
368#[derive(Debug, Error, Clone, PartialEq, Eq)]
369#[non_exhaustive]
370pub enum VarError {
371 /// A name is not an identifier.
372 #[error(
373 "invalid variable name {name:?}: use letters, digits and `_`, not starting with a digit"
374 )]
375 Name {
376 /// The rejected input.
377 name: String,
378 },
379 /// The target answers a variable no layer declares.
380 #[error("no profile declares a variable named {name}")]
381 Unknown {
382 /// The answered name.
383 name: VarName,
384 /// Every declared name.
385 declared: Vec<VarName>,
386 },
387 /// The target has not answered these variables.
388 #[error("{} unanswered: {}", plural(questions.len(), "variable"), list(questions.iter().map(|q| &q.name)))]
389 Unanswered {
390 /// One per variable.
391 questions: Vec<Question>,
392 },
393 /// A template uses a variable no profile declares.
394 #[error("template {path} uses `{name}`, which no profile declares")]
395 Undeclared {
396 /// The template.
397 path: RelPath,
398 /// The variable.
399 name: String,
400 /// Every declared name.
401 declared: Vec<VarName>,
402 },
403 /// A template cannot be read as text.
404 #[error("template {path} cannot be rendered: {reason}")]
405 Template {
406 /// The template.
407 path: RelPath,
408 /// Why not.
409 reason: String,
410 },
411}
412
413/// Why a merge cannot run, or its resolution cannot be installed.
414#[derive(Debug, Error, Clone, PartialEq, Eq)]
415#[non_exhaustive]
416pub enum MergeError {
417 /// A `driver` line does not parse.
418 #[error("merge driver `{line}`: {reason}")]
419 Driver {
420 /// As configured.
421 line: String,
422 /// The rule it breaks.
423 reason: &'static str,
424 },
425 /// The driver's program is not installed, or not on `PATH`.
426 #[error("merge driver `{program}` is not installed")]
427 NoDriver {
428 /// The program.
429 program: String,
430 },
431 /// A driver program did not run to completion.
432 #[error("merge driver `{program}`: {reason}")]
433 Run {
434 /// The program.
435 program: String,
436 /// Why it stopped.
437 reason: String,
438 },
439 /// Conflicts wait in `.devset/conflicts/`.
440 #[error("{} unresolved: {}", plural(paths.len(), "conflict"), list(paths))]
441 Unresolved {
442 /// Each conflicted path.
443 paths: Vec<RelPath>,
444 },
445 /// Resolutions were to be installed, and no conflict waits.
446 #[error("there are no conflicts to continue from")]
447 NothingToContinue,
448 /// An update was to be taken back, and none is unfinished.
449 #[error("there is no unfinished update to abort")]
450 NothingToAbort,
451 /// Taking an update back would discard changes made since it.
452 #[error("{} changed since the update: {}", plural(paths.len(), "file"), list(paths))]
453 ChangedSince {
454 /// Each changed file, relative to the target root.
455 paths: Vec<String>,
456 },
457 /// A copy an update saved, to take it back, is missing or damaged.
458 #[error("the saved copy of {path} in .devset/conflicts/.devset/ is missing or damaged")]
459 CorruptUndo {
460 /// The file it would restore, relative to the target root.
461 path: String,
462 },
463 /// A resolution still has conflict markers.
464 #[error(".devset/conflicts/{path} still has conflict markers")]
465 Unmerged {
466 /// The conflicted path.
467 path: RelPath,
468 },
469 /// A resolution does not pass its file's check.
470 #[error(".devset/conflicts/{path}: {reason}")]
471 Invalid {
472 /// The conflicted path.
473 path: RelPath,
474 /// Why the check failed.
475 reason: String,
476 },
477}
478
479/// `items`, comma-separated, with `and` before the last.
480pub(crate) fn list<T: Display>(items: impl IntoIterator<Item = T>) -> String {
481 let items: Vec<String> = items.into_iter().map(|item| item.to_string()).collect();
482 match items.split_last() {
483 Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
484 _ => items.concat(),
485 }
486}
487
488/// Each layer and its scope: "`file` by base and `keys` by lints".
489fn scopes(layers: &[(String, Scope)]) -> String {
490 list(layers.iter().map(|(layer, scope)| format!("`{scope}` by {layer}")))
491}
492
493/// `n` and `noun`, pluralised.
494fn plural(n: usize, noun: &str) -> String {
495 if n == 1 { format!("1 {noun}") } else { format!("{n} {noun}s") }
496}