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}