From 6b29f2c2b939e5621da827259583452567596d6f Mon Sep 17 00:00:00 2001 From: alexion Date: Thu, 17 Sep 2026 15:07:14 -0400 Subject: [PATCH] feat(status): add error context --- src/status/status.rs | 24 ++++++++++++++++++++++++ src/status/status_test.rs | 25 ++++++++++++++++++++++++- src/xatu/xatu_main.rs | 14 ++++++++++---- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/status/status.rs b/src/status/status.rs index 2bca061..3bccc12 100644 --- a/src/status/status.rs +++ b/src/status/status.rs @@ -1,5 +1,12 @@ pub type Result = std::result::Result; +pub trait ResultExt { + fn with_context(self, context: F) -> Result + where + C: Into, + F: FnOnce() -> C; +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Code { InvalidArgument, @@ -48,6 +55,10 @@ impl Status { self } + pub fn with_context(self, context: impl Into) -> Self { + Self::new(self.code, context).with_source(self) + } + pub fn code(&self) -> Code { self.code } @@ -82,3 +93,16 @@ impl From for Status { Self::new(code, error.to_string()).with_source(error) } } + +impl ResultExt for std::result::Result +where + E: Into, +{ + fn with_context(self, context: F) -> Result + where + C: Into, + F: FnOnce() -> C, + { + self.map_err(|error| error.into().with_context(context())) + } +} diff --git a/src/status/status_test.rs b/src/status/status_test.rs index 289b8e4..e417b96 100644 --- a/src/status/status_test.rs +++ b/src/status/status_test.rs @@ -1,6 +1,6 @@ use std::error::Error; -use status::{Code, Status}; +use status::{Code, ResultExt, Status}; #[test] fn constructors_set_code_and_message() { @@ -34,6 +34,29 @@ fn with_source_preserves_source_error() { assert_eq!("denied", status.source().unwrap().to_string()); } +#[test] +fn with_context_preserves_code_and_wraps_status() { + let status = Status::not_found("missing input").with_context("failed to load config"); + + assert_eq!(Code::NotFound, status.code()); + assert_eq!("failed to load config", status.message()); + assert_eq!("NotFound: missing input", status.source().unwrap().to_string()); +} + +#[test] +fn result_ext_adds_lazy_context() { + let result: status::Result<()> = Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + )) + .with_context(|| "failed to read input"); + + let status = result.unwrap_err(); + assert_eq!(Code::PermissionDenied, status.code()); + assert_eq!("failed to read input", status.message()); + assert_eq!("PermissionDenied: denied", status.source().unwrap().to_string()); +} + #[test] fn io_error_maps_not_found() { let status = Status::from(std::io::Error::new( diff --git a/src/xatu/xatu_main.rs b/src/xatu/xatu_main.rs index ed90128..24d519e 100644 --- a/src/xatu/xatu_main.rs +++ b/src/xatu/xatu_main.rs @@ -1,4 +1,5 @@ use clap::Parser; +use status::ResultExt; use std::path::{Path, PathBuf}; use xatu::SourceFile; @@ -26,7 +27,8 @@ fn is_excluded_dir(path: &Path) -> bool { } fn collect_source_files(path: &Path) -> status::Result> { - let metadata = std::fs::symlink_metadata(path)?; + let metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("failed to read metadata for {}", path.display()))?; if metadata.file_type().is_dir() { if is_excluded_dir(path) { @@ -35,8 +37,11 @@ fn collect_source_files(path: &Path) -> status::Result> { let mut files = Vec::new(); - for entry in std::fs::read_dir(path)? { - let entry = entry?; + for entry in std::fs::read_dir(path) + .with_context(|| format!("failed to read directory {}", path.display()))? + { + let entry = + entry.with_context(|| format!("failed to read entry in {}", path.display()))?; files.extend(collect_source_files(&entry.path())?); } @@ -46,7 +51,8 @@ fn collect_source_files(path: &Path) -> status::Result> { if metadata.file_type().is_file() && is_nix_file(path) { return Ok(vec![SourceFile { path: path.to_path_buf(), - source: std::fs::read_to_string(path)?, + source: std::fs::read_to_string(path) + .with_context(|| format!("failed to read source file {}", path.display()))?, }]); }