Skip to main content

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}