use super::{RemoteGitConflict, WorkspaceVersionConflict};
use std::time::Duration;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum WorkspaceError {
#[error("path not found: {path}")]
NotFound {
path: String,
},
#[error(transparent)]
VersionConflict(#[from] WorkspaceVersionConflict),
#[error(transparent)]
RemoteGitConflict(#[from] RemoteGitConflict),
#[error("invalid argument: {message}")]
InvalidArgument {
message: String,
},
#[error("workspace operation '{op}' timed out after {duration:?}")]
Timeout {
op: String,
duration: Duration,
},
#[error("not supported by this backend: {0}")]
Unsupported(String),
#[error(transparent)]
Backend(#[from] anyhow::Error),
}
impl WorkspaceError {
pub fn from_anyhow(err: anyhow::Error) -> Self {
if let Some(conflict) = err.downcast_ref::<WorkspaceVersionConflict>() {
return Self::VersionConflict(conflict.clone());
}
if let Some(conflict) = err.downcast_ref::<RemoteGitConflict>() {
return Self::RemoteGitConflict(conflict.clone());
}
Self::Backend(err)
}
}
pub type WorkspaceResult<T> = std::result::Result<T, WorkspaceError>;
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
#[test]
fn anyhow_with_version_conflict_round_trips_through_from_anyhow() {
let conflict = WorkspaceVersionConflict {
path: "doc.md".to_string(),
expected: "etag-1".to_string(),
actual: Some("etag-2".to_string()),
};
let err: anyhow::Error = anyhow::Error::new(conflict.clone());
let typed = WorkspaceError::from_anyhow(err);
match typed {
WorkspaceError::VersionConflict(v) => {
assert_eq!(v.path, "doc.md");
assert_eq!(v.expected, "etag-1");
assert_eq!(v.actual.as_deref(), Some("etag-2"));
}
other => panic!("expected VersionConflict, got {other:?}"),
}
}
#[test]
fn anyhow_with_remote_git_conflict_round_trips_through_from_anyhow() {
let conflict = RemoteGitConflict {
code: "BRANCH_EXISTS".to_string(),
message: "branch 'feat/x' already exists".to_string(),
};
let err: anyhow::Error = anyhow::Error::new(conflict);
let typed = WorkspaceError::from_anyhow(err);
match typed {
WorkspaceError::RemoteGitConflict(c) => {
assert_eq!(c.code, "BRANCH_EXISTS");
assert!(c.message.contains("feat/x"));
}
other => panic!("expected RemoteGitConflict, got {other:?}"),
}
}
#[test]
fn anyhow_without_known_type_falls_into_backend_variant() {
let err: anyhow::Error = anyhow!("some I/O thing exploded");
let typed = WorkspaceError::from_anyhow(err);
match typed {
WorkspaceError::Backend(e) => {
assert!(e.to_string().contains("I/O thing exploded"));
}
other => panic!("expected Backend, got {other:?}"),
}
}
#[test]
fn workspace_error_converts_back_to_anyhow_via_blanket_impl() {
fn produce() -> WorkspaceResult<()> {
Err(WorkspaceError::NotFound {
path: "missing.txt".into(),
})
}
fn consumes_anyhow() -> anyhow::Result<()> {
produce()?;
Ok(())
}
let err = consumes_anyhow().unwrap_err();
assert!(err.to_string().contains("missing.txt"));
assert!(err.downcast_ref::<WorkspaceError>().is_some());
}
#[test]
fn version_conflict_struct_converts_via_from() {
let conflict = WorkspaceVersionConflict {
path: "x.txt".into(),
expected: "v1".into(),
actual: None,
};
let err: WorkspaceError = conflict.into();
matches!(err, WorkspaceError::VersionConflict(_))
.then_some(())
.expect("From<WorkspaceVersionConflict> must produce VersionConflict variant");
}
#[test]
fn invalid_argument_variant_carries_message_in_display() {
let err = WorkspaceError::InvalidArgument {
message: "expected_version must not be empty".into(),
};
let s = err.to_string();
assert!(s.contains("invalid argument"), "got: {s}");
assert!(s.contains("expected_version"), "got: {s}");
}
#[test]
fn timeout_variant_carries_op_and_duration_in_display() {
let err = WorkspaceError::Timeout {
op: "read_text".into(),
duration: Duration::from_secs(30),
};
let s = err.to_string();
assert!(s.contains("read_text"), "got: {s}");
assert!(s.contains("30"), "got: {s}");
}
#[test]
fn unsupported_variant_names_the_operation() {
let err = WorkspaceError::Unsupported("worktree on remote git".into());
let s = err.to_string();
assert!(s.contains("not supported"), "got: {s}");
assert!(s.contains("worktree"), "got: {s}");
}
}