From d30517859102ac257e544b2cc2bb69404fddb7ac Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 20 Apr 2026 14:02:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(cli):=20#130=20export=20error=20envelope=20?= =?UTF-8?q?=E2=80=94=20wrap=20fs::write()=20in=20run=5Fexport()=20with=20s?= =?UTF-8?q?tructured=20ExportError=20per=20Phase=202=20=C2=A74.44=20typed-?= =?UTF-8?q?error=20envelope=20contract;=20eliminates=20zero-context=20errn?= =?UTF-8?q?o=20output.=20ExportError=20struct=20includes=20kind/operation/?= =?UTF-8?q?target/errno/hint/retryable=20fields=20with=20serde::Serialize?= =?UTF-8?q?=20and=20Display=20impls.=20wrap=5Fexport=5Fio=5Ferror()=20clas?= =?UTF-8?q?sifies=20io::ErrorKind=20into=20filesystem/permission/invalid?= =?UTF-8?q?=5Fpath=20categories=20and=20synthesizes=20actionable=20hints?= =?UTF-8?q?=20(e.g.=20'intermediate=20directory=20does=20not=20exist;=20tr?= =?UTF-8?q?y=20mkdir=20-p=20X=20first').=20Verified=20end-to-end:=20ENOENT?= =?UTF-8?q?,=20EPERM,=20IsADirectory,=20empty=20path,=20trailing=20slash?= =?UTF-8?q?=20all=20emit=20structured=20envelope;=20success=20case=20uncha?= =?UTF-8?q?nged=20(backward-compat=20anchor=20preserved).=20JSON=20mode=20?= =?UTF-8?q?still=20uses=20string=20error=20rendering=20=E2=80=94=20separat?= =?UTF-8?q?e=20concern=20requiring=20global=20error=20renderer=20refactor?= =?UTF-8?q?=20(tracked=20for=20follow-up=20cycle).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/crates/rusty-claude-cli/src/main.rs | 91 +++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index ded1749..f00ce20 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6018,6 +6018,93 @@ fn summarize_tool_payload_for_markdown(payload: &str) -> String { truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT) } +/// Structured export error envelope (#130). +/// Conforms to Phase 2 §4.44 typed-error envelope contract. +/// Includes kind/operation/target/errno/hint/retryable for actionable diagnostics. +#[derive(Debug, serde::Serialize)] +struct ExportError { + kind: String, + operation: String, + target: String, + #[serde(skip_serializing_if = "Option::is_none")] + errno: Option, + hint: String, + retryable: bool, +} + +impl std::fmt::Display for ExportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "export failed: {} ({})\n target: {}\n errno: {}\n hint: {}", + self.kind, + self.operation, + self.target, + self.errno.as_deref().unwrap_or("unknown"), + self.hint + ) + } +} + +impl std::error::Error for ExportError {} + +/// Wrap std::io::Error into a structured ExportError per §4.44. +fn wrap_export_io_error(path: &Path, op: &str, e: std::io::Error) -> ExportError { + use std::io::ErrorKind; + let target_display = path.display().to_string(); + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| p.display().to_string()); + let (kind, hint) = match e.kind() { + ErrorKind::NotFound => ( + "filesystem", + parent + .as_ref() + .map(|p| format!("intermediate directory does not exist; try `mkdir -p {p}` first")) + .unwrap_or_else(|| { + "path is empty or invalid; provide a non-empty file path".to_string() + }), + ), + ErrorKind::PermissionDenied => ( + "permission", + format!( + "permission denied; check file permissions with `ls -la {}`", + parent.as_deref().unwrap_or(".") + ), + ), + ErrorKind::IsADirectory => ( + "filesystem", + format!( + "path `{}` is a directory, not a file; use a file path like `{}/session.md`", + target_display, target_display + ), + ), + ErrorKind::AlreadyExists => ( + "filesystem", + format!("path `{target_display}` already exists; remove it or pick a different name"), + ), + ErrorKind::InvalidInput | ErrorKind::InvalidData => ( + "invalid_path", + format!("path `{target_display}` is invalid; check for empty or malformed input"), + ), + _ => ( + "filesystem", + format!( + "unexpected error writing to `{target_display}`; check disk space and path validity" + ), + ), + }; + ExportError { + kind: kind.to_string(), + operation: op.to_string(), + target: target_display, + errno: Some(format!("{:?}", e.kind())), + hint, + retryable: matches!(e.kind(), ErrorKind::TimedOut | ErrorKind::Interrupted), + } +} + fn run_export( session_reference: &str, output_path: Option<&Path>, @@ -6027,7 +6114,9 @@ fn run_export( let markdown = render_session_markdown(&session, &handle.id, &handle.path); if let Some(path) = output_path { - fs::write(path, &markdown)?; + fs::write(path, &markdown).map_err(|e| { + Box::new(wrap_export_io_error(path, "write", e)) as Box + })?; let report = format!( "Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}", path.display(),