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