Skip to main content

a3s_code_core/workspace/
mod.rs

1//! Workspace capability abstractions.
2//!
3//! Built-in tools expose stable model-facing contracts (`read`, `write`, `ls`,
4//! `bash`, ...). The concrete place where those operations happen is supplied
5//! by a workspace capability backend. The default backend is the local
6//! filesystem (see [`LocalWorkspaceBackend`]); hosts can provide remote,
7//! browser, DFS, or container-backed implementations by assembling
8//! [`WorkspaceServices`] through [`WorkspaceServicesBuilder`].
9
10#[cfg(test)]
11pub(crate) mod conformance;
12mod error;
13mod local;
14mod manifest;
15mod path;
16mod remote_git;
17#[cfg(feature = "s3")]
18mod s3;
19mod services;
20
21pub use error::{WorkspaceError, WorkspaceResult};
22pub use local::LocalWorkspaceBackend;
23pub use manifest::{
24    scan_workspace_files, LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifest,
25    LocalWorkspaceManifestSnapshot, ManifestWorkspaceBackend, RecentWorkspaceFile,
26    WorkspaceFileChange, WorkspaceFileChangeKind,
27};
28pub(crate) use path::validate_relative_pattern;
29pub use path::VirtualPathResolver;
30use path::{
31    default_path_input, escape_control_chars_for_display, has_windows_path_prefix,
32    normalize_relative_path, pathbuf_to_workspace_path,
33};
34pub use remote_git::{RemoteGitBackend, RemoteGitBackendConfig, RemoteGitConflict};
35#[cfg(feature = "s3")]
36pub use s3::{S3BackendConfig, S3WorkspaceBackend};
37pub use services::{WorkspaceServices, WorkspaceServicesBuilder};
38
39use anyhow::Result;
40use async_trait::async_trait;
41use std::collections::HashMap;
42use std::sync::Arc;
43
44/// Identity and display metadata for a workspace.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct WorkspaceRef {
47    /// Stable workspace identifier used by host backends.
48    pub id: String,
49    /// Human-readable root shown in tool output.
50    pub display_root: String,
51}
52
53impl WorkspaceRef {
54    pub fn new(id: impl Into<String>, display_root: impl Into<String>) -> Self {
55        Self {
56            id: id.into(),
57            display_root: display_root.into(),
58        }
59    }
60}
61
62/// A normalized virtual path inside a workspace.
63#[derive(Debug, Clone, PartialEq, Eq, Hash)]
64pub struct WorkspacePath {
65    inner: String,
66}
67
68impl WorkspacePath {
69    pub fn root() -> Self {
70        Self {
71            inner: ".".to_string(),
72        }
73    }
74
75    pub fn from_normalized(path: impl Into<String>) -> Self {
76        let path = path.into();
77        let path = path.trim_matches('/');
78        if path.is_empty() || path == "." {
79            Self::root()
80        } else {
81            Self {
82                inner: path.replace('\\', "/"),
83            }
84        }
85    }
86
87    pub fn as_str(&self) -> &str {
88        &self.inner
89    }
90
91    pub fn is_root(&self) -> bool {
92        self.inner == "."
93    }
94}
95
96/// Workspace capability flags used to gate which built-in tools are registered.
97///
98/// Each flag corresponds to a provider trait on [`WorkspaceServices`]; flags
99/// without a backing provider are deliberately omitted so the surface stays
100/// minimal until a real consumer appears.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct WorkspaceCapabilities {
103    pub read: bool,
104    pub write: bool,
105    pub exec: bool,
106    pub search: bool,
107    pub git: bool,
108    pub code_intelligence: bool,
109}
110
111impl WorkspaceCapabilities {
112    pub fn local_default() -> Self {
113        Self {
114            read: true,
115            write: true,
116            exec: true,
117            search: true,
118            git: true,
119            code_intelligence: false,
120        }
121    }
122
123    pub fn read_write() -> Self {
124        Self {
125            read: true,
126            write: true,
127            exec: false,
128            search: false,
129            git: false,
130            code_intelligence: false,
131        }
132    }
133}
134
135impl Default for WorkspaceCapabilities {
136    fn default() -> Self {
137        Self::read_write()
138    }
139}
140
141/// Directory entry kind.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum WorkspaceFileType {
144    File,
145    Directory,
146    Symlink,
147    Unknown,
148}
149
150impl WorkspaceFileType {
151    pub fn as_tool_kind(self) -> &'static str {
152        match self {
153            Self::File => "file",
154            Self::Directory => "dir",
155            Self::Symlink => "link",
156            Self::Unknown => "unknown",
157        }
158    }
159}
160
161/// Directory entry returned by a workspace backend.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct WorkspaceDirEntry {
164    pub name: String,
165    pub kind: WorkspaceFileType,
166    pub size: u64,
167}
168
169/// Result metadata for a write operation.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct WorkspaceWriteOutcome {
172    pub bytes: usize,
173    pub lines: usize,
174}
175
176/// Glob request for workspace-backed search.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct WorkspaceGlobRequest {
179    pub base: WorkspacePath,
180    pub pattern: String,
181}
182
183/// Glob result returned by a workspace search provider.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct WorkspaceGlobResult {
186    pub matches: Vec<WorkspacePath>,
187}
188
189/// Grep request for workspace-backed search.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct WorkspaceGrepRequest {
192    pub base: WorkspacePath,
193    pub pattern: String,
194    pub glob: Option<String>,
195    pub context_lines: usize,
196    pub case_insensitive: bool,
197    pub max_output_size: usize,
198}
199
200/// Grep result returned by a workspace search provider.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct WorkspaceGrepResult {
203    pub output: String,
204    pub match_count: usize,
205    pub file_count: usize,
206    pub truncated: bool,
207}
208
209/// Grep result plus structured source evidence when supplied by a backend.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct WorkspaceGrepOutcome {
212    pub result: WorkspaceGrepResult,
213    /// Distinct paths that contributed rendered match lines, in result order.
214    /// `None` denotes a legacy/custom backend with display output only.
215    pub matched_paths: Option<Vec<WorkspacePath>>,
216}
217
218/// Repository status returned by a workspace Git provider.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct WorkspaceGitStatus {
221    pub branch: String,
222    pub commit: String,
223    pub is_worktree: bool,
224    pub is_dirty: bool,
225    pub dirty_count: usize,
226}
227
228/// Commit information returned by a workspace Git provider.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct WorkspaceGitCommit {
231    pub id: String,
232    pub message: String,
233    pub author: String,
234    pub date: String,
235}
236
237/// Branch information returned by a workspace Git provider.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct WorkspaceGitBranch {
240    pub name: String,
241    pub is_current: bool,
242}
243
244/// Branch creation request for a workspace Git provider.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct WorkspaceGitCreateBranchRequest {
247    pub name: String,
248    pub base: String,
249}
250
251/// Checkout request for a workspace Git provider.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct WorkspaceGitCheckoutRequest {
254    pub refspec: String,
255    pub force: bool,
256}
257
258/// Checkout output returned by a workspace Git provider.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct WorkspaceGitCheckoutOutput {
261    pub stdout: String,
262}
263
264/// Diff request for a workspace Git provider.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct WorkspaceGitDiffRequest {
267    pub target: Option<String>,
268}
269
270/// Stash information returned by a workspace Git provider.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct WorkspaceGitStash {
273    pub index: usize,
274    pub message: String,
275}
276
277/// Stash request for a workspace Git provider.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct WorkspaceGitStashRequest {
280    pub message: Option<String>,
281    pub include_untracked: bool,
282}
283
284/// Remote information returned by a workspace Git provider.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct WorkspaceGitRemote {
287    pub name: String,
288    pub url: String,
289    pub direction: String,
290}
291
292/// Worktree information returned by a workspace Git provider.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct WorkspaceGitWorktree {
295    pub path: String,
296    pub branch: String,
297    pub is_bare: bool,
298    pub is_detached: bool,
299}
300
301/// Worktree creation request for a workspace Git provider.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct WorkspaceGitCreateWorktreeRequest {
304    pub branch: String,
305    pub path: Option<String>,
306    pub new_branch: bool,
307}
308
309/// Worktree removal request for a workspace Git provider.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct WorkspaceGitRemoveWorktreeRequest {
312    pub path: String,
313    pub force: bool,
314}
315
316/// Mutation result for workspace Git worktree operations.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct WorkspaceGitWorktreeMutation {
319    pub path: String,
320    pub branch: Option<String>,
321}
322
323/// Observer that receives streaming output deltas from a workspace command.
324///
325/// Backend implementations call this on each chunk of stdout/stderr they
326/// observe. Tool layers wire host event channels behind this trait, so the
327/// workspace abstraction does not depend on any tool event type.
328#[async_trait]
329pub trait CommandOutputObserver: Send + Sync {
330    async fn on_output_delta(&self, delta: &str);
331
332    /// Receive the final bounded-capture accounting for the command.
333    ///
334    /// The default keeps existing remote workspace runners source-compatible.
335    /// Runners that bound output should report the original byte count so
336    /// callers can distinguish a complete result from a partial observation.
337    async fn on_output_complete(&self, _summary: &CommandOutputSummary) {}
338}
339
340/// Final accounting for a bounded command-output capture.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub struct CommandOutputSummary {
343    /// Total stdout and stderr bytes observed before completion or timeout.
344    pub total_bytes: usize,
345    /// Original process bytes retained in the rendered output.
346    pub captured_bytes: usize,
347    /// Whether bytes were omitted from the middle of the rendered output.
348    pub truncated: bool,
349    /// Whether command execution reached its own deadline.
350    pub timed_out: bool,
351}
352
353/// Command execution request.
354#[derive(Clone)]
355pub struct CommandRequest {
356    pub command: String,
357    pub timeout_ms: u64,
358    pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
359    pub env: Option<Arc<HashMap<String, String>>>,
360}
361
362impl std::fmt::Debug for CommandRequest {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        f.debug_struct("CommandRequest")
365            .field("command", &self.command)
366            .field("timeout_ms", &self.timeout_ms)
367            .field("output_observer", &self.output_observer.is_some())
368            .field("env", &self.env.as_ref().map(|env| env.len()))
369            .finish()
370    }
371}
372
373/// Command execution output.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct CommandOutput {
376    pub output: String,
377    pub exit_code: i32,
378    pub timed_out: bool,
379}
380
381/// Normalizes and validates host-supplied paths before they reach a backend.
382pub trait WorkspacePathResolver: Send + Sync {
383    fn normalize(&self, input: &str) -> Result<WorkspacePath>;
384}
385
386/// File operations available to built-in file tools.
387///
388/// **Trait stability policy:** new methods added to this trait are a breaking
389/// change for every external backend implementation. Until the workspace
390/// extension story is stabilised, new methods will be added to a separate
391/// `WorkspaceFileSystemExt` trait (with default implementations that fall back
392/// to the core methods) rather than to this trait directly. Backend authors
393/// can rely on this trait surface remaining additive only through extension
394/// traits.
395#[async_trait]
396pub trait WorkspaceFileSystem: Send + Sync {
397    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String>;
398    async fn write_text(
399        &self,
400        path: &WorkspacePath,
401        content: &str,
402    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
403    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>>;
404}
405
406/// A bounded text range returned by an optional streaming workspace reader.
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct WorkspaceTextRange {
409    pub lines: Vec<String>,
410    pub next_offset: Option<usize>,
411    pub eof: bool,
412    /// Exact line count when EOF was observed while satisfying the request.
413    pub total_lines: Option<usize>,
414}
415
416/// Optional streaming text capability for backends that can avoid loading a
417/// complete file when a caller needs only a line range.
418#[async_trait]
419pub trait WorkspaceTextReader: Send + Sync {
420    async fn read_text_range(
421        &self,
422        path: &WorkspacePath,
423        offset: usize,
424        limit: usize,
425    ) -> WorkspaceResult<WorkspaceTextRange>;
426}
427
428/// Error returned by [`WorkspaceFileSystemExt::write_text_if_version`] when
429/// the underlying object version no longer matches the expected version.
430///
431/// Surfaced through `anyhow::Error`; tools recover by downcasting:
432/// `err.downcast_ref::<WorkspaceVersionConflict>()`. The typical response is
433/// to re-read the file and retry the modify-write cycle once.
434#[derive(Debug, Clone, thiserror::Error)]
435#[error(
436    "version conflict on {path}: expected version {expected:?}, found {actual:?} (file modified by another writer; re-read and retry)"
437)]
438pub struct WorkspaceVersionConflict {
439    pub path: String,
440    pub expected: String,
441    /// Backend-reported current version, if known. S3 does not return the
442    /// current ETag on `412 Precondition Failed`, so this is typically `None`.
443    pub actual: Option<String>,
444}
445
446/// Optional compare-and-swap extensions to [`WorkspaceFileSystem`].
447///
448/// Implemented by backends that expose object-level versioning (S3 ETag,
449/// future GCS generation, ...) so tools that perform read-modify-write
450/// cycles can reject concurrent overwrites. Tools should access this through
451/// [`WorkspaceServices::fs_ext`] — when absent, callers fall back to plain
452/// `read_text` / `write_text` (last-writer-wins).
453///
454/// Kept as a separate trait rather than inheriting from
455/// [`WorkspaceFileSystem`] so existing backend implementations are not
456/// forced to opt in.
457#[async_trait]
458pub trait WorkspaceFileSystemExt: Send + Sync {
459    /// Read text content together with an opaque version token. Tokens are
460    /// backend-specific (S3 returns the ETag) and treated as opaque by
461    /// callers — they are only ever compared for equality on the backend
462    /// side.
463    async fn read_text_with_version(
464        &self,
465        path: &WorkspacePath,
466    ) -> WorkspaceResult<(String, String)>;
467
468    /// Write content iff the current object version matches `expected_version`.
469    /// On mismatch the returned error is the typed
470    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
471    /// downcast through `anyhow::Error` when the value has been lifted into
472    /// the legacy result type.
473    async fn write_text_if_version(
474        &self,
475        path: &WorkspacePath,
476        content: &str,
477        expected_version: &str,
478    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
479}
480
481/// Shell/command execution available to the `bash` tool.
482#[async_trait]
483pub trait WorkspaceCommandRunner: Send + Sync {
484    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
485}
486
487/// Search operations available to `glob` and `grep`.
488#[async_trait]
489pub trait WorkspaceSearch: Send + Sync {
490    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult>;
491    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult>;
492
493    /// Run grep with structured source paths when the backend can provide them.
494    ///
495    /// The default preserves compatibility for custom backends implementing
496    /// only [`Self::grep`]. Callers must treat its display output as untrusted.
497    async fn grep_with_sources(
498        &self,
499        request: WorkspaceGrepRequest,
500    ) -> Result<WorkspaceGrepOutcome> {
501        let result = self.grep(request).await?;
502        Ok(WorkspaceGrepOutcome {
503            result,
504            matched_paths: None,
505        })
506    }
507}
508
509/// Core Git operations supported by virtually every workspace Git backend.
510///
511/// Optional features (stash, worktrees) live in separate traits so backends
512/// like browser-side `isomorphic-git` can implement only what they support
513/// instead of returning runtime "unsupported" errors.
514#[async_trait]
515pub trait WorkspaceGit: Send + Sync {
516    async fn is_repository(&self) -> Result<bool>;
517    async fn status(&self) -> Result<WorkspaceGitStatus>;
518    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>>;
519    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>>;
520    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>;
521    async fn checkout(
522        &self,
523        request: WorkspaceGitCheckoutRequest,
524    ) -> Result<WorkspaceGitCheckoutOutput>;
525    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String>;
526    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>>;
527}
528
529/// Optional Git stash operations.
530///
531/// Browser-side libraries such as `isomorphic-git` do not implement stash;
532/// backends that cannot stash simply do not implement this trait.
533#[async_trait]
534pub trait WorkspaceGitStashProvider: Send + Sync {
535    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>>;
536    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>;
537}
538
539/// Optional Git worktree operations.
540///
541/// Worktrees are a local-filesystem concept and are typically not supported
542/// by remote or browser-backed git providers.
543#[async_trait]
544pub trait WorkspaceGitWorktreeProvider: Send + Sync {
545    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>>;
546    async fn create_worktree(
547        &self,
548        request: WorkspaceGitCreateWorktreeRequest,
549    ) -> Result<WorkspaceGitWorktreeMutation>;
550    async fn remove_worktree(
551        &self,
552        request: WorkspaceGitRemoveWorktreeRequest,
553    ) -> Result<WorkspaceGitWorktreeMutation>;
554}
555
556#[cfg(test)]
557#[path = "tests.rs"]
558mod tests;