注册表错误带上底层错误并实现 source

export/registry.rs:新增 RegistryErrorCause,io/serde 失败保留原始错误类型(拿不到类型时用 Message 兜底),RegistryError 与 Cause 都实现 std::error::Error::source,Display 文案不变。
export/draft/xhs_minitool/error.rs:只在命令边界把 cause 转成 String,配合新增测试锁定 io::ErrorKind 可透出。
This commit is contained in:
2026-10-06 21:33:58 +08:00
parent 24dfbd4a55
commit 00b8d12d98
2 changed files with 122 additions and 12 deletions
@@ -192,7 +192,9 @@ macro_rules! impl_from_registry_error {
fn from(error: crate::export::RegistryError) -> Self {
match error {
crate::export::RegistryError::Malformed { cause, .. } => {
Self::RegistryMalformed(RegistryMalformed { cause })
Self::RegistryMalformed(RegistryMalformed {
cause: cause.to_string(),
})
}
other => Self::ExportUnavailable(ExportUnavailable::new(other.to_string())),
}
@@ -24,15 +24,103 @@ pub(crate) struct Loaded<T> {
pub(crate) created: bool,
}
/// 注册表失败的底层现场。
///
/// 能拿到底层错误类型时(`io` / `serde`)就把类型带出来,这样 Rust 侧调用方可以按
/// `std::io::ErrorKind` 或 serde 错误分流,日志也能沿 `source()` 追到根因;只有本模块自己拼
/// 出来的一句话(例如「注册表路径缺少父目录」)才退化成 [`RegistryErrorCause::Message`]。
/// `context` 是给原始错误补的中文说明,只影响展示,不影响类型。
#[derive(Debug)]
pub(crate) enum RegistryErrorCause {
Io {
context: Option<&'static str>,
error: std::io::Error,
},
Serde {
context: Option<&'static str>,
error: serde_json::Error,
},
Message(String),
}
impl RegistryErrorCause {
fn message(message: impl Into<String>) -> Self {
Self::Message(message.into())
}
fn io(error: std::io::Error) -> Self {
Self::Io {
context: None,
error,
}
}
fn io_with_context(context: &'static str, error: std::io::Error) -> Self {
Self::Io {
context: Some(context),
error,
}
}
fn serde(error: serde_json::Error) -> Self {
Self::Serde {
context: None,
error,
}
}
}
impl fmt::Display for RegistryErrorCause {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { context, error } => match context {
Some(context) => write!(formatter, "{context}:{error}"),
None => write!(formatter, "{error}"),
},
Self::Serde { context, error } => match context {
Some(context) => write!(formatter, "{context}:{error}"),
None => write!(formatter, "{error}"),
},
Self::Message(message) => formatter.write_str(message),
}
}
}
impl std::error::Error for RegistryErrorCause {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { error, .. } => Some(error),
Self::Serde { error, .. } => Some(error),
Self::Message(_) => None,
}
}
}
/// 注册表文件级失败。目标模块负责把它映射成自己的 typed error。
#[derive(Debug)]
pub(crate) enum RegistryError {
/// 路径不可读写(含父目录创建失败)。
Io { path: PathBuf, cause: String },
Io {
path: PathBuf,
cause: RegistryErrorCause,
},
/// 文件内容不是本目标期望的形状。fail closed:不猜测、不修补。
Malformed { path: PathBuf, cause: String },
Malformed {
path: PathBuf,
cause: RegistryErrorCause,
},
/// 宿主自己的 DTO 序列化失败,属于编程错误。
Encode { cause: String },
Encode { cause: RegistryErrorCause },
}
impl std::error::Error for RegistryError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { cause, .. } | Self::Malformed { cause, .. } | Self::Encode { cause } => {
Some(cause)
}
}
}
}
impl fmt::Display for RegistryError {
@@ -83,13 +171,13 @@ where
// 被报成「路径 / 权限故障」,把用户和 agent 往修权限的方向引。
return Err(RegistryError::Malformed {
path: path.to_path_buf(),
cause: format!("注册表不是有效 UTF-8:{error}"),
cause: RegistryErrorCause::io_with_context("注册表不是有效 UTF-8", error),
});
}
Err(error) => {
return Err(RegistryError::Io {
path: path.to_path_buf(),
cause: error.to_string(),
cause: RegistryErrorCause::io(error),
});
}
};
@@ -111,7 +199,7 @@ pub(crate) fn save<T: Serialize>(path: &Path, value: &T) -> Result<String, Regis
/// 规范形式:两空格缩进 + 结尾换行。agent 会手改这个文件,人类可读比紧凑更重要。
fn encode<T: Serialize>(value: &T) -> Result<String, RegistryError> {
let mut text = serde_json::to_string_pretty(value).map_err(|error| RegistryError::Encode {
cause: error.to_string(),
cause: RegistryErrorCause::serde(error),
})?;
text.push('\n');
Ok(text)
@@ -120,7 +208,7 @@ fn encode<T: Serialize>(value: &T) -> Result<String, RegistryError> {
fn decode<T: DeserializeOwned>(path: &Path, text: &str) -> Result<T, RegistryError> {
serde_json::from_str(text).map_err(|error| RegistryError::Malformed {
path: path.to_path_buf(),
cause: error.to_string(),
cause: RegistryErrorCause::serde(error),
})
}
@@ -131,11 +219,11 @@ fn decode<T: DeserializeOwned>(path: &Path, text: &str) -> Result<T, RegistryErr
fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> {
let parent = path.parent().ok_or_else(|| RegistryError::Io {
path: path.to_path_buf(),
cause: "注册表路径缺少父目录".to_string(),
cause: RegistryErrorCause::message("注册表路径缺少父目录"),
})?;
fs::create_dir_all(parent).map_err(|error| RegistryError::Io {
path: path.to_path_buf(),
cause: format!("创建导出目录失败:{error}"),
cause: RegistryErrorCause::io_with_context("创建导出目录失败", error),
})?;
let temp = temp_path(path);
let write = || -> std::io::Result<()> {
@@ -153,14 +241,14 @@ fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> {
let _ = fs::remove_file(&temp);
return Err(RegistryError::Io {
path: path.to_path_buf(),
cause: error.to_string(),
cause: RegistryErrorCause::io(error),
});
}
fs::rename(&temp, path).map_err(|error| {
let _ = fs::remove_file(&temp);
RegistryError::Io {
path: path.to_path_buf(),
cause: error.to_string(),
cause: RegistryErrorCause::io(error),
}
})?;
// rename 只改目录条目:不 fsync 父目录的话,崩溃后「哪个文件才是新的」这件事可能没落盘,
@@ -292,4 +380,24 @@ mod tests {
assert_eq!(loaded.value, value);
assert_eq!(loaded.content_hash, hash);
}
#[test]
fn error_keeps_the_underlying_io_kind_for_rust_callers() {
let error = RegistryError::Io {
path: PathBuf::from("/tmp/registry.json"),
cause: RegistryErrorCause::io_with_context(
"创建导出目录失败",
std::io::Error::from(std::io::ErrorKind::PermissionDenied),
),
};
// 展示文案保持原样,Rust 侧则能沿 source() 一路拿回 io::Error 并按 ErrorKind 分流。
assert!(error.to_string().contains("创建导出目录失败"));
let cause = std::error::Error::source(&error).expect("cause");
let io = cause
.source()
.and_then(|source| source.downcast_ref::<std::io::Error>())
.expect("io source");
assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
}
}