a3s_code_core/workspace/error.rs
1//! Typed error surface for the workspace subsystem.
2//!
3//! Until this module landed, every backend method returned
4//! `anyhow::Result<T>` and callers that wanted to react to specific
5//! failure kinds had to downcast — fragile, opaque to docs, and
6//! non-exhaustive. [`WorkspaceError`] gives the trait surface a typed
7//! enum with `#[non_exhaustive]` so callers can `match` known
8//! variants while still leaving room for future ones without breaking
9//! compatibility.
10//!
11//! # Migration shape
12//!
13//! The migration ships in two commits:
14//!
15//! 1. **7.3.a (this commit):** introduce [`WorkspaceError`] and
16//! [`WorkspaceResult`] alongside the existing `anyhow::Result`
17//! surface. Add `From` conversions in both directions. *No trait
18//! signature changes* — purely additive infrastructure. Existing
19//! callers and backends remain on `anyhow::Result`; the new types
20//! are immediately usable but not yet required.
21//!
22//! 2. **7.3.b (next commit):** flip every trait method and helper to
23//! return `WorkspaceResult<T>`, update every backend implementation,
24//! every tool, and the SDK transparent paths. That commit is the
25//! breaking change that motivates the v3.0.0 version bump.
26//!
27//! Splitting it this way lets the type definitions land independently
28//! (and be reviewed in isolation) without breaking any existing
29//! callsite.
30//!
31//! # Bridge between `anyhow::Error` and `WorkspaceError`
32//!
33//! In addition to the auto-generated `From<anyhow::Error>` impl
34//! provided by `#[from]` on the `Backend` variant, this module supplies
35//! [`WorkspaceError::from_anyhow`] which **preserves the typed variant**
36//! when an `anyhow::Error` was originally constructed from a known
37//! conflict struct (`WorkspaceVersionConflict`, `RemoteGitConflict`).
38//! The plain `Into::into` path drops the type information into the
39//! `Backend(_)` variant because `anyhow::Error` erases the source type
40//! at the value level.
41
42use super::{RemoteGitConflict, WorkspaceVersionConflict};
43use std::time::Duration;
44
45/// Error type returned by every [`WorkspaceFileSystem`](super::WorkspaceFileSystem)
46/// and friend trait method.
47///
48/// `#[non_exhaustive]` so adding a new variant in a future release is a
49/// minor change — existing `match` callers compile, they just hit the
50/// catch-all arm for unknown variants.
51///
52/// The variants intentionally split into three categories:
53///
54/// * **Structured failures** the trait surface knows how to describe
55/// (`NotFound`, `InvalidArgument`, `Timeout`, `Unsupported`). New
56/// variants in this category should also be structured.
57/// * **Typed conflicts** with their own payload structs that already
58/// ship as part of the public API
59/// (`VersionConflict(WorkspaceVersionConflict)`,
60/// `RemoteGitConflict(RemoteGitConflict)`).
61/// * **`Backend(anyhow::Error)`** — the escape hatch. Any failure not
62/// covered above wraps an `anyhow::Error`. Backends should prefer the
63/// typed variants where they apply; `Backend` is for genuinely
64/// opaque or backend-specific failures.
65#[derive(Debug, thiserror::Error)]
66#[non_exhaustive]
67pub enum WorkspaceError {
68 /// A read / list against a path that does not exist on the backend.
69 ///
70 /// Backends that distinguish "doesn't exist" from "exists but
71 /// access denied" should still emit this for the former; the
72 /// latter belongs in `Backend(_)` with the backend's native auth
73 /// error wrapped.
74 #[error("path not found: {path}")]
75 NotFound {
76 /// Path that triggered the failure, in workspace-relative form
77 /// where possible. May include backend-specific qualifiers
78 /// (`s3://bucket/key`) when that aids debugging.
79 path: String,
80 },
81
82 /// Compare-and-swap write rejected because the in-storage version
83 /// no longer matches what the caller observed at read time. Carries
84 /// the existing public [`WorkspaceVersionConflict`] struct verbatim.
85 #[error(transparent)]
86 VersionConflict(#[from] WorkspaceVersionConflict),
87
88 /// A remote git server returned 409 / 422 with a typed conflict
89 /// code (e.g. `BRANCH_EXISTS`, `WORKING_TREE_DIRTY`,
90 /// `NOTHING_TO_STASH`). Carries the existing public
91 /// [`RemoteGitConflict`] struct verbatim.
92 #[error(transparent)]
93 RemoteGitConflict(#[from] RemoteGitConflict),
94
95 /// Caller passed an argument the backend cannot honour
96 /// (empty version on a CAS write, malformed pattern on a search,
97 /// path with parent-traversal, ...). Backends should prefer this
98 /// over `Backend(_)` for caller-fault errors so the model can
99 /// reason about retry strategy.
100 #[error("invalid argument: {message}")]
101 InvalidArgument {
102 /// Human-readable description; safe to surface to the model.
103 message: String,
104 },
105
106 /// The operation's outer timeout (see
107 /// [`WorkspaceServices::operation_timeout`](super::WorkspaceServices::operation_timeout))
108 /// fired before the backend responded.
109 #[error("workspace operation '{op}' timed out after {duration:?}")]
110 Timeout {
111 /// Human-readable operation name, e.g. `read_text` or `s3.get_object`.
112 op: String,
113 /// Configured timeout that expired.
114 duration: Duration,
115 },
116
117 /// The backend explicitly does not support this operation.
118 ///
119 /// Used by adapters that wrap a partial trait surface (e.g. the
120 /// remote git backend rejecting worktree operations even though
121 /// `WorkspaceGit` is implemented).
122 #[error("not supported by this backend: {0}")]
123 Unsupported(String),
124
125 /// Catch-all wrapping a lower-level error that does not map to one
126 /// of the typed variants above. This is the bridge between the
127 /// existing `anyhow::Result` world and the typed surface — when a
128 /// backend throws a generic I/O / HTTP / SDK error it ends up here.
129 #[error(transparent)]
130 Backend(#[from] anyhow::Error),
131}
132
133impl WorkspaceError {
134 /// Convert an `anyhow::Error` to a `WorkspaceError`, **preserving
135 /// the typed variant** when the original cause was one of the
136 /// known conflict structs.
137 ///
138 /// `Into::into` (auto-derived from the `#[from] anyhow::Error`
139 /// variant) drops every `anyhow::Error` into the `Backend` arm
140 /// because at the value level `anyhow::Error` has type-erased its
141 /// source. Use this helper instead when migrating code paths that
142 /// today emit `anyhow::Error::new(WorkspaceVersionConflict { .. })`
143 /// or `anyhow::Error::new(RemoteGitConflict { .. })` — the typed
144 /// variant survives the round-trip.
145 ///
146 /// ```ignore
147 /// // Old code:
148 /// fn legacy() -> anyhow::Result<()> { ... }
149 /// // New caller:
150 /// let typed = WorkspaceError::from_anyhow(legacy().unwrap_err());
151 /// match typed {
152 /// WorkspaceError::VersionConflict(v) => retry(v),
153 /// other => return Err(other),
154 /// }
155 /// ```
156 pub fn from_anyhow(err: anyhow::Error) -> Self {
157 if let Some(conflict) = err.downcast_ref::<WorkspaceVersionConflict>() {
158 return Self::VersionConflict(conflict.clone());
159 }
160 if let Some(conflict) = err.downcast_ref::<RemoteGitConflict>() {
161 return Self::RemoteGitConflict(conflict.clone());
162 }
163 Self::Backend(err)
164 }
165}
166
167/// Result alias used throughout the workspace trait surface in v3.0+.
168///
169/// In v2.x this co-exists with [`anyhow::Result`] (the legacy return
170/// type of every trait method); in v3.0 the trait surface will return
171/// `WorkspaceResult<T>` directly. See the module docs for the
172/// two-commit migration plan.
173pub type WorkspaceResult<T> = std::result::Result<T, WorkspaceError>;
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use anyhow::anyhow;
179
180 #[test]
181 fn anyhow_with_version_conflict_round_trips_through_from_anyhow() {
182 let conflict = WorkspaceVersionConflict {
183 path: "doc.md".to_string(),
184 expected: "etag-1".to_string(),
185 actual: Some("etag-2".to_string()),
186 };
187 let err: anyhow::Error = anyhow::Error::new(conflict.clone());
188
189 let typed = WorkspaceError::from_anyhow(err);
190 match typed {
191 WorkspaceError::VersionConflict(v) => {
192 assert_eq!(v.path, "doc.md");
193 assert_eq!(v.expected, "etag-1");
194 assert_eq!(v.actual.as_deref(), Some("etag-2"));
195 }
196 other => panic!("expected VersionConflict, got {other:?}"),
197 }
198 }
199
200 #[test]
201 fn anyhow_with_remote_git_conflict_round_trips_through_from_anyhow() {
202 let conflict = RemoteGitConflict {
203 code: "BRANCH_EXISTS".to_string(),
204 message: "branch 'feat/x' already exists".to_string(),
205 };
206 let err: anyhow::Error = anyhow::Error::new(conflict);
207
208 let typed = WorkspaceError::from_anyhow(err);
209 match typed {
210 WorkspaceError::RemoteGitConflict(c) => {
211 assert_eq!(c.code, "BRANCH_EXISTS");
212 assert!(c.message.contains("feat/x"));
213 }
214 other => panic!("expected RemoteGitConflict, got {other:?}"),
215 }
216 }
217
218 #[test]
219 fn anyhow_without_known_type_falls_into_backend_variant() {
220 let err: anyhow::Error = anyhow!("some I/O thing exploded");
221 let typed = WorkspaceError::from_anyhow(err);
222 match typed {
223 WorkspaceError::Backend(e) => {
224 assert!(e.to_string().contains("I/O thing exploded"));
225 }
226 other => panic!("expected Backend, got {other:?}"),
227 }
228 }
229
230 #[test]
231 fn workspace_error_converts_back_to_anyhow_via_blanket_impl() {
232 // anyhow's blanket `From<E: Error + Send + Sync + 'static>` impl
233 // means `?` on a `WorkspaceResult` inside an `anyhow::Result`
234 // function lifts cleanly. This is the only thing keeping
235 // existing `anyhow::Result`-returning callers compatible during
236 // the Phase 7.3.b migration.
237 fn produce() -> WorkspaceResult<()> {
238 Err(WorkspaceError::NotFound {
239 path: "missing.txt".into(),
240 })
241 }
242 fn consumes_anyhow() -> anyhow::Result<()> {
243 produce()?;
244 Ok(())
245 }
246 let err = consumes_anyhow().unwrap_err();
247 assert!(err.to_string().contains("missing.txt"));
248 // The original typed value is still recoverable via downcast.
249 assert!(err.downcast_ref::<WorkspaceError>().is_some());
250 }
251
252 #[test]
253 fn version_conflict_struct_converts_via_from() {
254 // The auto-derived `#[from] WorkspaceVersionConflict` impl lets
255 // backends build a `WorkspaceError` directly from the existing
256 // conflict struct without going through anyhow first.
257 let conflict = WorkspaceVersionConflict {
258 path: "x.txt".into(),
259 expected: "v1".into(),
260 actual: None,
261 };
262 let err: WorkspaceError = conflict.into();
263 matches!(err, WorkspaceError::VersionConflict(_))
264 .then_some(())
265 .expect("From<WorkspaceVersionConflict> must produce VersionConflict variant");
266 }
267
268 #[test]
269 fn invalid_argument_variant_carries_message_in_display() {
270 let err = WorkspaceError::InvalidArgument {
271 message: "expected_version must not be empty".into(),
272 };
273 let s = err.to_string();
274 assert!(s.contains("invalid argument"), "got: {s}");
275 assert!(s.contains("expected_version"), "got: {s}");
276 }
277
278 #[test]
279 fn timeout_variant_carries_op_and_duration_in_display() {
280 let err = WorkspaceError::Timeout {
281 op: "read_text".into(),
282 duration: Duration::from_secs(30),
283 };
284 let s = err.to_string();
285 assert!(s.contains("read_text"), "got: {s}");
286 assert!(s.contains("30"), "got: {s}");
287 }
288
289 #[test]
290 fn unsupported_variant_names_the_operation() {
291 let err = WorkspaceError::Unsupported("worktree on remote git".into());
292 let s = err.to_string();
293 assert!(s.contains("not supported"), "got: {s}");
294 assert!(s.contains("worktree"), "got: {s}");
295 }
296}