#![cfg_attr(not(feature = "cli"), allow(dead_code))]
pub const SUCCESS: u8 = 0;
pub const FAILURE: u8 = 1;
pub const CHANGES_DETECTED: u8 = 2;
pub const NO_MATCHES: u8 = 3;
#[derive(Debug)]
pub struct NoMatchError {
pub msg: String,
}
impl std::fmt::Display for NoMatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for NoMatchError {}
pub fn is_no_match(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<NoMatchError>().is_some())
}
#[derive(Debug)]
pub struct AmbiguousError {
pub msg: String,
}
impl std::fmt::Display for AmbiguousError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for AmbiguousError {}
pub fn is_ambiguous(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<AmbiguousError>().is_some())
}
#[derive(Debug)]
pub struct InvalidInputError {
pub msg: String,
}
impl std::fmt::Display for InvalidInputError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for InvalidInputError {}
pub fn is_invalid_input(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<InvalidInputError>().is_some())
}
pub fn is_io_not_found(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound)
})
}
#[derive(Debug)]
pub struct AlreadyExistsError {
pub msg: String,
}
impl std::fmt::Display for AlreadyExistsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for AlreadyExistsError {}
pub fn is_already_exists(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<AlreadyExistsError>().is_some())
}
#[derive(Debug)]
pub struct TypeErrorError {
pub msg: String,
}
impl std::fmt::Display for TypeErrorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for TypeErrorError {}
pub fn is_type_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<TypeErrorError>().is_some())
}
#[derive(Debug)]
pub struct ConflictsError {
pub msg: String,
}
impl std::fmt::Display for ConflictsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for ConflictsError {}
pub fn is_conflicts(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<ConflictsError>().is_some())
}
#[derive(Debug)]
pub struct ParseErrorError {
pub msg: String,
}
impl std::fmt::Display for ParseErrorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for ParseErrorError {}
pub fn is_parse_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<ParseErrorError>().is_some())
}
#[derive(Debug)]
pub struct ChangesDetectedError {
pub msg: String,
}
impl std::fmt::Display for ChangesDetectedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for ChangesDetectedError {}
pub fn is_changes_detected(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<ChangesDetectedError>().is_some())
}
#[derive(Debug)]
pub struct BinaryError {
pub msg: String,
}
impl std::fmt::Display for BinaryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for BinaryError {}
pub fn is_binary(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<BinaryError>().is_some())
}
#[derive(Debug)]
pub struct InvalidEncodingError {
pub msg: String,
}
impl std::fmt::Display for InvalidEncodingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for InvalidEncodingError {}
pub fn is_invalid_encoding(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<InvalidEncodingError>().is_some())
}
#[must_use]
pub fn is_load_text_strict_fail(err: &anyhow::Error) -> bool {
is_binary(err) || is_invalid_encoding(err) || is_invalid_input(err)
}
#[derive(Debug)]
pub struct FormatFailedError {
pub msg: String,
pub backup_session: Option<String>,
pub written_files: Vec<String>,
}
impl FormatFailedError {
#[must_use]
pub fn new(msg: impl Into<String>) -> Self {
Self {
msg: msg.into(),
backup_session: None,
written_files: Vec::new(),
}
}
#[must_use]
pub fn with_backup_session(mut self, session: Option<String>) -> Self {
self.backup_session = session.filter(|s| !s.is_empty());
self
}
#[must_use]
pub fn with_written_files(mut self, files: Vec<String>) -> Self {
self.written_files = files;
self
}
}
impl std::fmt::Display for FormatFailedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.backup_session {
Some(ts) => write!(
f,
"{} (backup session {ts}; run `patchloom undo` to restore)",
self.msg
),
None => f.write_str(&self.msg),
}
}
}
impl std::error::Error for FormatFailedError {}
pub fn is_format_failed(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<FormatFailedError>().is_some())
}
#[must_use]
pub fn format_failed_backup_session(err: &anyhow::Error) -> Option<&str> {
err.chain().find_map(|cause| {
cause
.downcast_ref::<FormatFailedError>()
.and_then(|f| f.backup_session.as_deref())
})
}
#[must_use]
pub fn format_failed_written_files(err: &anyhow::Error) -> Vec<String> {
err.chain()
.find_map(|cause| {
cause
.downcast_ref::<FormatFailedError>()
.map(|f| f.written_files.clone())
})
.unwrap_or_default()
}
#[must_use]
pub fn is_io_is_a_directory(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::IsADirectory)
})
}
#[must_use]
pub fn agent_error_message(err: &anyhow::Error) -> String {
let top = err.to_string();
let mut complete = true;
for cause in err.chain().skip(1) {
let c = cause.to_string();
if !c.is_empty() && !top.contains(&c) {
complete = false;
break;
}
}
if complete { top } else { format!("{err:#}") }
}
#[must_use]
pub fn structured_error_payload(err: &anyhow::Error) -> (serde_json::Value, u8) {
let (kind, code) = match classify_typed_error(err) {
Some((k, c)) => (Some(k), c),
None => (None, FAILURE),
};
let mut output = serde_json::json!({
"ok": false,
"error": agent_error_message(err)
});
if let Some(k) = kind {
output["error_kind"] = serde_json::Value::String(k.to_string());
}
if let Some(bs) = format_failed_backup_session(err) {
output["backup_session"] = serde_json::Value::String(bs.to_string());
}
let written = format_failed_written_files(err);
if !written.is_empty() {
output["applied"] = serde_json::Value::Bool(true);
output["write_applied"] = serde_json::Value::Bool(true);
output["files_changed"] = serde_json::Value::Number(written.len().into());
output["files"] = serde_json::Value::Array(
written
.into_iter()
.map(|path| serde_json::json!({ "path": path }))
.collect(),
);
} else if kind.is_some_and(error_kind_implies_not_applied)
|| matches!(kind, Some("format_failed"))
{
output["applied"] = serde_json::Value::Bool(false);
}
(output, code)
}
#[must_use]
pub fn error_kind_implies_not_applied(kind: &str) -> bool {
matches!(
kind,
"no_matches"
| "ambiguous"
| "invalid_input"
| "binary"
| "invalid_encoding"
| "guard_rejected"
| "not_found"
| "already_exists"
| "type_error"
| "conflicts"
| "parse_error"
| "changes_detected"
)
}
pub fn classify_typed_error(err: &anyhow::Error) -> Option<(&'static str, u8)> {
if is_no_match(err) {
Some(("no_matches", NO_MATCHES))
} else if is_ambiguous(err) {
Some(("ambiguous", AMBIGUOUS))
} else if is_binary(err) {
Some(("binary", FAILURE))
} else if is_invalid_encoding(err) {
Some(("invalid_encoding", FAILURE))
} else if is_invalid_input(err) {
Some(("invalid_input", FAILURE))
} else if is_io_not_found(err) {
Some(("not_found", FAILURE))
} else if is_io_is_a_directory(err) {
Some(("invalid_input", FAILURE))
} else if is_already_exists(err) {
Some(("already_exists", FAILURE))
} else if is_type_error(err) {
Some(("type_error", FAILURE))
} else if is_conflicts(err) {
Some(("conflicts", CONFLICTS))
} else if is_parse_error(err) {
Some(("parse_error", PARSE_ERROR))
} else if is_changes_detected(err) {
Some(("changes_detected", CHANGES_DETECTED))
} else if is_format_failed(err) {
Some(("format_failed", FAILURE))
} else if let Some(edit) = err
.chain()
.find_map(|c| c.downcast_ref::<crate::fallback::EditError>())
{
use crate::fallback::EditErrorKind;
match edit.kind {
EditErrorKind::GuardRejected => Some(("guard_rejected", FAILURE)),
EditErrorKind::NoMatch => Some(("no_matches", NO_MATCHES)),
EditErrorKind::AmbiguousTarget => Some(("ambiguous", AMBIGUOUS)),
EditErrorKind::InvalidInput => Some(("invalid_input", FAILURE)),
EditErrorKind::Binary => Some(("binary", FAILURE)),
EditErrorKind::InvalidEncoding => Some(("invalid_encoding", FAILURE)),
EditErrorKind::FuzzySpanSuspicious => Some(("fuzzy_span_suspicious", FAILURE)),
EditErrorKind::TypeError => Some(("type_error", FAILURE)),
EditErrorKind::AlreadyExists => Some(("already_exists", FAILURE)),
EditErrorKind::NotFound => Some(("not_found", FAILURE)),
EditErrorKind::Conflicts => Some(("conflicts", CONFLICTS)),
EditErrorKind::ChangesDetected => Some(("changes_detected", CHANGES_DETECTED)),
EditErrorKind::ParseError => Some(("parse_error", PARSE_ERROR)),
EditErrorKind::FormatFailed => Some(("format_failed", FAILURE)),
EditErrorKind::SyntaxInvalid
| EditErrorKind::ConflictingEdit
| EditErrorKind::OperationFailed => Some(("operation_failed", OPERATION_FAILED)),
}
} else {
None
}
}
pub const PARSE_ERROR: u8 = 4;
pub const AMBIGUOUS: u8 = 5;
pub const VALIDATION_FAILED: u8 = 6;
pub const ROLLBACK: u8 = 7;
pub const CONFLICTS: u8 = 8;
pub const OPERATION_FAILED: u8 = 9;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_typed_error_maps_format_failed() {
let err: anyhow::Error = FormatFailedError::new("format command failed").into();
let wrapped = err.context("files were written but formatting failed");
assert_eq!(
classify_typed_error(&wrapped),
Some(("format_failed", FAILURE))
);
}
#[test]
fn structured_error_payload_includes_format_backup_session() {
let err: anyhow::Error = FormatFailedError::new("format command failed (false)")
.with_backup_session(Some("123_0".into()))
.into();
let (payload, code) = structured_error_payload(&err);
assert_eq!(code, FAILURE);
assert_eq!(payload["error_kind"], "format_failed");
assert_eq!(payload["backup_session"], "123_0");
assert!(
payload["error"]
.as_str()
.unwrap_or("")
.contains("backup session 123_0"),
"display should mention undo session: {payload}"
);
}
#[test]
fn structured_error_payload_includes_format_written_files() {
let err: anyhow::Error = FormatFailedError::new("format command failed (false)")
.with_backup_session(Some("123_0".into()))
.with_written_files(vec!["a.txt".into(), "b.txt".into()])
.into();
let (payload, code) = structured_error_payload(&err);
assert_eq!(code, FAILURE);
assert_eq!(payload["error_kind"], "format_failed");
assert_eq!(
payload["applied"], true,
"format_failed must set applied when write landed (#1831): {payload}"
);
assert_eq!(
payload["write_applied"], true,
"write_applied kept as deprecated alias: {payload}"
);
assert_eq!(payload["files_changed"], 2);
assert_eq!(payload["files"][0]["path"], "a.txt");
assert_eq!(payload["files"][1]["path"], "b.txt");
assert_eq!(
format_failed_written_files(&err),
vec!["a.txt".to_string(), "b.txt".to_string()]
);
}
#[test]
fn structured_error_payload_prewrite_no_matches_and_ambiguous_set_applied_false() {
let no_match: anyhow::Error = NoMatchError {
msg: "no matches".into(),
}
.into();
let (p, code) = structured_error_payload(&no_match);
assert_eq!(code, NO_MATCHES);
assert_eq!(p["error_kind"], "no_matches");
assert_eq!(
p["applied"], false,
"pre-write no_matches must set applied:false (#1835): {p}"
);
let amb: anyhow::Error = AmbiguousError {
msg: "many matches".into(),
}
.into();
let (p, code) = structured_error_payload(&amb);
assert_eq!(code, AMBIGUOUS);
assert_eq!(p["error_kind"], "ambiguous");
assert_eq!(
p["applied"], false,
"pre-write ambiguous must set applied:false (#1835): {p}"
);
}
#[test]
fn structured_error_payload_prewrite_already_exists_and_not_found_set_applied_false() {
let exists: anyhow::Error = AlreadyExistsError {
msg: "file already exists".into(),
}
.into();
let (p, code) = structured_error_payload(&exists);
assert_eq!(code, FAILURE);
assert_eq!(p["error_kind"], "already_exists");
assert_eq!(
p["applied"], false,
"pre-write already_exists must set applied:false: {p}"
);
let missing: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::NotFound, "no such file").into();
let (p, code) = structured_error_payload(&missing);
assert_eq!(code, FAILURE);
assert_eq!(p["error_kind"], "not_found");
assert_eq!(
p["applied"], false,
"pre-write not_found must set applied:false: {p}"
);
}
#[test]
fn agent_error_message_avoids_double_os_when_context_embeds_cause() {
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
let msg = format!("failed to read missing.txt: {io}");
let err = anyhow::Error::new(io).context(msg);
let agent = agent_error_message(&err);
assert!(
agent.contains("failed to read missing.txt"),
"path context missing: {agent}"
);
assert!(agent.contains("No such file"), "OS detail missing: {agent}");
assert_eq!(
agent.matches("No such file").count(),
1,
"OS detail must not double under agent_error_message: {agent}"
);
let raw = format!("{err:#}");
assert!(
raw.matches("No such file").count() >= 2,
"test premise: raw {{:#}} should double: {raw}"
);
let (payload, _) = structured_error_payload(&err);
let json_err = payload["error"].as_str().unwrap_or("");
assert_eq!(
json_err.matches("No such file").count(),
1,
"structured_error_payload must not double OS detail: {json_err}"
);
}
#[test]
fn agent_error_message_keeps_multi_layer_chain_when_causes_add_info() {
let root = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
let err = anyhow::Error::new(root)
.context("failed to read plan.yaml")
.context("operation 1 (doc.get) failed");
let agent = agent_error_message(&err);
assert!(
agent.contains("operation 1"),
"outer context missing: {agent}"
);
assert!(
agent.contains("failed to read plan.yaml"),
"middle context missing: {agent}"
);
assert!(
agent.contains("No such file"),
"root OS detail missing: {agent}"
);
}
#[test]
fn error_kind_implies_not_applied_covers_prewrite_kinds() {
assert!(error_kind_implies_not_applied("already_exists"));
assert!(error_kind_implies_not_applied("not_found"));
assert!(error_kind_implies_not_applied("parse_error"));
assert!(error_kind_implies_not_applied("guard_rejected"));
assert!(error_kind_implies_not_applied("binary"));
assert!(error_kind_implies_not_applied("invalid_encoding"));
assert!(!error_kind_implies_not_applied("format_failed"));
}
#[test]
fn classify_typed_error_maps_guard_rejected_edit_error() {
let err = crate::fallback::EditError::guard_rejected("escapes workspace");
let wrapped = err.context("file.create failed");
assert_eq!(
classify_typed_error(&wrapped),
Some(("guard_rejected", FAILURE))
);
let (payload, code) = structured_error_payload(&wrapped);
assert_eq!(code, FAILURE);
assert_eq!(payload["error_kind"], "guard_rejected");
assert_eq!(payload["applied"], false);
}
#[test]
fn classify_typed_error_maps_is_a_directory() {
let err: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::IsADirectory, "is a directory").into();
let wrapped = err.context("failed to read /tmp/dir");
assert_eq!(
classify_typed_error(&wrapped),
Some(("invalid_input", FAILURE))
);
}
#[test]
fn is_load_text_strict_fail_covers_content_and_invalid_input() {
let bin: anyhow::Error = BinaryError {
msg: "binary".into(),
}
.into();
assert!(is_load_text_strict_fail(&bin));
assert!(!is_io_not_found(&bin));
let enc: anyhow::Error = InvalidEncodingError { msg: "utf8".into() }.into();
assert!(is_load_text_strict_fail(&enc));
let inv: anyhow::Error = InvalidInputError {
msg: "not a file".into(),
}
.into();
assert!(is_load_text_strict_fail(&inv));
let missing: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::NotFound, "gone").into();
assert!(!is_load_text_strict_fail(&missing));
assert!(is_io_not_found(&missing));
}
#[test]
fn classify_typed_error_maps_all_kinds() {
let cases: Vec<(anyhow::Error, &str, u8)> = vec![
(
NoMatchError { msg: "none".into() }.into(),
"no_matches",
NO_MATCHES,
),
(
AmbiguousError { msg: "many".into() }.into(),
"ambiguous",
AMBIGUOUS,
),
(
InvalidInputError { msg: "bad".into() }.into(),
"invalid_input",
FAILURE,
),
(
BinaryError {
msg: "binary file".into(),
}
.into(),
"binary",
FAILURE,
),
(
InvalidEncodingError {
msg: "not utf-8".into(),
}
.into(),
"invalid_encoding",
FAILURE,
),
(
std::io::Error::new(std::io::ErrorKind::NotFound, "gone").into(),
"not_found",
FAILURE,
),
(
AlreadyExistsError {
msg: "exists".into(),
}
.into(),
"already_exists",
FAILURE,
),
(
TypeErrorError { msg: "type".into() }.into(),
"type_error",
FAILURE,
),
(
ConflictsError {
msg: "conflict".into(),
}
.into(),
"conflicts",
CONFLICTS,
),
(
ParseErrorError {
msg: "parse".into(),
}
.into(),
"parse_error",
PARSE_ERROR,
),
(
ChangesDetectedError { msg: "diff".into() }.into(),
"changes_detected",
CHANGES_DETECTED,
),
(
FormatFailedError::new("fmt").into(),
"format_failed",
FAILURE,
),
(
std::io::Error::new(std::io::ErrorKind::IsADirectory, "dir").into(),
"invalid_input",
FAILURE,
),
(
crate::fallback::EditError::new(
crate::fallback::EditErrorKind::FuzzySpanSuspicious,
"wide fuzzy",
)
.into(),
"fuzzy_span_suspicious",
FAILURE,
),
];
for (err, kind, code) in cases {
assert_eq!(
classify_typed_error(&err),
Some((kind, code)),
"kind={kind}"
);
}
}
#[test]
fn classify_typed_error_none_for_plain() {
let err = anyhow::anyhow!("plain");
assert_eq!(classify_typed_error(&err), None);
}
#[test]
fn exit_code_values() {
assert_eq!(SUCCESS, 0);
assert_eq!(FAILURE, 1);
assert_eq!(CHANGES_DETECTED, 2);
assert_eq!(NO_MATCHES, 3);
assert_eq!(PARSE_ERROR, 4);
assert_eq!(AMBIGUOUS, 5);
assert_eq!(VALIDATION_FAILED, 6);
assert_eq!(ROLLBACK, 7);
assert_eq!(CONFLICTS, 8);
assert_eq!(OPERATION_FAILED, 9);
}
#[test]
fn no_match_error_downcast() {
let err: anyhow::Error = NoMatchError {
msg: "selector 'x' matched nothing".to_string(),
}
.into();
assert!(
err.downcast_ref::<NoMatchError>().is_some(),
"NoMatchError should be downcastable from anyhow::Error"
);
}
#[test]
fn no_match_error_display() {
let err = NoMatchError {
msg: "test message".to_string(),
};
assert_eq!(err.to_string(), "test message");
}
#[test]
fn non_no_match_error_not_downcast() {
let err = anyhow::anyhow!("some other error");
assert!(
err.downcast_ref::<NoMatchError>().is_none(),
"plain anyhow error should not downcast to NoMatchError"
);
}
#[test]
fn is_no_match_finds_wrapped_error() {
let err: anyhow::Error = NoMatchError {
msg: "selector matched nothing".to_string(),
}
.into();
let wrapped = err.context("operation 1 (doc.update) failed");
assert!(
is_no_match(&wrapped),
"is_no_match should find NoMatchError through .context() wrapper"
);
}
#[test]
fn is_no_match_rejects_wrapped_non_no_match() {
let err = anyhow::anyhow!("some other error").context("operation 1 failed");
assert!(
!is_no_match(&err),
"is_no_match should return false for non-NoMatchError in chain"
);
}
#[test]
fn is_ambiguous_finds_wrapped_error() {
let err: anyhow::Error = AmbiguousError {
msg: "ambiguous match: pattern \"a\" matches 2 times".to_string(),
}
.into();
let wrapped = err.context("operation 1 (replace) failed");
assert!(
is_ambiguous(&wrapped),
"is_ambiguous should find AmbiguousError through .context() wrapper"
);
assert!(!is_no_match(&wrapped));
}
#[test]
fn is_ambiguous_rejects_plain_error() {
let err = anyhow::anyhow!("some other error").context("operation 1 failed");
assert!(!is_ambiguous(&err));
}
#[test]
fn is_invalid_input_finds_wrapped_error() {
let err: anyhow::Error = InvalidInputError {
msg: "path rejected by workspace guard: escapes".to_string(),
}
.into();
let wrapped = err.context("create failed");
assert!(is_invalid_input(&wrapped));
assert!(!is_no_match(&wrapped));
assert!(!is_ambiguous(&wrapped));
}
#[test]
fn exit_codes_are_unique() {
let codes = [
SUCCESS,
FAILURE,
CHANGES_DETECTED,
NO_MATCHES,
PARSE_ERROR,
AMBIGUOUS,
VALIDATION_FAILED,
ROLLBACK,
CONFLICTS,
OPERATION_FAILED,
];
let mut seen = std::collections::HashSet::new();
for code in codes {
assert!(seen.insert(code), "duplicate exit code: {code}");
}
}
#[test]
fn is_io_not_found_preserves_with_context_chain() {
use anyhow::Context;
let err = std::fs::read_to_string("/tmp/patchloom-definitely-missing-xyz-99999")
.with_context(|| "failed to read path");
let err = err.unwrap_err();
assert!(
is_io_not_found(&err),
"with_context should keep NotFound in chain: {err:#}"
);
}
}