use core::fmt::Display;
use core::ops::Range;
use core::result;
use std::io;
use camino::Utf8PathBuf;
use thiserror::Error;
use crate::part::Scope;
use crate::path::RelPath;
use crate::source::Oid;
use crate::vars::{Question, VarName};
pub type Result<T, E = Error> = result::Result<T, E>;
#[derive(Debug, Error)]
#[non_exhaustive]
#[expect(
clippy::error_impl_error,
reason = "the crate's one umbrella error, named as `io::Error` and `serde_json::Error` are"
)]
pub enum Error {
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Parse(Box<ParseError>),
#[error(transparent)]
Path(#[from] PathError),
#[error(transparent)]
Source(#[from] SourceError),
#[error(transparent)]
Profile(#[from] ProfileError),
#[error(transparent)]
Target(#[from] TargetError),
#[error(transparent)]
Var(#[from] VarError),
#[error(transparent)]
Merge(#[from] MergeError),
}
impl From<ParseError> for Error {
fn from(error: ParseError) -> Self {
Self::Parse(Box::new(error))
}
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[error("{file}: {message}")]
pub struct ParseError {
pub file: String,
pub text: String,
pub span: Option<Range<usize>>,
pub message: String,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[error("invalid path {path:?}: {rule}")]
pub struct PathError {
pub(crate) path: String,
pub(crate) rule: &'static str,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SourceError {
#[error("invalid source: needs `git` or `path`")]
NoLocation,
#[error("invalid source: `path` is empty")]
EmptyPath,
#[error("invalid source: `tag`, `branch` and `rev` need `git`")]
RefWithoutGit,
#[error("invalid source: at most one of `tag`, `branch` and `rev`")]
TwoRefs,
#[error("invalid source: `git` must be a repository URL, not {url:?}")]
Url {
url: String,
},
#[error("invalid source: `rev` must be a full commit id, not {rev:?}")]
Rev {
rev: String,
},
#[error(transparent)]
Path(#[from] PathError),
#[error("{file} is not a regular file")]
NotAFile {
file: String,
},
#[error("git sources need `git`, which is not on PATH")]
NoGit,
#[error("{url} has no {reference}")]
NoRef {
url: String,
reference: String,
},
#[error("{url} has no commit {rev}")]
NoCommit {
url: String,
rev: Oid,
},
#[error("{url} needs credentials, and git could not get them")]
Credentials {
url: String,
},
#[error("git {command} failed: {}", stderr.lines().next().unwrap_or("with no message"))]
Git {
command: String,
stderr: String,
},
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProfileError {
#[error("{location} is not a profile: it has no profile.toml")]
NotAProfile {
location: String,
profiles: Vec<String>,
},
#[error("profile {profile} requires devset {requires}; this is {version}", version = crate::VERSION)]
Incompatible {
profile: String,
requires: String,
},
#[error("profile {profile} lists {path}, but files/{path} does not exist")]
MissingPayload {
profile: String,
path: RelPath,
},
#[error("{path} is provided by {}", list(layers))]
Collision {
path: RelPath,
layers: Vec<String>,
},
#[error("{from} does not provide {path}")]
NotProvided {
path: RelPath,
from: String,
layers: Vec<String>,
},
#[error("{first} and {second} are the same file on case-insensitive filesystems")]
FoldCollision {
first: RelPath,
second: RelPath,
},
#[error("requirements form a cycle: {}", chain.join(" → "))]
Cycle {
chain: Vec<String>,
},
#[error("requirements nest too deep: {}", chain.join(" → "))]
TooDeep {
chain: Vec<String>,
},
#[error("{first} and {second} are one profile at two refs")]
Diverged {
first: String,
second: String,
},
#[error("profile {profile} requires {path}, which is outside its source")]
Escapes {
profile: String,
path: String,
},
#[error("{path} is not read as TOML, JSON or YAML, so a profile cannot own its keys")]
NoKeys {
path: RelPath,
},
#[error("devset knows no comment syntax for {path}, which a block's markers need")]
NoComment {
path: RelPath,
},
#[error("{first} and {second} both own {key} in {path}")]
Overlap {
path: RelPath,
key: String,
first: String,
second: String,
},
#[error("{path} is owned in different scopes: {}", scopes(layers))]
Scopes {
path: RelPath,
layers: Vec<(String, Scope)>,
},
#[error("two layers are named {name}: {first} and {second}")]
SameName {
name: String,
first: String,
second: String,
},
#[error("{first} and {second} set {key} differently")]
Setting {
key: String,
first: String,
second: String,
},
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum TargetError {
#[error("no devset target in {dir} or any parent")]
NotFound {
dir: Utf8PathBuf,
},
#[error("{dir} is inside the devset target at {root}")]
Nested {
dir: Utf8PathBuf,
root: Utf8PathBuf,
},
#[error("the layer {layer} is already applied")]
DuplicateLayer {
layer: String,
},
#[error("no layer is named {name}")]
NoSuchLayer {
name: String,
layers: Vec<String>,
},
#[error("{path} is not a file devset manages")]
NotManaged {
path: String,
managed: Vec<RelPath>,
},
#[error("{name} is required by {by}, not configured by this target")]
Required {
name: String,
by: String,
},
#[error(".devset/config.toml overrides {path}, which no layer provides")]
StaleOverride {
path: RelPath,
provided: Vec<RelPath>,
},
#[error("{path} is {kind}, not a regular file")]
NotAFile {
path: RelPath,
kind: &'static str,
},
#[error("another devset is running in this target")]
Busy,
#[error(".devset/state.toml changed while devset ran")]
Concurrent,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum VarError {
#[error(
"invalid variable name {name:?}: use letters, digits and `_`, not starting with a digit"
)]
Name {
name: String,
},
#[error("no profile declares a variable named {name}")]
Unknown {
name: VarName,
declared: Vec<VarName>,
},
#[error("{} unanswered: {}", plural(questions.len(), "variable"), list(questions.iter().map(|q| &q.name)))]
Unanswered {
questions: Vec<Question>,
},
#[error("template {path} uses `{name}`, which no profile declares")]
Undeclared {
path: RelPath,
name: String,
declared: Vec<VarName>,
},
#[error("template {path} cannot be rendered: {reason}")]
Template {
path: RelPath,
reason: String,
},
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MergeError {
#[error("merge driver `{line}`: {reason}")]
Driver {
line: String,
reason: &'static str,
},
#[error("merge driver `{program}` is not installed")]
NoDriver {
program: String,
},
#[error("merge driver `{program}`: {reason}")]
Run {
program: String,
reason: String,
},
#[error("{} unresolved: {}", plural(paths.len(), "conflict"), list(paths))]
Unresolved {
paths: Vec<RelPath>,
},
#[error("there are no conflicts to continue from")]
NothingToContinue,
#[error("there is no unfinished update to abort")]
NothingToAbort,
#[error("{} changed since the update: {}", plural(paths.len(), "file"), list(paths))]
ChangedSince {
paths: Vec<String>,
},
#[error("the saved copy of {path} in .devset/conflicts/.devset/ is missing or damaged")]
CorruptUndo {
path: String,
},
#[error(".devset/conflicts/{path} still has conflict markers")]
Unmerged {
path: RelPath,
},
#[error(".devset/conflicts/{path}: {reason}")]
Invalid {
path: RelPath,
reason: String,
},
}
pub(crate) fn list<T: Display>(items: impl IntoIterator<Item = T>) -> String {
let items: Vec<String> = items.into_iter().map(|item| item.to_string()).collect();
match items.split_last() {
Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
_ => items.concat(),
}
}
fn scopes(layers: &[(String, Scope)]) -> String {
list(layers.iter().map(|(layer, scope)| format!("`{scope}` by {layer}")))
}
fn plural(n: usize, noun: &str) -> String {
if n == 1 { format!("1 {noun}") } else { format!("{n} {noun}s") }
}