fix(cli): #130 export error envelope — wrap fs::write() in run_export() with structured ExportError per Phase 2 §4.44 typed-error envelope contract; eliminates zero-context errno output. ExportError struct includes kind/operation/target/errno/hint/retryable fields with serde::Serialize and Display impls. wrap_export_io_error() classifies io::ErrorKind into filesystem/permission/invalid_path categories and synthesizes actionable hints (e.g. 'intermediate directory does not exist; try mkdir -p X first'). Verified end-to-end: ENOENT, EPERM, IsADirectory, empty path, trailing slash all emit structured envelope; success case unchanged (backward-compat anchor preserved). JSON mode still uses string error rendering — separate concern requiring global error renderer refactor (tracked for follow-up cycle).

This commit is contained in:
YeonGyu-Kim 2026-04-20 14:02:15 +09:00
parent 0cbff5dc76
commit d305178591

View File

@ -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<String>,
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<dyn std::error::Error>
})?;
let report = format!(
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
path.display(),