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 tag, branch or commit given for a source that is a directory.
409    #[error("the source {name} is a directory, which has no tag, branch or commit")]
410    NotGit {
411        /// The source.
412        name: SourceName,
413    },
414    /// A profile is named without its source, and the target names several, or none.
415    #[error("which source is {profile} in? name it as `source/{profile}`")]
416    WhichSource {
417        /// The profile.
418        profile: ProfileName,
419        /// Every source the target names.
420        sources: Vec<SourceName>,
421    },
422    /// A source holds several profiles, and none was named.
423    #[error("{location} holds {} profiles; name the one to add", profiles.len())]
424    Ambiguous {
425        /// The source, as configured.
426        location: String,
427        /// Every profile it holds.
428        profiles: Vec<ProfileName>,
429    },
430    /// `config.toml` already lists the layer.
431    #[error("the layer {layer} is already applied")]
432    DuplicateLayer {
433        /// The layer's profile.
434        layer: ProfileName,
435    },
436    /// `config.toml` lists one layer twice, which a layer's name must tell apart.
437    #[error("`config.toml` lists the layer {layer} twice")]
438    LayerTwice {
439        /// The layer's profile.
440        layer: ProfileName,
441    },
442    /// A feature to turn off that the layer does not list.
443    #[error("the layer {layer} does not list the feature {feature}")]
444    NotListed {
445        /// The layer's profile.
446        layer: ProfileName,
447        /// The feature.
448        feature: FeatureName,
449        /// The features the layer lists.
450        listed: Vec<FeatureName>,
451    },
452    /// No layer, and no source, has the name asked for.
453    #[error("no layer is named {name}")]
454    NoSuchLayer {
455        /// The name asked for.
456        name: String,
457        /// Every layer's name, and every source's.
458        names: Vec<String>,
459    },
460    /// A scaffold named on the command line is no active profile's.
461    #[error("no scaffold is named {name}")]
462    NoSuchScaffold {
463        /// The name asked for, `profile/group`.
464        name: String,
465        /// Every active profile's scaffolds.
466        scaffolds: Vec<String>,
467    },
468    /// A profile or a collection to author would be written over a file that is there.
469    #[error("{path} is there already")]
470    Occupied {
471        /// The file, as the command line names it.
472        path: Utf8PathBuf,
473    },
474    /// A name `explain` was given that is neither a layer's nor a managed file's.
475    #[error("{name} is neither a layer nor a file devset manages")]
476    Unexplained {
477        /// The name, as given.
478        name: String,
479        /// Every layer's name, then every managed path.
480        names: Vec<String>,
481    },
482    /// A path named on the command line is not a file devset manages.
483    #[error("{path} is not a file devset manages")]
484    NotManaged {
485        /// The path, as given.
486        path: String,
487        /// Every managed path.
488        managed: Vec<RelPath>,
489    },
490    /// A layer named where only a configured one will do is one another profile requires.
491    #[error("{name} is required by {by}, not configured by this target")]
492    Required {
493        /// The layer asked for.
494        name: ProfileName,
495        /// The profile that requires it.
496        by: ProfileName,
497    },
498    /// `config.toml` overrides a path no layer provides.
499    #[error(".devset/config.toml overrides {path}, which no layer provides")]
500    StaleOverride {
501        /// The overridden path.
502        path: RelPath,
503        /// Every path the layers provide.
504        provided: Vec<RelPath>,
505    },
506    /// A managed path on disk is a symlink, a directory or a special file.
507    #[error("{path} is {kind}, not a regular file")]
508    NotAFile {
509        /// The managed path.
510        path: RelPath,
511        /// What is there instead: `a symlink`, `a directory` or `a special file`.
512        kind: &'static str,
513    },
514    /// Another devset holds the target's lock.
515    #[error("another devset is running in this target")]
516    Busy,
517    /// `state.toml` changed while this devset ran.
518    #[error(".devset/state.toml changed while devset ran")]
519    Concurrent,
520}
521
522/// Why the layers' variables cannot be answered or rendered.
523#[derive(Debug, Error, Clone, PartialEq, Eq)]
524#[non_exhaustive]
525pub enum VarError {
526    /// A name is not an identifier.
527    #[error(
528        "invalid variable name {name:?}: use letters, digits and `_`, not starting with a digit"
529    )]
530    Name {
531        /// The rejected input.
532        name: String,
533    },
534    /// A variable is named `devset`, which templates see the target's profiles under.
535    #[error("the variable name `devset` is reserved: templates see the target's profiles under it")]
536    Reserved,
537    /// The target answers a variable no layer declares.
538    #[error("no profile declares a variable named {name}")]
539    Unknown {
540        /// The answered name.
541        name: VarName,
542        /// Every declared name.
543        declared: Vec<VarName>,
544    },
545    /// The target has not answered these variables.
546    #[error("{} unanswered: {}", plural(questions.len(), "variable"), list(questions.iter().map(|q| &q.name)))]
547    Unanswered {
548        /// One per variable.
549        questions: Vec<Question>,
550    },
551    /// A template uses a variable no profile declares.
552    #[error("template {path} uses `{name}`, which no profile declares")]
553    Undeclared {
554        /// The template.
555        path: RelPath,
556        /// The variable.
557        name: String,
558        /// Every declared name.
559        declared: Vec<VarName>,
560    },
561    /// A template cannot be read as text.
562    #[error("template {path} cannot be rendered: {reason}")]
563    Template {
564        /// The template.
565        path: RelPath,
566        /// Why not.
567        reason: String,
568    },
569}
570
571/// Why a merge cannot run, or its resolution cannot be installed.
572#[derive(Debug, Error, Clone, PartialEq, Eq)]
573#[non_exhaustive]
574pub enum MergeError {
575    /// A `driver` line does not parse.
576    #[error("merge driver `{line}`: {reason}")]
577    Driver {
578        /// As configured.
579        line: String,
580        /// The rule it breaks.
581        reason: &'static str,
582    },
583    /// The driver's program is not installed, or not on `PATH`.
584    #[error("merge driver `{program}` is not installed")]
585    NoDriver {
586        /// The program.
587        program: String,
588    },
589    /// A driver program did not run to completion.
590    #[error("merge driver `{program}`: {reason}")]
591    Run {
592        /// The program.
593        program: String,
594        /// Why it stopped.
595        reason: String,
596    },
597    /// Conflicts wait in `.devset/conflicts/`.
598    #[error("{} unresolved: {}", plural(paths.len(), "conflict"), list(paths))]
599    Unresolved {
600        /// Each conflicted path.
601        paths: Vec<RelPath>,
602    },
603    /// Resolutions were to be installed, and no conflict waits.
604    #[error("there are no conflicts to continue from")]
605    NothingToContinue,
606    /// A conflicted run was to be taken back, and none is unfinished.
607    #[error("there is no run that conflicted to take back")]
608    NothingToAbort,
609    /// Taking a conflicted run back would discard changes made since it.
610    #[error("{} changed since the run that conflicted: {}", plural(paths.len(), "file"), list(paths))]
611    ChangedSince {
612        /// Each changed file, relative to the target root.
613        paths: Vec<String>,
614    },
615    /// A copy an update saved, to take it back, is missing or damaged.
616    #[error("the saved copy of {path} in .devset/conflicts/.devset/ is missing or damaged")]
617    CorruptUndo {
618        /// The file it would restore, relative to the target root.
619        path: String,
620    },
621    /// A resolution still has conflict markers.
622    #[error(".devset/conflicts/{path} still has conflict markers")]
623    Unmerged {
624        /// The conflicted path.
625        path: RelPath,
626    },
627    /// A resolution does not pass its file's check.
628    #[error(".devset/conflicts/{path}: {reason}")]
629    Invalid {
630        /// The conflicted path.
631        path: RelPath,
632        /// Why the check failed.
633        reason: String,
634    },
635}
636
637/// `items`, comma-separated, with `and` before the last.
638pub(crate) fn list<T: Display>(items: impl IntoIterator<Item = T>) -> String {
639    let items: Vec<String> = items.into_iter().map(|item| item.to_string()).collect();
640    match items.split_last() {
641        Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
642        _ => items.concat(),
643    }
644}
645
646/// Each layer and its scope: "`file` by base and `keys` by lints".
647fn scopes(layers: &[(ProfileName, Scope)]) -> String {
648    list(layers.iter().map(|(layer, scope)| format!("`{scope}` by {layer}")))
649}
650
651/// `: the layers a/b and a/c name it`, when `layers` is not empty.
652fn naming(layers: &[ProfileRef]) -> String {
653    match layers {
654        [] => String::new(),
655        [one] => format!(": the layer {one} names it"),
656        _ => format!(": the layers {} name it", list(layers)),
657    }
658}
659
660/// Each profile of a cycle, arrowed: `a → b → a`.
661fn list_chain(chain: &[ProfileName]) -> String {
662    chain.iter().map(ProfileName::as_str).collect::<Vec<_>>().join(" → ")
663}
664
665/// `n` and `noun`, pluralised.
666fn plural(n: usize, noun: &str) -> String {
667    if n == 1 { format!("1 {noun}") } else { format!("{n} {noun}s") }
668}