Skip to main content

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}