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