diff --git a/Cargo.lock b/Cargo.lock index a46d7a0..1b27f68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,7 @@ dependencies = [ "comfy-table", "directories", "serde", + "thiserror", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index 446f6ed..3733017 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,5 @@ clap = { version = "4.6", features = ["derive"] } comfy-table = "7.2" directories = "6.0" serde = { version = "1.0", features = ["derive"] } +thiserror = "2" toml = "1.1" diff --git a/src/action.rs b/src/action.rs index 5b3e34e..01c38e5 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,7 +1,5 @@ use std::env; -use std::error::Error; use std::ffi::{OsStr, OsString}; -use std::fmt; use std::io; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; @@ -178,27 +176,13 @@ impl PreparedCommand { } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum CommandPreparationError { - InvalidPathEnvironment { source: env::JoinPathsError }, -} - -impl fmt::Display for CommandPreparationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidPathEnvironment { .. } => { - formatter.write_str("the effective PATH contains an invalid path entry") - } - } - } -} - -impl Error for CommandPreparationError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::InvalidPathEnvironment { source } => Some(source), - } - } + #[error("the effective PATH contains an invalid path entry")] + InvalidPathEnvironment { + #[source] + source: env::JoinPathsError, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -306,39 +290,18 @@ impl ProcessExecutor { } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ExecutionError { + #[error("failed to start `{}`: {source}", .program.to_string_lossy())] Spawn { program: OsString, + #[source] source: io::Error, }, + #[error("failed while waiting for `{}`: {source}", .program.to_string_lossy())] Wait { program: OsString, + #[source] source: io::Error, }, } - -impl fmt::Display for ExecutionError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Spawn { program, source } => write!( - formatter, - "failed to start `{}`: {source}", - program.to_string_lossy() - ), - Self::Wait { program, source } => write!( - formatter, - "failed while waiting for `{}`: {source}", - program.to_string_lossy() - ), - } - } -} - -impl Error for ExecutionError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Spawn { source, .. } | Self::Wait { source, .. } => Some(source), - } - } -} diff --git a/src/action_runner.rs b/src/action_runner.rs index 9c9c0fd..506f4e8 100644 --- a/src/action_runner.rs +++ b/src/action_runner.rs @@ -1,4 +1,3 @@ -use std::error::Error; use std::fmt; use crate::action::{ @@ -116,16 +115,21 @@ impl<'a> ActionRunner<'a> { } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ActionRunError { + #[error("failed to prepare action {stage}: {source}")] Preparation { stage: ActionStage, + #[source] source: CommandPreparationError, }, + #[error("failed to execute action {stage}: {source}")] Execution { stage: ActionStage, + #[source] source: ExecutionError, }, + #[error("action {stage} returned {}", .result.status())] UnsuccessfulExit { stage: ActionStage, result: ExecutionResult, @@ -148,29 +152,3 @@ impl ActionRunError { } } } - -impl fmt::Display for ActionRunError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Preparation { stage, source } => { - write!(formatter, "failed to prepare action {stage}: {source}") - } - Self::Execution { stage, source } => { - write!(formatter, "failed to execute action {stage}: {source}") - } - Self::UnsuccessfulExit { stage, result } => { - write!(formatter, "action {stage} returned {}", result.status()) - } - } - } -} - -impl Error for ActionRunError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Preparation { source, .. } => Some(source), - Self::Execution { source, .. } => Some(source), - Self::UnsuccessfulExit { .. } => None, - } - } -} diff --git a/src/app.rs b/src/app.rs index c00b4f5..71e2f86 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,8 @@ +#![expect( + clippy::result_large_err, + reason = "direct typed error sources preserve Error::source downcasts" +)] + mod apply; mod check_providers; mod command; @@ -6,7 +11,6 @@ mod list_jobs; mod list_profiles; mod list_targets; -use std::error::Error; use std::fmt; use std::io::{self, IsTerminal}; use std::process::ExitCode; @@ -16,6 +20,7 @@ pub use command::{Dispatch, ExecutionRequest, Operation, ProfileSelection, Scope use crate::config::ConfigLoadError; use crate::manifest::ManifestError; use crate::output::{TableRenderer, TsvRecord, TsvRenderer}; +use crate::plan::ExecutionPlanError; use crate::platform::PlatformInfo; use crate::report::{CommandReport, ReportStatus}; @@ -151,40 +156,25 @@ fn normalize_list_output(result: io::Result<()>) -> io::Result<()> { } } -#[derive(Debug)] -enum ListCommandError { - Config(ConfigLoadError), - Manifest(ManifestError), -} +#[derive(Debug, thiserror::Error)] +enum ManifestCommandError { + #[error("{0}")] + Config(#[from] ConfigLoadError), -impl fmt::Display for ListCommandError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Config(source) => source.fmt(formatter), - Self::Manifest(source) => source.fmt(formatter), - } - } + #[error("{0}")] + Manifest(#[from] ManifestError), } -impl Error for ListCommandError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Config(source) => Some(source), - Self::Manifest(source) => Some(source), - } - } -} +#[derive(Debug, thiserror::Error)] +enum ExecutionCommandError { + #[error("{0}")] + Config(#[from] ConfigLoadError), -impl From for ListCommandError { - fn from(source: ConfigLoadError) -> Self { - Self::Config(source) - } -} + #[error("{0}")] + Manifest(#[from] ManifestError), -impl From for ListCommandError { - fn from(source: ManifestError) -> Self { - Self::Manifest(source) - } + #[error("{0}")] + Plan(#[from] ExecutionPlanError), } #[cfg(test)] diff --git a/src/app/apply.rs b/src/app/apply.rs index 4bbfb8a..6c6b5c6 100644 --- a/src/app/apply.rs +++ b/src/app/apply.rs @@ -1,19 +1,16 @@ -use std::error::Error; -use std::fmt; use std::path::Path; -use super::ExecutionRequest; +use super::{ExecutionCommandError, ExecutionRequest}; use crate::action::ExecutionResult; use crate::action_runner::{ActionOutcome, ActionRunError, ActionStage}; -use crate::config::{ConfigLoadError, LoadedConfig}; +use crate::config::LoadedConfig; use crate::diagnostic::lookup; use crate::interpolation::{DotPaths, XdgPaths}; use crate::job_runner::{BlockReason, JobExecutionReport, JobOutcome, JobRunner, JobState}; use crate::link::LinkOutcome; -use crate::manifest::{EffectiveManifest, ManifestError}; +use crate::manifest::EffectiveManifest; use crate::plan::{ - ExecutionPlan, ExecutionPlanError, ExecutionPlanner, PlannedJob, PlannedPackage, - PlannedProviderInstall, + ExecutionPlan, ExecutionPlanner, PlannedJob, PlannedPackage, PlannedProviderInstall, }; use crate::platform::PlatformInfo; use crate::provider::{ @@ -28,7 +25,7 @@ use crate::report::{ pub(super) fn run( config: &Path, request: &ExecutionRequest, -) -> Result { +) -> Result { let loaded = LoadedConfig::load(config)?; let platform = PlatformInfo::detect(); let manifest = EffectiveManifest::select_for_execution( @@ -425,51 +422,6 @@ fn captured_text(output: Option<&[u8]>) -> Option { .map(|output| String::from_utf8_lossy(output).into_owned()) } -#[derive(Debug)] -pub(super) enum CommandError { - Config(ConfigLoadError), - Manifest(ManifestError), - Plan(ExecutionPlanError), -} - -impl fmt::Display for CommandError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Config(source) => source.fmt(formatter), - Self::Manifest(source) => source.fmt(formatter), - Self::Plan(source) => source.fmt(formatter), - } - } -} - -impl Error for CommandError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Config(source) => Some(source), - Self::Manifest(source) => Some(source), - Self::Plan(source) => Some(source), - } - } -} - -impl From for CommandError { - fn from(source: ConfigLoadError) -> Self { - Self::Config(source) - } -} - -impl From for CommandError { - fn from(source: ManifestError) -> Self { - Self::Manifest(source) - } -} - -impl From for CommandError { - fn from(source: ExecutionPlanError) -> Self { - Self::Plan(source) - } -} - #[cfg(test)] mod tests { use std::env; diff --git a/src/app/check_providers.rs b/src/app/check_providers.rs index d86b093..9e96171 100644 --- a/src/app/check_providers.rs +++ b/src/app/check_providers.rs @@ -1,11 +1,8 @@ -use std::error::Error; -use std::fmt; - -use super::ScopeSelection; +use super::{ManifestCommandError, ScopeSelection}; use crate::check::{ProviderChecker, build_report}; -use crate::config::{ConfigLoadError, LoadedConfig}; +use crate::config::LoadedConfig; use crate::interpolation::{DotPaths, XdgPaths}; -use crate::manifest::{EffectiveManifest, ManifestError}; +use crate::manifest::EffectiveManifest; use crate::platform::PlatformInfo; use crate::report::CommandReport; @@ -13,7 +10,7 @@ pub(super) fn run( config: &std::path::Path, scope: &ScopeSelection, platform_override: Option<&PlatformInfo>, -) -> Result { +) -> Result { let loaded = LoadedConfig::load(config)?; let platform = platform_override .cloned() @@ -38,39 +35,3 @@ pub(super) fn run( &checks, )) } - -#[derive(Debug)] -pub(super) enum CommandError { - Config(ConfigLoadError), - Manifest(ManifestError), -} - -impl fmt::Display for CommandError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Config(source) => source.fmt(formatter), - Self::Manifest(source) => source.fmt(formatter), - } - } -} - -impl Error for CommandError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Config(source) => Some(source), - Self::Manifest(source) => Some(source), - } - } -} - -impl From for CommandError { - fn from(source: ConfigLoadError) -> Self { - Self::Config(source) - } -} - -impl From for CommandError { - fn from(source: ManifestError) -> Self { - Self::Manifest(source) - } -} diff --git a/src/app/dry_run.rs b/src/app/dry_run.rs index 60e59cd..b6e2abe 100644 --- a/src/app/dry_run.rs +++ b/src/app/dry_run.rs @@ -1,12 +1,9 @@ -use std::error::Error; -use std::fmt; - -use super::ExecutionRequest; -use crate::config::{ConfigLoadError, LoadedConfig}; +use super::{ExecutionCommandError, ExecutionRequest}; +use crate::config::LoadedConfig; use crate::dry_run::build_report; use crate::interpolation::{DotPaths, XdgPaths}; -use crate::manifest::{EffectiveManifest, ManifestError}; -use crate::plan::{ExecutionPlanError, ExecutionPlanner}; +use crate::manifest::EffectiveManifest; +use crate::plan::ExecutionPlanner; use crate::platform::PlatformInfo; use crate::report::CommandReport; @@ -14,7 +11,7 @@ pub(super) fn run( config: &std::path::Path, request: &ExecutionRequest, platform_override: Option<&PlatformInfo>, -) -> Result { +) -> Result { let loaded = LoadedConfig::load(config)?; let platform = platform_override .cloned() @@ -32,48 +29,3 @@ pub(super) fn run( Ok(build_report(loaded.path(), &plan)) } - -#[derive(Debug)] -pub(super) enum CommandError { - Config(ConfigLoadError), - Manifest(ManifestError), - Plan(ExecutionPlanError), -} - -impl fmt::Display for CommandError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Config(source) => source.fmt(formatter), - Self::Manifest(source) => source.fmt(formatter), - Self::Plan(source) => source.fmt(formatter), - } - } -} - -impl Error for CommandError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Config(source) => Some(source), - Self::Manifest(source) => Some(source), - Self::Plan(source) => Some(source), - } - } -} - -impl From for CommandError { - fn from(source: ConfigLoadError) -> Self { - Self::Config(source) - } -} - -impl From for CommandError { - fn from(source: ManifestError) -> Self { - Self::Manifest(source) - } -} - -impl From for CommandError { - fn from(source: ExecutionPlanError) -> Self { - Self::Plan(source) - } -} diff --git a/src/app/list_jobs.rs b/src/app/list_jobs.rs index e24901b..f9816f2 100644 --- a/src/app/list_jobs.rs +++ b/src/app/list_jobs.rs @@ -7,7 +7,7 @@ use crate::output::TsvRecord; use crate::platform::PlatformInfo; use crate::schema::{Package, ProviderPackage}; -use super::{ListCommandError, ScopeSelection}; +use super::{ManifestCommandError, ScopeSelection}; pub(super) struct Catalog { manifest: EffectiveManifest, @@ -18,7 +18,7 @@ impl Catalog { config: &std::path::Path, platform: &PlatformInfo, scope: &ScopeSelection, - ) -> Result { + ) -> Result { let loaded = LoadedConfigDocument::load(config)?; let manifest = EffectiveManifest::select_for_inspection( loaded.config(), diff --git a/src/app/list_profiles.rs b/src/app/list_profiles.rs index 8a2fb56..655408b 100644 --- a/src/app/list_profiles.rs +++ b/src/app/list_profiles.rs @@ -6,7 +6,7 @@ use crate::output::TsvRecord; use crate::platform::PlatformInfo; use crate::schema::SelectorIdentifier; -use super::{ListCommandError, ProfileSelection}; +use super::{ManifestCommandError, ProfileSelection}; pub(super) struct Catalog { loaded: LoadedConfigDocument, @@ -18,7 +18,7 @@ impl Catalog { config: &std::path::Path, platform: &PlatformInfo, requested_target: Option<&SelectorIdentifier>, - ) -> Result { + ) -> Result { let loaded = LoadedConfigDocument::load(config)?; let selected = EffectiveManifest::select_for_inspection( loaded.config(), diff --git a/src/app/list_targets.rs b/src/app/list_targets.rs index 592f4d8..4c24ffe 100644 --- a/src/app/list_targets.rs +++ b/src/app/list_targets.rs @@ -6,14 +6,14 @@ use crate::output::TsvRecord; use crate::platform::PlatformInfo; use crate::schema::{Identifier, OneOrMany, PlatformConstraint, SelectorIdentifier}; -use super::ListCommandError; +use super::ManifestCommandError; pub(super) struct Catalog { loaded: LoadedConfigDocument, } impl Catalog { - pub(super) fn load(config: &std::path::Path) -> Result { + pub(super) fn load(config: &std::path::Path) -> Result { Ok(Self { loaded: LoadedConfigDocument::load(config)?, }) diff --git a/src/check.rs b/src/check.rs index 7689cfa..01b06c3 100644 --- a/src/check.rs +++ b/src/check.rs @@ -1,6 +1,3 @@ -use std::error::Error; -use std::fmt; - use crate::action::{ CommandPreparationError, ExecutionEnvironment, ExecutionError, ExecutionResult, IoMode, PreparedCommand, ProcessExecutor, @@ -149,49 +146,18 @@ fn captured_text(output: Option<&[u8]>) -> Option { .map(|output| String::from_utf8_lossy(output).into_owned()) } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ProviderCheckError { - ActivateInterpolation(InterpolationError), - ActivatePreparation(CommandPreparationError), - ProbeInterpolation(InterpolationError), - ProbePreparation(CommandPreparationError), - Execution(ExecutionError), -} - -impl fmt::Display for ProviderCheckError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ActivateInterpolation(source) => { - write!(formatter, "failed to resolve provider activate: {source}") - } - Self::ActivatePreparation(source) => { - write!(formatter, "failed to apply provider activate: {source}") - } - Self::ProbeInterpolation(source) => { - write!(formatter, "failed to resolve provider probe: {source}") - } - Self::ProbePreparation(source) => { - write!(formatter, "failed to prepare provider probe: {source}") - } - Self::Execution(source) => write!(formatter, "failed to execute probe: {source}"), - } - } -} - -impl Error for ProviderCheckError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::ActivateInterpolation(source) | Self::ProbeInterpolation(source) => Some(source), - Self::ActivatePreparation(source) | Self::ProbePreparation(source) => Some(source), - Self::Execution(source) => Some(source), - } - } -} - -impl From for ProviderCheckError { - fn from(source: ExecutionError) -> Self { - Self::Execution(source) - } + #[error("failed to resolve provider activate: {0}")] + ActivateInterpolation(#[source] InterpolationError), + #[error("failed to apply provider activate: {0}")] + ActivatePreparation(#[source] CommandPreparationError), + #[error("failed to resolve provider probe: {0}")] + ProbeInterpolation(#[source] InterpolationError), + #[error("failed to prepare provider probe: {0}")] + ProbePreparation(#[source] CommandPreparationError), + #[error("failed to execute probe: {0}")] + Execution(#[from] ExecutionError), } #[derive(Clone, Copy, Debug)] diff --git a/src/config.rs b/src/config.rs index be22c45..7937764 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,9 @@ +#![expect( + clippy::result_large_err, + reason = "direct typed error sources preserve Error::source downcasts" +)] + use std::env; -use std::error::Error; -use std::fmt; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -66,11 +69,29 @@ impl UserConfigRoot { } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ConfigDiscoveryError { - CurrentDirectory { source: io::Error }, + #[error("failed to determine the invocation directory: {source}")] + CurrentDirectory { + #[source] + source: io::Error, + }, + #[error("failed to determine the user configuration directory")] UserDirectoryUnavailable, - Inspect { path: PathBuf, source: io::Error }, + #[error( + "failed to inspect configuration candidate `{}`: {source}", + .path.display() + )] + Inspect { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error( + "configuration not found; checked `{}` then `{}`; use --config PATH to select another file", + .local.display(), + .user.display() + )] NotFound { local: PathBuf, user: PathBuf }, } @@ -121,49 +142,6 @@ impl ConfigRequest { } } -impl fmt::Display for ConfigDiscoveryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::CurrentDirectory { source } => { - write!( - formatter, - "failed to determine the invocation directory: {source}" - ) - } - Self::UserDirectoryUnavailable => { - write!( - formatter, - "failed to determine the user configuration directory" - ) - } - Self::Inspect { path, source } => { - write!( - formatter, - "failed to inspect configuration candidate `{}`: {source}", - path.display() - ) - } - Self::NotFound { local, user } => { - write!( - formatter, - "configuration not found; checked `{}` then `{}`; use --config PATH to select another file", - local.display(), - user.display() - ) - } - } - } -} - -impl Error for ConfigDiscoveryError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::CurrentDirectory { source } | Self::Inspect { source, .. } => Some(source), - Self::UserDirectoryUnavailable | Self::NotFound { .. } => None, - } - } -} - #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LoadedConfigDocument { config: Config, @@ -187,7 +165,7 @@ impl LoadedConfigDocument { })?; validate_config(&config).map_err(|source| ConfigLoadError::Validation { path: path.clone(), - source: Box::new(source), + source, })?; let directory = path .parent() @@ -265,71 +243,40 @@ fn absolute_path(path: &Path, invocation_cwd: &Path) -> PathBuf { } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ConfigLoadError { + #[error("failed to determine the invocation directory: {source}")] CurrentDirectory { + #[source] source: io::Error, }, + #[error("failed to read configuration `{}`: {source}", .path.display())] Read { path: PathBuf, + #[source] source: io::Error, }, + #[error("failed to parse configuration `{}`: {source}", .path.display())] Parse { path: PathBuf, + #[source] source: toml::de::Error, }, + #[error( + "failed to validate configuration `{}`: {source}", + .path.display() + )] Validation { path: PathBuf, - source: Box, + #[source] + source: ConfigValidationError, }, } -impl fmt::Display for ConfigLoadError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::CurrentDirectory { source } => { - write!( - formatter, - "failed to determine the invocation directory: {source}" - ) - } - Self::Read { path, source } => { - write!( - formatter, - "failed to read configuration `{}`: {source}", - path.display() - ) - } - Self::Parse { path, source } => { - write!( - formatter, - "failed to parse configuration `{}`: {source}", - path.display() - ) - } - Self::Validation { path, source } => { - write!( - formatter, - "failed to validate configuration `{}`: {source}", - path.display() - ) - } - } - } -} - -impl Error for ConfigLoadError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::CurrentDirectory { source } | Self::Read { source, .. } => Some(source), - Self::Parse { source, .. } => Some(source), - Self::Validation { source, .. } => Some(source.as_ref()), - } - } -} - #[cfg(test)] mod tests { + use std::error::Error; + use super::*; fn unique_temp_path(label: &str) -> PathBuf { diff --git a/src/interpolation.rs b/src/interpolation.rs index 254e448..5dc76fb 100644 --- a/src/interpolation.rs +++ b/src/interpolation.rs @@ -1,8 +1,6 @@ mod resolver; use std::collections::BTreeMap; -use std::error::Error; -use std::fmt; use std::path::{Path, PathBuf}; use directories::{BaseDirs, UserDirs}; @@ -597,129 +595,48 @@ fn evaluate_string_list_variable( .into_string_list(reference.resolver()) } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum InterpolationError { - UnclosedResolver { - offset: usize, - }, - MissingPayloadSeparator { - offset: usize, - }, - NestedResolver { - offset: usize, - }, - UnknownResolver { - name: String, - }, - InvalidResolverPayload { - resolver: String, - payload: String, - }, - ResolverUnavailable { - resolver: String, - }, + #[error("unclosed resolver call at byte {offset}")] + UnclosedResolver { offset: usize }, + #[error("resolver call at byte {offset} is missing the `:` payload separator")] + MissingPayloadSeparator { offset: usize }, + #[error("nested resolver call at byte {offset}")] + NestedResolver { offset: usize }, + #[error("unknown resolver `{name}`")] + UnknownResolver { name: String }, + #[error("invalid payload `{payload}` for resolver `{resolver}`")] + InvalidResolverPayload { resolver: String, payload: String }, + #[error("resolver `{resolver}` is unavailable in this context")] + ResolverUnavailable { resolver: String }, + #[error("resolver `{resolver}` has type {actual:?}, but this context requires {expected:?}")] ResolverTypeMismatch { resolver: String, expected: SchemaType, actual: SchemaType, }, + #[error("resolver `{resolver}` returned {actual:?}, but declared {expected:?}")] ResolverContractViolation { resolver: String, expected: SchemaType, actual: SchemaType, }, - ResolverInLiteralString { - resolver: String, - }, - ListResolverMustOccupyArgument { - resolver: String, - }, - MissingEnvironmentVariable { - name: String, - }, - NonUnicodeEnvironmentVariable { - name: String, - }, - UnavailablePath { - name: String, - }, - NonUnicodePath { - name: String, - }, + #[error("resolver `{resolver}` is not allowed in a literal string")] + ResolverInLiteralString { resolver: String }, + #[error("list resolver `{resolver}` must occupy one complete argument")] + ListResolverMustOccupyArgument { resolver: String }, + #[error("environment variable `{name}` is not defined")] + MissingEnvironmentVariable { name: String }, + #[error("environment variable `{name}` is not Unicode")] + NonUnicodeEnvironmentVariable { name: String }, + #[error("path value `{name}` is unavailable")] + UnavailablePath { name: String }, + #[error("path value `{name}` is not Unicode")] + NonUnicodePath { name: String }, + #[error("package resolver requires a provider package batch")] MissingPackageContext, } -impl fmt::Display for InterpolationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnclosedResolver { offset } => { - write!(formatter, "unclosed resolver call at byte {offset}") - } - Self::MissingPayloadSeparator { offset } => write!( - formatter, - "resolver call at byte {offset} is missing the `:` payload separator" - ), - Self::NestedResolver { offset } => { - write!(formatter, "nested resolver call at byte {offset}") - } - Self::UnknownResolver { name } => write!(formatter, "unknown resolver `{name}`"), - Self::InvalidResolverPayload { resolver, payload } => { - write!( - formatter, - "invalid payload `{payload}` for resolver `{resolver}`" - ) - } - Self::ResolverUnavailable { resolver } => { - write!( - formatter, - "resolver `{resolver}` is unavailable in this context" - ) - } - Self::ResolverTypeMismatch { - resolver, - expected, - actual, - } => write!( - formatter, - "resolver `{resolver}` has type {actual:?}, but this context requires {expected:?}" - ), - Self::ResolverContractViolation { - resolver, - expected, - actual, - } => write!( - formatter, - "resolver `{resolver}` returned {actual:?}, but declared {expected:?}" - ), - Self::ResolverInLiteralString { resolver } => write!( - formatter, - "resolver `{resolver}` is not allowed in a literal string" - ), - Self::ListResolverMustOccupyArgument { resolver } => write!( - formatter, - "list resolver `{resolver}` must occupy one complete argument" - ), - Self::MissingEnvironmentVariable { name } => { - write!(formatter, "environment variable `{name}` is not defined") - } - Self::NonUnicodeEnvironmentVariable { name } => { - write!(formatter, "environment variable `{name}` is not Unicode") - } - Self::UnavailablePath { name } => { - write!(formatter, "path value `{name}` is unavailable") - } - Self::NonUnicodePath { name } => { - write!(formatter, "path value `{name}` is not Unicode") - } - Self::MissingPackageContext => { - formatter.write_str("package resolver requires a provider package batch") - } - } - } -} - -impl Error for InterpolationError {} - #[cfg(test)] mod tests { use std::collections::BTreeMap; diff --git a/src/job.rs b/src/job.rs index 4c16eb2..38b7556 100644 --- a/src/job.rs +++ b/src/job.rs @@ -1,5 +1,4 @@ use std::collections::BTreeSet; -use std::error::Error; use std::fmt; use std::str::FromStr; @@ -96,40 +95,18 @@ impl FromStr for JobSelector { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum JobSelectorParseError { + #[error("a job selector must have a kind followed by ':'")] MissingKind, - InvalidIdentifier(SelectorIdentifierError), + #[error("invalid job selector identifier: {0}")] + InvalidIdentifier(#[source] SelectorIdentifierError), + #[error("unknown job selector kind '{0}'")] UnknownKind(String), + #[error("provider jobs cannot be selected directly")] ProviderNotSelectable, } -impl fmt::Display for JobSelectorParseError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MissingKind => { - formatter.write_str("a job selector must have a kind followed by ':'") - } - Self::InvalidIdentifier(error) => { - write!(formatter, "invalid job selector identifier: {error}") - } - Self::UnknownKind(kind) => write!(formatter, "unknown job selector kind '{kind}'"), - Self::ProviderNotSelectable => { - formatter.write_str("provider jobs cannot be selected directly") - } - } - } -} - -impl Error for JobSelectorParseError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::InvalidIdentifier(error) => Some(error), - Self::MissingKind | Self::UnknownKind(_) | Self::ProviderNotSelectable => None, - } - } -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum JobSelection { All, diff --git a/src/link.rs b/src/link.rs index 5c55f89..50d818f 100644 --- a/src/link.rs +++ b/src/link.rs @@ -1,4 +1,3 @@ -use std::error::Error; use std::ffi::OsString; use std::fmt; use std::fs; @@ -114,7 +113,7 @@ fn prepare_link(link: &PlannedLink, target: PathBuf) -> Result, SourceKind::Directory } else { return Err(LinkError::UnsupportedSourceType { - source: link.source().to_owned(), + path: link.source().to_owned(), }); }; let source = fs::canonicalize(link.source()) @@ -346,16 +345,17 @@ fn paths_equivalent(left: &Path, right: &Path) -> bool { .eq_ignore_ascii_case(&right.to_string_lossy()) } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum LinkError { Io { operation: &'static str, path: PathBuf, + #[source] source: io::Error, diagnostic_operation: Option, }, UnsupportedSourceType { - source: PathBuf, + path: PathBuf, }, ExistingNonLink { target: PathBuf, @@ -426,10 +426,10 @@ impl fmt::Display for LinkError { "failed to {operation} `{}`: {source}", path.display() ), - Self::UnsupportedSourceType { source } => write!( + Self::UnsupportedSourceType { path } => write!( formatter, "link source `{}` is not a file or directory", - source.display() + path.display() ), Self::ExistingNonLink { target } => write!( formatter, @@ -476,39 +476,15 @@ impl fmt::Display for LinkError { } } -impl Error for LinkError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Io { source, .. } => Some(source), - Self::UnsupportedSourceType { .. } - | Self::ExistingNonLink { .. } - | Self::Conflict { .. } - | Self::InvalidTarget { .. } - | Self::ParentNotDirectory { .. } - | Self::VerificationMismatch { .. } => None, - } - } -} - -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum LinkPhaseError { + #[error( + "links {links:?} resolve to the same target `{}`", + .target.display() + )] DuplicateTarget { target: PathBuf, links: Vec }, } -impl fmt::Display for LinkPhaseError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::DuplicateTarget { target, links } => write!( - formatter, - "links {links:?} resolve to the same target `{}`", - target.display() - ), - } - } -} - -impl Error for LinkPhaseError {} - #[cfg(test)] mod tests { use super::*; diff --git a/src/manifest.rs b/src/manifest.rs index 7d6025c..b8e14ea 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1,5 +1,4 @@ use std::collections::BTreeMap; -use std::error::Error; use std::fmt; use crate::platform::PlatformInfo; @@ -379,7 +378,7 @@ fn select_target<'a>( } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum ManifestError { NoCompatibleTargets { available: Vec, @@ -460,5 +459,3 @@ impl fmt::Display for ManifestError { } } } - -impl Error for ManifestError {} diff --git a/src/plan.rs b/src/plan.rs index 9890b4a..fc1b199 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -1,6 +1,4 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::error::Error; -use std::fmt; use std::path::{Path, PathBuf}; use crate::action::{CommandPreparationError, ExecutionEnvironment}; @@ -809,160 +807,60 @@ fn resolve_ensure( } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum PlanningError { - UnknownProvider { - package: String, - provider: String, - }, + #[error( + "selected job `package:{package}` field `provider` references unknown provider `{provider}`" + )] + UnknownProvider { package: String, provider: String }, + #[error("failed to resolve {context}: {source}")] Interpolation { context: String, + #[source] source: InterpolationError, }, + #[error("failed to apply selected job `provider:{provider}` field `activate`: {source}")] EnvironmentPatch { provider: String, + #[source] source: CommandPreparationError, }, + #[error( + "selected job `package:{package}` field `provider.install.args` from provider `{provider}` must contain exactly one `${{package:provider_args}}` argument for nonempty provider_args; found {actual}" + )] ProviderArgsResolverCount { package: String, provider: String, actual: usize, }, - EmptyPackageBatch { - package: String, - }, - DuplicatePackageBatchName { - package: String, - name: String, - }, - RelativeLinkTarget { - link: String, - target: PathBuf, - }, -} - -impl fmt::Display for PlanningError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnknownProvider { package, provider } => { - write!( - formatter, - "selected job `package:{package}` field `provider` references unknown provider `{provider}`" - ) - } - Self::Interpolation { context, source } => { - write!(formatter, "failed to resolve {context}: {source}") - } - Self::EnvironmentPatch { provider, source } => { - write!( - formatter, - "failed to apply selected job `provider:{provider}` field `activate`: {source}" - ) - } - Self::ProviderArgsResolverCount { - package, - provider, - actual, - } => write!( - formatter, - "selected job `package:{package}` field `provider.install.args` from provider `{provider}` must contain exactly one `${{package:provider_args}}` argument for nonempty provider_args; found {actual}" - ), - Self::EmptyPackageBatch { package } => { - write!( - formatter, - "selected job `package:{package}` field `names` must contain at least one name" - ) - } - Self::DuplicatePackageBatchName { package, name } => write!( - formatter, - "selected job `package:{package}` field `names` contains duplicate name `{name}`" - ), - Self::RelativeLinkTarget { link, target } => write!( - formatter, - "selected job `link:{link}` field `target` must be absolute after interpolation: `{}`", - target.display() - ), - } - } -} - -impl Error for PlanningError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::UnknownProvider { .. } => None, - Self::Interpolation { source, .. } => Some(source), - Self::EnvironmentPatch { source, .. } => Some(source), - Self::ProviderArgsResolverCount { .. } => None, - Self::EmptyPackageBatch { .. } => None, - Self::DuplicatePackageBatchName { .. } => None, - Self::RelativeLinkTarget { .. } => None, - } - } -} - -#[derive(Debug)] + #[error("selected job `package:{package}` field `names` must contain at least one name")] + EmptyPackageBatch { package: String }, + #[error("selected job `package:{package}` field `names` contains duplicate name `{name}`")] + DuplicatePackageBatchName { package: String, name: String }, + #[error( + "selected job `link:{link}` field `target` must be absolute after interpolation: `{}`", + .target.display() + )] + RelativeLinkTarget { link: String, target: PathBuf }, +} + +#[derive(Debug, thiserror::Error)] pub enum JobSelectionError { + #[error("unknown job `{0}`")] Unknown(JobSelector), + + #[error("package job `{package}` references missing provider job `{provider}`")] MissingProvider { package: SelectorIdentifier, provider: Identifier, }, } -impl fmt::Display for JobSelectionError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Unknown(JobSelector::Package(id)) => { - write!(formatter, "unknown package job `{id}`") - } - Self::Unknown(JobSelector::Action(id)) => { - write!(formatter, "unknown action job `{id}`") - } - Self::Unknown(JobSelector::Link(id)) => { - write!(formatter, "unknown link job `{id}`") - } - Self::MissingProvider { package, provider } => write!( - formatter, - "package job `{package}` references missing provider job `{provider}`" - ), - } - } -} - -impl Error for JobSelectionError {} - -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ExecutionPlanError { - Selection(JobSelectionError), - Planning(PlanningError), -} + #[error("{0}")] + Selection(#[from] JobSelectionError), -impl fmt::Display for ExecutionPlanError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Selection(source) => source.fmt(formatter), - Self::Planning(source) => source.fmt(formatter), - } - } -} - -impl Error for ExecutionPlanError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Selection(source) => Some(source), - Self::Planning(source) => Some(source), - } - } -} - -impl From for ExecutionPlanError { - fn from(source: JobSelectionError) -> Self { - Self::Selection(source) - } -} - -impl From for ExecutionPlanError { - fn from(source: PlanningError) -> Self { - Self::Planning(source) - } + #[error("{0}")] + Planning(#[from] PlanningError), } diff --git a/src/provider.rs b/src/provider.rs index 2c6f75d..d00a9ea 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -1,4 +1,3 @@ -use std::error::Error; use std::fmt; use crate::action::{ @@ -323,20 +322,27 @@ impl<'a> ProviderRunner<'a> { } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ProviderError { + #[error("failed to apply provider {stage}: {source}")] Environment { stage: ProviderStage, + #[source] source: CommandPreparationError, }, + #[error("failed to prepare provider {stage}: {source}")] Preparation { stage: ProviderStage, + #[source] source: CommandPreparationError, }, + #[error("failed to execute provider {stage}: {source}")] Execution { stage: ProviderStage, + #[source] source: ExecutionError, }, + #[error("provider {stage} returned {}", .result.status())] UnsuccessfulExit { stage: ProviderStage, result: ExecutionResult, @@ -365,40 +371,21 @@ impl ProviderError { } } -impl fmt::Display for ProviderError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Environment { stage, source } => { - write!(formatter, "failed to apply provider {stage}: {source}") - } - Self::Preparation { stage, source } => { - write!(formatter, "failed to prepare provider {stage}: {source}") - } - Self::Execution { stage, source } => { - write!(formatter, "failed to execute provider {stage}: {source}") - } - Self::UnsuccessfulExit { stage, result } => { - write!(formatter, "provider {stage} returned {}", result.status()) - } - } - } -} - -impl Error for ProviderError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Environment { source, .. } | Self::Preparation { source, .. } => Some(source), - Self::Execution { source, .. } => Some(source), - Self::UnsuccessfulExit { .. } => None, - } - } -} - -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ProviderInstallError { + #[error("provider install expects provider `{expected}`, but received status for `{actual}`")] ProviderMismatch { expected: String, actual: String }, - Preparation { source: CommandPreparationError }, - Execution { source: ExecutionError }, + #[error("failed to prepare provider install: {source}")] + Preparation { + #[source] + source: CommandPreparationError, + }, + #[error("failed to execute provider install: {source}")] + Execution { + #[source] + source: ExecutionError, + }, + #[error("provider install returned {}", .result.status())] UnsuccessfulExit { result: ExecutionResult }, } @@ -412,36 +399,3 @@ impl ProviderInstallError { } } } - -impl fmt::Display for ProviderInstallError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ProviderMismatch { expected, actual } => { - write!( - formatter, - "provider install expects provider `{expected}`, but received status for `{actual}`" - ) - } - Self::Preparation { source } => { - write!(formatter, "failed to prepare provider install: {source}") - } - Self::Execution { source } => { - write!(formatter, "failed to execute provider install: {source}") - } - Self::UnsuccessfulExit { result } => { - write!(formatter, "provider install returned {}", result.status()) - } - } - } -} - -impl Error for ProviderInstallError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::ProviderMismatch { .. } => None, - Self::Preparation { source } => Some(source), - Self::Execution { source } => Some(source), - Self::UnsuccessfulExit { .. } => None, - } - } -} diff --git a/src/validation.rs b/src/validation.rs index 0850753..393bd83 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -1,5 +1,9 @@ +#![expect( + clippy::result_large_err, + reason = "direct typed error sources preserve Error::source downcasts" +)] + use std::collections::BTreeSet; -use std::error::Error; use std::fmt; use crate::interpolation::{ @@ -32,74 +36,38 @@ impl fmt::Display for ConfigValidationJob { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum ConfigValidationErrorKind { - Expression(InterpolationError), + #[error("{0}")] + Expression(#[source] InterpolationError), + #[error("package `{package}` references unknown provider `{provider}`")] UnknownProvider { package: SelectorIdentifier, provider: Identifier, }, - EmptyPackageBatch { - package: SelectorIdentifier, - }, + #[error("package batch `{package}` must contain at least one name")] + EmptyPackageBatch { package: SelectorIdentifier }, + #[error("package batch `{package}` contains duplicate name `{name}`")] DuplicatePackageBatchName { package: SelectorIdentifier, name: Identifier, }, - ProviderArgsResolverCount { - provider: Identifier, - actual: usize, - }, - Manifest(ManifestError), -} - -impl fmt::Display for ConfigValidationErrorKind { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Expression(source) => source.fmt(formatter), - Self::UnknownProvider { package, provider } => write!( - formatter, - "package `{package}` references unknown provider `{provider}`" - ), - Self::EmptyPackageBatch { package } => { - write!( - formatter, - "package batch `{package}` must contain at least one name" - ) - } - Self::DuplicatePackageBatchName { package, name } => write!( - formatter, - "package batch `{package}` contains duplicate name `{name}`" - ), - Self::ProviderArgsResolverCount { provider, actual } => write!( - formatter, - "provider `{provider}` install must contain exactly one `${{package:provider_args}}` argument for an install unit with nonempty provider_args; found {actual}" - ), - Self::Manifest(source) => source.fmt(formatter), - } - } + #[error( + "provider `{provider}` install must contain exactly one `${{package:provider_args}}` argument for an install unit with nonempty provider_args; found {actual}" + )] + ProviderArgsResolverCount { provider: Identifier, actual: usize }, + #[error("{0}")] + Manifest(#[source] ManifestError), } -impl Error for ConfigValidationErrorKind { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Expression(source) => Some(source), - Self::Manifest(source) => Some(source), - Self::UnknownProvider { .. } - | Self::EmptyPackageBatch { .. } - | Self::DuplicatePackageBatchName { .. } - | Self::ProviderArgsResolverCount { .. } => None, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub struct ConfigValidationError { pub target: SelectorIdentifier, pub profile: Option, pub job: Option, pub field: Option, - pub kind: Box, + #[source] + pub kind: ConfigValidationErrorKind, } impl fmt::Display for ConfigValidationError { @@ -118,12 +86,6 @@ impl fmt::Display for ConfigValidationError { } } -impl Error for ConfigValidationError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - Some(self.kind.as_ref()) - } -} - #[derive(Clone)] struct ValidationContext { target: SelectorIdentifier, @@ -166,7 +128,7 @@ impl ValidationContext { profile: self.profile.clone(), job: self.job.clone(), field: field.map(Into::into), - kind: Box::new(kind), + kind, } } diff --git a/tests/action_runner.rs b/tests/action_runner.rs index 91c029b..5874694 100644 --- a/tests/action_runner.rs +++ b/tests/action_runner.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::env; +use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; use std::process; @@ -183,6 +184,7 @@ fn rejects_an_initial_check_exit_other_than_zero_or_one() { error.exit_result().and_then(|result| result.code()), Some(23) ); + assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["check"]); } @@ -203,6 +205,7 @@ fn stops_before_post_check_when_exec_fails() { error.exit_result().and_then(|result| result.code()), Some(17) ); + assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["check", "exec"]); } @@ -223,6 +226,7 @@ fn fails_when_post_check_is_not_satisfied() { error.exit_result().and_then(|result| result.code()), Some(1) ); + assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["check", "exec", "check"]); } diff --git a/tests/apply_command.rs b/tests/apply_command.rs index c6a1bbb..160ad4f 100644 --- a/tests/apply_command.rs +++ b/tests/apply_command.rs @@ -192,7 +192,7 @@ fn exact_apply_rejects_an_unknown_selector_atomically() { assert_eq!(output.status.code(), Some(1)); assert!(output.stdout.is_empty(), "{:?}", output.stdout); assert!( - String::from_utf8_lossy(&output.stderr).contains("unknown action job `unknown`"), + String::from_utf8_lossy(&output.stderr).contains("unknown job `action:unknown`"), "{}", String::from_utf8_lossy(&output.stderr) ); diff --git a/tests/config.rs b/tests/config.rs index 6e2a8d1..a217369 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -89,7 +89,7 @@ fn reports_the_absolute_path_when_the_complete_manifest_is_invalid() { ConfigLoadError::Validation { path, source } => { assert_eq!(path, &expected_path); assert!(matches!( - source.kind.as_ref(), + &source.kind, ConfigValidationErrorKind::Manifest(ManifestError::DuplicateProfile { target, profile, diff --git a/tests/dry_run_command.rs b/tests/dry_run_command.rs index be7e920..97984ec 100644 --- a/tests/dry_run_command.rs +++ b/tests/dry_run_command.rs @@ -233,7 +233,7 @@ fn exact_dry_run_rejects_an_unknown_selector_atomically() { assert_eq!(output.status.code(), Some(1)); assert!(output.stdout.is_empty(), "{:?}", output.stdout); assert!( - String::from_utf8_lossy(&output.stderr).contains("unknown action job `unknown`"), + String::from_utf8_lossy(&output.stderr).contains("unknown job `action:unknown`"), "{}", String::from_utf8_lossy(&output.stderr) ); diff --git a/tests/error_contract.rs b/tests/error_contract.rs new file mode 100644 index 0000000..8e3cc6b --- /dev/null +++ b/tests/error_contract.rs @@ -0,0 +1,528 @@ +use std::env; +use std::error::Error; +use std::ffi::OsString; +use std::io; +use std::path::PathBuf; + +use dot::action::{CommandPreparationError, ExecutionError}; +use dot::action_runner::{ActionRunError, ActionStage}; +use dot::check::ProviderCheckError; +use dot::config::{ConfigDiscoveryError, ConfigLoadError}; +use dot::diagnostic::Operation; +use dot::interpolation::InterpolationError; +use dot::job::{JobSelector, JobSelectorParseError}; +use dot::link::{LinkError, LinkPhaseError}; +use dot::manifest::ManifestError; +use dot::plan::{JobSelectionError, PlanningError}; +use dot::platform::PlatformInfo; +use dot::provider::{ProviderError, ProviderInstallError, ProviderStage}; +use dot::schema::{ + Identifier, OneOrMany, PlatformConstraint, SchemaType, SelectorIdentifier, + SelectorIdentifierError, +}; +use dot::validation::{ConfigValidationError, ConfigValidationErrorKind, ConfigValidationJob}; + +fn assert_no_source(error: &(dyn Error + 'static)) { + assert!( + error.source().is_none(), + "expected `{error}` to have no source" + ); +} + +fn assert_source_is<'a, T: Error + 'static>(error: &'a (dyn Error + 'static)) -> &'a T { + let source = error + .source() + .unwrap_or_else(|| panic!("expected `{error}` to have an immediate source")); + source.downcast_ref::().unwrap_or_else(|| { + panic!( + "expected immediate source of `{error}` to be `{}`, but it was `{source}`", + std::any::type_name::() + ) + }) +} + +fn io_error() -> io::Error { + io::Error::other("test I/O failure") +} + +fn join_paths_error() -> env::JoinPathsError { + #[cfg(not(windows))] + let invalid_path = "invalid:path"; + #[cfg(windows)] + let invalid_path = "invalid\"path"; + + env::join_paths([invalid_path]).expect_err("test path should be invalid in PATH") +} + +fn toml_error() -> toml::de::Error { + toml::from_str::("invalid = [") + .expect_err("test TOML should be syntactically invalid") +} + +fn identifier(value: &str) -> Identifier { + Identifier::new(value).expect("test identifier should be valid") +} + +fn selector_identifier(value: &str) -> SelectorIdentifier { + SelectorIdentifier::new(value).expect("test selector identifier should be valid") +} + +fn selector_identifier_error() -> SelectorIdentifierError { + SelectorIdentifier::new("").expect_err("empty selector identifier should be invalid") +} + +fn interpolation_error() -> InterpolationError { + InterpolationError::UnclosedResolver { offset: 0 } +} + +fn preparation_error() -> CommandPreparationError { + CommandPreparationError::InvalidPathEnvironment { + source: join_paths_error(), + } +} + +fn execution_error() -> ExecutionError { + ExecutionError::Spawn { + program: OsString::from("test-command"), + source: io_error(), + } +} + +fn validation_error(kind: ConfigValidationErrorKind) -> ConfigValidationError { + ConfigValidationError { + target: selector_identifier("target"), + profile: Some(selector_identifier("profile")), + job: Some(ConfigValidationJob::Provider(identifier("provider"))), + field: Some("field".to_owned()), + kind, + } +} + +#[test] +fn command_preparation_error_exposes_join_paths_error() { + assert_source_is::(&preparation_error()); +} + +#[test] +fn execution_errors_expose_io_errors() { + let errors = [ + ExecutionError::Spawn { + program: OsString::from("spawn"), + source: io_error(), + }, + ExecutionError::Wait { + program: OsString::from("wait"), + source: io_error(), + }, + ]; + + for error in &errors { + assert_source_is::(error); + } +} + +#[test] +fn action_run_errors_expose_their_wrapper_errors() { + let preparation = ActionRunError::Preparation { + stage: ActionStage::Exec, + source: preparation_error(), + }; + let execution = ActionRunError::Execution { + stage: ActionStage::Exec, + source: execution_error(), + }; + + assert_source_is::(&preparation); + assert_source_is::(&execution); +} + +#[test] +fn provider_check_errors_expose_their_wrapper_errors() { + let errors = [ + ProviderCheckError::ActivateInterpolation(interpolation_error()), + ProviderCheckError::ProbeInterpolation(interpolation_error()), + ]; + for error in &errors { + assert_source_is::(error); + } + + let errors = [ + ProviderCheckError::ActivatePreparation(preparation_error()), + ProviderCheckError::ProbePreparation(preparation_error()), + ]; + for error in &errors { + assert_source_is::(error); + } + + let converted = ProviderCheckError::from(execution_error()); + assert!(matches!(&converted, ProviderCheckError::Execution(_))); + assert_source_is::(&converted); +} + +#[test] +fn config_discovery_errors_expose_io_errors() { + let errors = [ + ConfigDiscoveryError::CurrentDirectory { source: io_error() }, + ConfigDiscoveryError::Inspect { + path: PathBuf::from("candidate"), + source: io_error(), + }, + ]; + + for error in &errors { + assert_source_is::(error); + } +} + +#[test] +fn config_load_errors_expose_their_immediate_errors() { + let current_directory = ConfigLoadError::CurrentDirectory { source: io_error() }; + let read = ConfigLoadError::Read { + path: PathBuf::from("dot.toml"), + source: io_error(), + }; + let parse = ConfigLoadError::Parse { + path: PathBuf::from("dot.toml"), + source: toml_error(), + }; + + assert_source_is::(¤t_directory); + assert_source_is::(&read); + assert_source_is::(&parse); +} + +#[test] +fn config_load_validation_preserves_all_three_immediate_source_layers() { + let error = ConfigLoadError::Validation { + path: PathBuf::from("dot.toml"), + source: validation_error(ConfigValidationErrorKind::Expression(interpolation_error())), + }; + + let validation = assert_source_is::(&error); + let kind = assert_source_is::(validation); + assert_source_is::(kind); +} + +#[test] +fn selector_parse_error_exposes_selector_identifier_error() { + let error = JobSelectorParseError::InvalidIdentifier(selector_identifier_error()); + + let source = assert_source_is::(&error); + assert_no_source(source); +} + +#[test] +fn link_io_error_exposes_io_error() { + let error = LinkError::Io { + operation: "inspect", + path: PathBuf::from("target"), + source: io_error(), + diagnostic_operation: Some(Operation::CreateSymbolicLink), + }; + + assert_source_is::(&error); +} + +#[test] +fn planning_errors_expose_their_wrapper_errors() { + let interpolation = PlanningError::Interpolation { + context: "test field".to_owned(), + source: interpolation_error(), + }; + let environment_patch = PlanningError::EnvironmentPatch { + provider: "provider".to_owned(), + source: preparation_error(), + }; + + assert_source_is::(&interpolation); + assert_source_is::(&environment_patch); +} + +#[test] +fn provider_errors_expose_their_wrapper_errors() { + let environment = ProviderError::Environment { + stage: ProviderStage::Activate, + source: preparation_error(), + }; + let preparation = ProviderError::Preparation { + stage: ProviderStage::InitialProbe, + source: preparation_error(), + }; + let execution = ProviderError::Execution { + stage: ProviderStage::InitialProbe, + source: execution_error(), + }; + + assert_source_is::(&environment); + assert_source_is::(&preparation); + assert_source_is::(&execution); +} + +#[test] +fn provider_install_errors_expose_their_wrapper_errors() { + let preparation = ProviderInstallError::Preparation { + source: preparation_error(), + }; + let execution = ProviderInstallError::Execution { + source: execution_error(), + }; + + assert_source_is::(&preparation); + assert_source_is::(&execution); +} + +#[test] +fn validation_error_kinds_expose_their_wrapper_errors() { + let expression = ConfigValidationErrorKind::Expression(interpolation_error()); + let manifest = ConfigValidationErrorKind::Manifest(ManifestError::NoCompatibleTargets { + available: Vec::new(), + }); + + assert_source_is::(&expression); + assert_source_is::(&manifest); +} + +#[test] +fn validation_error_exposes_its_kind() { + let error = validation_error(ConfigValidationErrorKind::EmptyPackageBatch { + package: selector_identifier("package"), + }); + + assert_source_is::(&error); +} + +#[test] +fn every_interpolation_error_has_no_source() { + let errors = [ + InterpolationError::UnclosedResolver { offset: 0 }, + InterpolationError::MissingPayloadSeparator { offset: 0 }, + InterpolationError::NestedResolver { offset: 0 }, + InterpolationError::UnknownResolver { + name: "resolver".to_owned(), + }, + InterpolationError::InvalidResolverPayload { + resolver: "resolver".to_owned(), + payload: "payload".to_owned(), + }, + InterpolationError::ResolverUnavailable { + resolver: "resolver".to_owned(), + }, + InterpolationError::ResolverTypeMismatch { + resolver: "resolver".to_owned(), + expected: SchemaType::String, + actual: SchemaType::Integer, + }, + InterpolationError::ResolverContractViolation { + resolver: "resolver".to_owned(), + expected: SchemaType::String, + actual: SchemaType::Integer, + }, + InterpolationError::ResolverInLiteralString { + resolver: "resolver".to_owned(), + }, + InterpolationError::ListResolverMustOccupyArgument { + resolver: "resolver".to_owned(), + }, + InterpolationError::MissingEnvironmentVariable { + name: "VARIABLE".to_owned(), + }, + InterpolationError::NonUnicodeEnvironmentVariable { + name: "VARIABLE".to_owned(), + }, + InterpolationError::UnavailablePath { + name: "path".to_owned(), + }, + InterpolationError::NonUnicodePath { + name: "path".to_owned(), + }, + InterpolationError::MissingPackageContext, + ]; + + for error in &errors { + assert_no_source(error); + } +} + +#[test] +fn source_less_selector_errors_have_no_source() { + let errors = [ + JobSelectorParseError::MissingKind, + JobSelectorParseError::UnknownKind("unknown".to_owned()), + JobSelectorParseError::ProviderNotSelectable, + ]; + + for error in &errors { + assert_no_source(error); + } +} + +#[test] +fn source_less_link_errors_have_no_source() { + let errors = [ + LinkError::UnsupportedSourceType { + path: PathBuf::from("source"), + }, + LinkError::ExistingNonLink { + target: PathBuf::from("target"), + }, + LinkError::Conflict { + target: PathBuf::from("target"), + destination: PathBuf::from("destination"), + }, + LinkError::InvalidTarget { + target: PathBuf::from("target"), + }, + LinkError::ParentNotDirectory { + parent: PathBuf::from("parent"), + }, + LinkError::VerificationMismatch { + target: PathBuf::from("target"), + expected: PathBuf::from("expected"), + actual: Some(PathBuf::from("actual")), + }, + ]; + + for error in &errors { + assert_no_source(error); + } + + assert_no_source(&LinkPhaseError::DuplicateTarget { + target: PathBuf::from("target"), + links: vec!["first".to_owned(), "second".to_owned()], + }); +} + +#[test] +fn every_manifest_error_has_no_source() { + let errors = [ + ManifestError::NoCompatibleTargets { + available: Vec::new(), + }, + ManifestError::TargetRequired { + available: vec!["target".to_owned()], + }, + ManifestError::UnknownTarget { + requested: "requested".to_owned(), + available: vec!["target".to_owned()], + }, + ManifestError::IncompatiblePlatform { + target: "target".to_owned(), + expected: Box::new(PlatformConstraint { + os: OneOrMany::One(identifier("test-os")), + arch: None, + distro: None, + distro_family: None, + environment: None, + }), + actual: Box::new(PlatformInfo::detect()), + }, + ManifestError::DuplicateProfile { + target: "target".to_owned(), + profile: "profile".to_owned(), + first_path: "first".to_owned(), + second_path: "second".to_owned(), + }, + ManifestError::UnknownProfile { + target: "target".to_owned(), + requested: "requested".to_owned(), + available: vec!["profile".to_owned()], + }, + ]; + + for error in &errors { + assert_no_source(error); + } +} + +#[test] +fn source_less_planning_errors_have_no_source() { + let errors = [ + PlanningError::UnknownProvider { + package: "package".to_owned(), + provider: "provider".to_owned(), + }, + PlanningError::ProviderArgsResolverCount { + package: "package".to_owned(), + provider: "provider".to_owned(), + actual: 0, + }, + PlanningError::EmptyPackageBatch { + package: "package".to_owned(), + }, + PlanningError::DuplicatePackageBatchName { + package: "package".to_owned(), + name: "name".to_owned(), + }, + PlanningError::RelativeLinkTarget { + link: "link".to_owned(), + target: PathBuf::from("relative"), + }, + ]; + + for error in &errors { + assert_no_source(error); + } +} + +#[test] +fn job_selection_errors_have_no_source() { + let errors = [ + JobSelectionError::Unknown(JobSelector::Package(selector_identifier("package"))), + JobSelectionError::MissingProvider { + package: selector_identifier("package"), + provider: identifier("provider"), + }, + ]; + + for error in &errors { + assert_no_source(error); + } +} + +#[test] +fn provider_mismatch_has_no_source() { + assert_no_source(&ProviderInstallError::ProviderMismatch { + expected: "expected".to_owned(), + actual: "actual".to_owned(), + }); +} + +#[test] +fn source_less_config_discovery_errors_have_no_source() { + let errors = [ + ConfigDiscoveryError::UserDirectoryUnavailable, + ConfigDiscoveryError::NotFound { + local: PathBuf::from("local"), + user: PathBuf::from("user"), + }, + ]; + + for error in &errors { + assert_no_source(error); + } +} + +#[test] +fn source_less_validation_error_kinds_have_no_source() { + let errors = [ + ConfigValidationErrorKind::UnknownProvider { + package: selector_identifier("package"), + provider: identifier("provider"), + }, + ConfigValidationErrorKind::EmptyPackageBatch { + package: selector_identifier("package"), + }, + ConfigValidationErrorKind::DuplicatePackageBatchName { + package: selector_identifier("package"), + name: identifier("name"), + }, + ConfigValidationErrorKind::ProviderArgsResolverCount { + provider: identifier("provider"), + actual: 0, + }, + ]; + + for error in &errors { + assert_no_source(error); + } +} diff --git a/tests/job.rs b/tests/job.rs index 0baae12..70e3b30 100644 --- a/tests/job.rs +++ b/tests/job.rs @@ -1,6 +1,7 @@ mod support; use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; use std::path::Path; use dot::action::ExecutionEnvironment; @@ -197,6 +198,35 @@ fn job_ids_display_the_canonical_spelling() { } } +#[test] +fn job_selection_errors_use_canonical_job_selectors() { + let cases = [ + ( + JobSelectionError::Unknown(JobSelector::Package(selector_id("tool"))), + "unknown job `package:tool`", + ), + ( + JobSelectionError::Unknown(JobSelector::Action(selector_id("setup"))), + "unknown job `action:setup`", + ), + ( + JobSelectionError::Unknown(JobSelector::Link(selector_id("config"))), + "unknown job `link:config`", + ), + ( + JobSelectionError::MissingProvider { + package: selector_id("tool"), + provider: provider_id("missing"), + }, + "package job `tool` references missing provider job `missing`", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } +} + #[test] fn selected_link_does_not_resolve_an_unselected_action() { let path = fixture::path("selection/valid-selected-runtime-isolation.toml"); @@ -335,6 +365,17 @@ fn unknown_typed_selector_fails_before_planning() { )); } +#[test] +fn execution_plan_error_exposes_its_contained_error_as_the_immediate_source() { + let error = ExecutionPlanError::from(JobSelectionError::Unknown(JobSelector::Action( + selector_id("missing"), + ))); + + let source = Error::source(&error).expect("wrapper error should expose its contained error"); + + assert!(source.is::()); +} + #[test] fn unknown_selector_rejects_the_complete_set_before_runtime_evaluation() { let path = fixture::path("selection/valid-selected-runtime-isolation.toml"); diff --git a/tests/provider.rs b/tests/provider.rs index 024a9c2..86a7a2e 100644 --- a/tests/provider.rs +++ b/tests/provider.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::env; +use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; use std::process; @@ -286,6 +287,7 @@ fn reports_a_failed_probe_when_no_ensure_is_declared() { error.exit_result().and_then(|result| result.code()), Some(1) ); + assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["probe"]); } @@ -316,6 +318,7 @@ fn stops_the_ensure_list_at_the_first_failure() { error.exit_result().and_then(|result| result.code()), Some(19) ); + assert!(error.source().is_none()); assert_eq!( state.recorded_events(), ["probe", "ensure-first", "ensure-fail"] @@ -345,6 +348,7 @@ fn requires_the_final_probe_to_succeed() { error.exit_result().and_then(|result| result.code()), Some(1) ); + assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["probe", "ensure-first", "probe"]); } diff --git a/tests/provider_installs.rs b/tests/provider_installs.rs index 994a168..e19a752 100644 --- a/tests/provider_installs.rs +++ b/tests/provider_installs.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::env; +use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; use std::process; @@ -420,10 +421,14 @@ fn a_failed_install_unit_does_not_stop_an_unrelated_unit() { let execution = runner.install_all(plan.provider_installs(), &readiness); assert_eq!(execution.statuses().len(), 2); + let error = execution.statuses()[0] + .error() + .expect("failed install should retain its error"); assert!(matches!( - execution.statuses()[0].error(), - Some(ProviderInstallError::UnsuccessfulExit { result }) if result.code() == Some(23) + error, + ProviderInstallError::UnsuccessfulExit { result } if result.code() == Some(23) )); + assert!(error.source().is_none()); assert!(matches!( execution.statuses()[1].outcome(), Ok(ProviderInstallOutcome::Executed { install }) if install.code() == Some(0) diff --git a/tests/validation.rs b/tests/validation.rs index 509d8bb..23fee3a 100644 --- a/tests/validation.rs +++ b/tests/validation.rs @@ -34,7 +34,7 @@ fn rejects_a_static_expression_error_in_an_unselected_target() { ); assert_eq!(source.field.as_deref(), Some("exec.program")); assert!(matches!( - source.kind.as_ref(), + &source.kind, ConfigValidationErrorKind::Expression(InterpolationError::UnknownResolver { name }) if name == "unknown" @@ -83,7 +83,7 @@ fn rejects_an_unknown_provider_in_one_effective_profile_atomically() { ); assert_eq!(source.field.as_deref(), Some("provider")); assert!(matches!( - source.kind.as_ref(), + &source.kind, ConfigValidationErrorKind::UnknownProvider { package, provider } if package.as_str() == "tool" && provider.as_str() == "missing" )); @@ -160,7 +160,7 @@ target = "/target" assert_eq!(error.job, Some(expected_job)); assert!( matches!( - error.kind.as_ref(), + &error.kind, ConfigValidationErrorKind::Expression( InterpolationError::UnknownResolver { .. } | InterpolationError::ResolverInLiteralString { .. } @@ -194,7 +194,7 @@ fn validates_package_batch_structure_during_load() { }; assert!( - match source.kind.as_ref() { + match &source.kind { ConfigValidationErrorKind::EmptyPackageBatch { package } => { package.as_str() == expected_package && expected_duplicate.is_none() } @@ -241,7 +241,7 @@ app = { provider = "brew", provider_args = ["--cask"] } )) ); assert!(matches!( - error.kind.as_ref(), + &error.kind, ConfigValidationErrorKind::ProviderArgsResolverCount { provider, actual: 0, @@ -269,7 +269,7 @@ fn requires_one_exact_provider_args_resolver_during_load() { assert!( matches!( - source.kind.as_ref(), + &source.kind, ConfigValidationErrorKind::ProviderArgsResolverCount { provider, actual,