Skip to main content

a3s_code_core/workspace/
services.rs

1//! Workspace service aggregation and builder.
2
3#[allow(unused_imports)]
4use super::{CommandRequest, WorkspaceError, WorkspaceVersionConflict};
5use super::{
6    LocalWorkspaceBackend, ManifestWorkspaceBackend, VirtualPathResolver, WorkspaceCapabilities,
7    WorkspaceChunkCatalog, WorkspaceCommandRunner, WorkspaceFileSystem, WorkspaceFileSystemExt,
8    WorkspaceGit, WorkspaceGitStashProvider, WorkspaceGitWorktreeProvider, WorkspacePath,
9    WorkspacePathResolver, WorkspaceRef, WorkspaceResult, WorkspaceRetrievalRuntime,
10    WorkspaceSearch, WorkspaceTextReader, WorkspaceWriteOutcome,
11};
12use crate::code_intelligence::{LocalCodeIntelligence, WorkspaceCodeIntelligence};
13use anyhow::{anyhow, Result};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17/// The host-provided workspace capability bundle used by tool execution.
18pub struct WorkspaceServices {
19    workspace_ref: WorkspaceRef,
20    capabilities: WorkspaceCapabilities,
21    path_resolver: Arc<dyn WorkspacePathResolver>,
22    file_system: Arc<dyn WorkspaceFileSystem>,
23    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
24    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
25    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
26    search: Option<Arc<dyn WorkspaceSearch>>,
27    code_intelligence: Option<Arc<dyn WorkspaceCodeIntelligence>>,
28    chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
29    workspace_retrieval: Option<Arc<WorkspaceRetrievalRuntime>>,
30    git: Option<Arc<dyn WorkspaceGit>>,
31    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
32    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
33    /// Default timeout applied to non-bash workspace operations (file system,
34    /// search, git). Bash uses its own per-call timeout in [`CommandRequest`].
35    /// `None` means no enforced timeout — appropriate for the local backend.
36    operation_timeout: Option<std::time::Duration>,
37    local_root: Option<PathBuf>,
38}
39
40impl std::fmt::Debug for WorkspaceServices {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("WorkspaceServices")
43            .field("workspace_ref", &self.workspace_ref)
44            .field("capabilities", &self.capabilities)
45            .field("file_system_ext", &self.file_system_ext.is_some())
46            .field("text_reader", &self.text_reader.is_some())
47            .field("command_runner", &self.command_runner.is_some())
48            .field("search", &self.search.is_some())
49            .field("code_intelligence", &self.code_intelligence.is_some())
50            .field("chunk_catalog", &self.chunk_catalog.is_some())
51            .field("workspace_retrieval", &self.workspace_retrieval.is_some())
52            .field("git", &self.git.is_some())
53            .field("git_stash", &self.git_stash.is_some())
54            .field("git_worktree", &self.git_worktree.is_some())
55            .field("local_root", &self.local_root)
56            .finish()
57    }
58}
59
60impl WorkspaceServices {
61    pub(crate) fn new_with_git(
62        workspace_ref: WorkspaceRef,
63        mut capabilities: WorkspaceCapabilities,
64        path_resolver: Arc<dyn WorkspacePathResolver>,
65        file_system: Arc<dyn WorkspaceFileSystem>,
66        command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
67        search: Option<Arc<dyn WorkspaceSearch>>,
68        git: Option<Arc<dyn WorkspaceGit>>,
69    ) -> Self {
70        if command_runner.is_none() {
71            capabilities.exec = false;
72        }
73        if search.is_none() {
74            capabilities.search = false;
75        }
76        if git.is_none() {
77            capabilities.git = false;
78        }
79        capabilities.code_intelligence = false;
80        Self {
81            workspace_ref,
82            capabilities,
83            path_resolver,
84            file_system,
85            file_system_ext: None,
86            text_reader: None,
87            command_runner,
88            search,
89            code_intelligence: None,
90            chunk_catalog: None,
91            workspace_retrieval: None,
92            git,
93            git_stash: None,
94            git_worktree: None,
95            operation_timeout: None,
96            local_root: None,
97        }
98    }
99
100    pub fn builder(
101        workspace_ref: WorkspaceRef,
102        file_system: Arc<dyn WorkspaceFileSystem>,
103    ) -> WorkspaceServicesBuilder {
104        WorkspaceServicesBuilder::new(workspace_ref, file_system)
105    }
106
107    pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
108        let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
109        let workspace_ref = WorkspaceRef::new(
110            backend.root.display().to_string(),
111            backend.root.display().to_string(),
112        );
113        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
114        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
115        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
116        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
117        let search: Arc<dyn WorkspaceSearch> = backend.clone();
118        let git: Arc<dyn WorkspaceGit> = backend.clone();
119        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
120        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
121        Arc::new(Self {
122            workspace_ref,
123            capabilities: WorkspaceCapabilities::local_default(),
124            path_resolver,
125            file_system,
126            file_system_ext: None,
127            text_reader: Some(text_reader),
128            command_runner: Some(command_runner),
129            search: Some(search),
130            code_intelligence: None,
131            chunk_catalog: None,
132            workspace_retrieval: None,
133            git: Some(git),
134            git_stash: Some(git_stash),
135            git_worktree: Some(git_worktree),
136            operation_timeout: None,
137            local_root: Some(backend.root.clone()),
138        })
139    }
140
141    /// Local workspace services backed by an in-memory file manifest for
142    /// search. `read`/`write`/`ls`/`bash`/`git` preserve local backend
143    /// behavior; `glob` and `grep` use the manifest once the initial scan has
144    /// completed and fall back to filesystem search before that.
145    pub fn local_with_manifest(root: impl Into<PathBuf>) -> Arc<Self> {
146        let backend = ManifestWorkspaceBackend::new(root);
147        Self::local_with_manifest_backend(backend)
148    }
149
150    /// Build local manifest services with an asynchronous ephemeral catalog.
151    pub fn local_with_retrieval(root: impl Into<PathBuf>) -> Arc<Self> {
152        let backend = ManifestWorkspaceBackend::new(root);
153        Self::local_with_retrieval_backend(backend)
154    }
155
156    /// Build local manifest-backed services with native Code Intelligence.
157    ///
158    /// The provider subscribes to the manifest's existing change stream and
159    /// therefore does not create a second filesystem watcher or file index.
160    pub async fn local_with_code_intelligence(
161        root: impl Into<PathBuf>,
162        isolation_scope: impl Into<String>,
163    ) -> Result<Arc<Self>> {
164        let backend = ManifestWorkspaceBackend::new(root);
165        Self::local_with_code_intelligence_backend(backend, isolation_scope).await
166    }
167
168    /// Build one local session with retrieval and Code Intelligence sharing
169    /// the same manifest scanner and filesystem change stream.
170    pub async fn local_with_retrieval_and_code_intelligence(
171        root: impl Into<PathBuf>,
172        isolation_scope: impl Into<String>,
173    ) -> Result<Arc<Self>> {
174        let backend = ManifestWorkspaceBackend::new(root);
175        Self::local_with_retrieval_and_code_intelligence_backend(backend, isolation_scope).await
176    }
177
178    /// Attach native Code Intelligence to one shared manifest backend.
179    pub async fn local_with_code_intelligence_backend(
180        backend: Arc<ManifestWorkspaceBackend>,
181        isolation_scope: impl Into<String>,
182    ) -> Result<Arc<Self>> {
183        let manifest = backend.manifest();
184        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
185        let services = Self::local_with_manifest_backend(backend);
186        let provider = LocalCodeIntelligence::start(isolation_scope, manifest, file_system)
187            .await
188            .map_err(|error| anyhow!("failed to start Code Intelligence: {error}"))?;
189        Ok(services.with_code_intelligence(provider))
190    }
191
192    /// Attach retrieval and Code Intelligence to one shared manifest backend.
193    pub async fn local_with_retrieval_and_code_intelligence_backend(
194        backend: Arc<ManifestWorkspaceBackend>,
195        isolation_scope: impl Into<String>,
196    ) -> Result<Arc<Self>> {
197        let manifest = backend.manifest();
198        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
199        let services = Self::local_with_retrieval_backend(backend);
200        let provider = LocalCodeIntelligence::start(isolation_scope, manifest, file_system)
201            .await
202            .map_err(|error| anyhow!("failed to start Code Intelligence: {error}"))?;
203        Ok(services.with_code_intelligence(provider))
204    }
205
206    /// Build local workspace services from a shared manifest backend. Hosts
207    /// can keep the same manifest for UI file pickers and agent tools.
208    pub fn local_with_manifest_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
209        Self::local_with_manifest_backend_and_catalog(backend, None)
210    }
211
212    /// Enable retrieval on a shared manifest backend.
213    pub fn local_with_retrieval_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
214        let catalog = backend.chunk_catalog();
215        Self::local_with_manifest_backend_and_catalog(backend, Some(catalog))
216    }
217
218    fn local_with_manifest_backend_and_catalog(
219        backend: Arc<ManifestWorkspaceBackend>,
220        chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
221    ) -> Arc<Self> {
222        let workspace_ref = WorkspaceRef::new(
223            backend.local_root().display().to_string(),
224            backend.local_root().display().to_string(),
225        );
226        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
227        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
228        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
229        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
230        let search: Arc<dyn WorkspaceSearch> = backend.clone();
231        let git: Arc<dyn WorkspaceGit> = backend.clone();
232        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
233        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
234        Arc::new(Self {
235            workspace_ref,
236            capabilities: WorkspaceCapabilities::local_default(),
237            path_resolver,
238            file_system,
239            file_system_ext: None,
240            text_reader: Some(text_reader),
241            command_runner: Some(command_runner),
242            search: Some(search),
243            code_intelligence: None,
244            chunk_catalog,
245            workspace_retrieval: None,
246            git: Some(git),
247            git_stash: Some(git_stash),
248            git_worktree: Some(git_worktree),
249            operation_timeout: None,
250            local_root: Some(backend.local_root().to_path_buf()),
251        })
252    }
253
254    pub fn workspace_ref(&self) -> &WorkspaceRef {
255        &self.workspace_ref
256    }
257
258    pub fn capabilities(&self) -> WorkspaceCapabilities {
259        self.capabilities
260    }
261
262    pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
263        self.path_resolver.normalize(input)
264    }
265
266    pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
267        Arc::clone(&self.file_system)
268    }
269
270    /// Optional compare-and-swap file system extensions.
271    ///
272    /// Returns `Some` when the backend supports version-aware writes (e.g.
273    /// S3 via ETag). Tools that perform read-modify-write cycles should
274    /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`]
275    /// rather than touching this directly.
276    pub fn fs_ext(&self) -> Option<Arc<dyn WorkspaceFileSystemExt>> {
277        self.file_system_ext.clone()
278    }
279
280    pub fn text_reader(&self) -> Option<Arc<dyn WorkspaceTextReader>> {
281        self.text_reader.clone()
282    }
283
284    pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
285        self.command_runner.clone()
286    }
287
288    pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
289        self.search.clone()
290    }
291
292    /// Optional workspace-scoped semantic code query provider.
293    pub fn code_intelligence(&self) -> Option<Arc<dyn WorkspaceCodeIntelligence>> {
294        self.code_intelligence.clone()
295    }
296
297    /// Optional session-local catalog shared by lexical and semantic retrieval.
298    pub fn chunk_catalog(&self) -> Option<Arc<WorkspaceChunkCatalog>> {
299        self.chunk_catalog.clone()
300    }
301
302    /// Optional semantic retrieval runtime bound to this workspace session.
303    pub fn workspace_retrieval(&self) -> Option<Arc<WorkspaceRetrievalRuntime>> {
304        self.workspace_retrieval.clone()
305    }
306
307    /// Search the session-owned semantic index and verify every returned
308    /// source chunk against the workspace backend before exposing its text.
309    pub async fn semantic_search(
310        &self,
311        mut request: super::WorkspaceSemanticSearchRequest,
312        cancellation: tokio_util::sync::CancellationToken,
313    ) -> super::WorkspaceRetrievalResult<super::WorkspaceSemanticSearchResult> {
314        if !self.capabilities.read {
315            return Err(super::WorkspaceRetrievalError::Unavailable);
316        }
317        if let Some(path) = request.path.take() {
318            request.path = Some(
319                self.path_resolver
320                    .normalize(&path)
321                    .map_err(|_| {
322                        super::WorkspaceRetrievalError::InvalidQuery(
323                            "path was rejected by the workspace resolver".to_owned(),
324                        )
325                    })?
326                    .as_str()
327                    .to_owned(),
328            );
329        }
330        let runtime = self
331            .workspace_retrieval
332            .as_ref()
333            .ok_or(super::WorkspaceRetrievalError::Unavailable)?;
334        runtime
335            .search(
336                request,
337                Arc::clone(&self.file_system),
338                self.operation_timeout,
339                cancellation,
340            )
341            .await
342    }
343
344    /// Run exact, lexical, structural, and semantic retrieval against one
345    /// catalog revision, fuse channel ranks, and verify returned source text.
346    pub async fn hybrid_search(
347        &self,
348        mut request: super::WorkspaceHybridSearchRequest,
349        cancellation: tokio_util::sync::CancellationToken,
350    ) -> super::WorkspaceRetrievalResult<super::WorkspaceHybridSearchResult> {
351        if !self.capabilities.read {
352            return Err(super::WorkspaceRetrievalError::Unavailable);
353        }
354        if let Some(path) = request.path.take() {
355            request.path = Some(
356                self.path_resolver
357                    .normalize(&path)
358                    .map_err(|_| {
359                        super::WorkspaceRetrievalError::InvalidQuery(
360                            "path was rejected by the workspace resolver".to_owned(),
361                        )
362                    })?
363                    .as_str()
364                    .to_owned(),
365            );
366        }
367        let runtime = self
368            .workspace_retrieval
369            .as_ref()
370            .ok_or(super::WorkspaceRetrievalError::Unavailable)?;
371        runtime
372            .hybrid_search(
373                request,
374                Arc::clone(&self.file_system),
375                self.code_intelligence.clone(),
376                self.operation_timeout,
377                cancellation,
378            )
379            .await
380    }
381
382    pub(crate) fn with_workspace_retrieval(
383        &self,
384        runtime: Arc<WorkspaceRetrievalRuntime>,
385    ) -> Option<Arc<Self>> {
386        if self.workspace_retrieval.is_some() {
387            return None;
388        }
389        Some(Arc::new(Self {
390            workspace_ref: self.workspace_ref.clone(),
391            capabilities: self.capabilities,
392            path_resolver: Arc::clone(&self.path_resolver),
393            file_system: Arc::clone(&self.file_system),
394            file_system_ext: self.file_system_ext.clone(),
395            text_reader: self.text_reader.clone(),
396            command_runner: self.command_runner.clone(),
397            search: self.search.clone(),
398            code_intelligence: self.code_intelligence.clone(),
399            chunk_catalog: self.chunk_catalog.clone(),
400            workspace_retrieval: Some(runtime),
401            git: self.git.clone(),
402            git_stash: self.git_stash.clone(),
403            git_worktree: self.git_worktree.clone(),
404            operation_timeout: self.operation_timeout,
405            local_root: self.local_root.clone(),
406        }))
407    }
408
409    /// Attach a semantic code query provider while preserving every existing
410    /// workspace capability and backend.
411    pub fn with_code_intelligence(
412        &self,
413        provider: Arc<dyn WorkspaceCodeIntelligence>,
414    ) -> Arc<Self> {
415        let mut capabilities = self.capabilities;
416        capabilities.code_intelligence = true;
417        Arc::new(Self {
418            workspace_ref: self.workspace_ref.clone(),
419            capabilities,
420            path_resolver: Arc::clone(&self.path_resolver),
421            file_system: Arc::clone(&self.file_system),
422            file_system_ext: self.file_system_ext.clone(),
423            text_reader: self.text_reader.clone(),
424            command_runner: self.command_runner.clone(),
425            search: self.search.clone(),
426            code_intelligence: Some(provider),
427            chunk_catalog: self.chunk_catalog.clone(),
428            workspace_retrieval: self.workspace_retrieval.clone(),
429            git: self.git.clone(),
430            git_stash: self.git_stash.clone(),
431            git_worktree: self.git_worktree.clone(),
432            operation_timeout: self.operation_timeout,
433            local_root: self.local_root.clone(),
434        })
435    }
436
437    pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
438        self.git.clone()
439    }
440
441    pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
442        self.git_stash.clone()
443    }
444
445    pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
446        self.git_worktree.clone()
447    }
448
449    /// Internal helper used by decorators (`with_remote_git` and any
450    /// future git-provider override) to swap the git layer of an existing
451    /// `WorkspaceServices` without losing unrelated fields.
452    ///
453    /// Every field is **explicitly listed** in the returned struct
454    /// literal. This is the point of the helper — adding a new field to
455    /// `WorkspaceServices` will trip a compile error here, and the author
456    /// of that new field has to decide whether a git-provider swap
457    /// preserves it. Previously the decorator went through
458    /// `WorkspaceServicesBuilder`, which silently dropped any field the
459    /// builder did not know about (notably `local_root`).
460    ///
461    /// `git_worktree` is reset to `None` because worktree operations are
462    /// part of the same domain as the git provider — keeping the local
463    /// worktree provider while routing `status`/`log`/`diff` to a remote
464    /// server would surface inconsistent state to the model.
465    pub(crate) fn with_git_provider(
466        &self,
467        git: Arc<dyn WorkspaceGit>,
468        git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
469    ) -> Arc<Self> {
470        let mut capabilities = self.capabilities;
471        capabilities.git = true;
472        Arc::new(Self {
473            workspace_ref: self.workspace_ref.clone(),
474            capabilities,
475            path_resolver: Arc::clone(&self.path_resolver),
476            file_system: Arc::clone(&self.file_system),
477            file_system_ext: self.file_system_ext.clone(),
478            text_reader: self.text_reader.clone(),
479            command_runner: self.command_runner.clone(),
480            search: self.search.clone(),
481            code_intelligence: self.code_intelligence.clone(),
482            chunk_catalog: self.chunk_catalog.clone(),
483            workspace_retrieval: self.workspace_retrieval.clone(),
484            git: Some(git),
485            git_stash,
486            git_worktree: None,
487            operation_timeout: self.operation_timeout,
488            local_root: self.local_root.clone(),
489        })
490    }
491
492    /// Default timeout applied to non-bash workspace operations.
493    ///
494    /// `None` means no enforced timeout. Backends that may stall (remote,
495    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
496    /// surface a timeout error instead of letting the agent loop hang.
497    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
498        self.operation_timeout
499    }
500
501    /// Run a workspace future under the configured operation timeout.
502    ///
503    /// Tools that route through file system / search / git providers should
504    /// wrap their calls with this helper so non-local backends never stall
505    /// the agent loop indefinitely.
506    ///
507    /// Polymorphic in the error type so the helper works equally well for
508    /// futures returning `anyhow::Result<T>` (the legacy callers — search,
509    /// git, etc.) and for futures returning [`WorkspaceResult<T>`] (the
510    /// migrated `WorkspaceFileSystem` callers). The `E: From<anyhow::Error>`
511    /// bound is satisfied by both `anyhow::Error` (trivially) and
512    /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout
513    /// surfaces as that From conversion of an `anyhow!(...)` message.
514    pub async fn run_with_timeout<F, T, E>(
515        &self,
516        op: &'static str,
517        fut: F,
518    ) -> std::result::Result<T, E>
519    where
520        F: std::future::Future<Output = std::result::Result<T, E>>,
521        E: From<anyhow::Error>,
522    {
523        match self.operation_timeout {
524            Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
525                E::from(anyhow!(
526                    "workspace operation '{}' timed out after {:?}",
527                    op,
528                    d
529                ))
530            })?,
531            None => fut.await,
532        }
533    }
534
535    /// Read a file for a subsequent modify-write cycle, requesting a version
536    /// token when the backend supports compare-and-swap writes.
537    ///
538    /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available
539    /// (e.g. on S3, where the version is the object ETag); `(content, None)`
540    /// otherwise. Pair with [`Self::write_for_edit`].
541    pub async fn read_for_edit(
542        &self,
543        path: &WorkspacePath,
544    ) -> WorkspaceResult<(String, Option<String>)> {
545        if let Some(ext) = self.fs_ext() {
546            let path = path.clone();
547            return self
548                .run_with_timeout("read_text_with_version", async move {
549                    let (content, version) = ext.read_text_with_version(&path).await?;
550                    Ok((content, Some(version)))
551                })
552                .await;
553        }
554        let fs = self.fs();
555        let path_owned = path.clone();
556        let content = self
557            .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await })
558            .await?;
559        Ok((content, None))
560    }
561
562    /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap
563    /// write when both [`Self::fs_ext`] is available *and* a version token
564    /// was returned by the prior read; falls back to a plain write
565    /// otherwise. On version mismatch the returned error is the typed
566    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
567    /// downcast `anyhow::Error::downcast_ref::<WorkspaceVersionConflict>()`
568    /// when the value has been lifted into an `anyhow::Result`.
569    pub async fn write_for_edit(
570        &self,
571        path: &WorkspacePath,
572        content: &str,
573        expected_version: Option<&str>,
574    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
575        if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) {
576            let path = path.clone();
577            let content = content.to_string();
578            let expected = version.to_string();
579            return self
580                .run_with_timeout("write_text_if_version", async move {
581                    ext.write_text_if_version(&path, &content, &expected).await
582                })
583                .await;
584        }
585        let fs = self.fs();
586        let path = path.clone();
587        let content = content.to_string();
588        self.run_with_timeout(
589            "write_text",
590            async move { fs.write_text(&path, &content).await },
591        )
592        .await
593    }
594
595    pub fn local_root(&self) -> Option<&Path> {
596        self.local_root.as_deref()
597    }
598
599    pub fn display_path(&self, path: &WorkspacePath) -> String {
600        if path.is_root() {
601            return self.workspace_ref.display_root.clone();
602        }
603
604        let root = self.workspace_ref.display_root.trim_end_matches('/');
605        if root.is_empty() {
606            path.as_str().to_string()
607        } else {
608            format!("{root}/{}", path.as_str())
609        }
610    }
611}
612
613/// Builder for assembling workspace services without constructor arity churn.
614pub struct WorkspaceServicesBuilder {
615    workspace_ref: WorkspaceRef,
616    capabilities: WorkspaceCapabilities,
617    path_resolver: Arc<dyn WorkspacePathResolver>,
618    file_system: Arc<dyn WorkspaceFileSystem>,
619    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
620    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
621    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
622    search: Option<Arc<dyn WorkspaceSearch>>,
623    code_intelligence: Option<Arc<dyn WorkspaceCodeIntelligence>>,
624    chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
625    git: Option<Arc<dyn WorkspaceGit>>,
626    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
627    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
628    operation_timeout: Option<std::time::Duration>,
629}
630
631impl WorkspaceServicesBuilder {
632    pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
633        Self {
634            workspace_ref,
635            capabilities: WorkspaceCapabilities::read_write(),
636            path_resolver: Arc::new(VirtualPathResolver),
637            file_system,
638            file_system_ext: None,
639            text_reader: None,
640            command_runner: None,
641            search: None,
642            code_intelligence: None,
643            chunk_catalog: None,
644            git: None,
645            git_stash: None,
646            git_worktree: None,
647            operation_timeout: None,
648        }
649    }
650
651    pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
652        self.capabilities = capabilities;
653        self
654    }
655
656    pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
657        self.capabilities.exec = true;
658        self.command_runner = Some(command_runner);
659        self
660    }
661
662    pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
663        self.capabilities.search = true;
664        self.search = Some(search);
665        self
666    }
667
668    pub fn code_intelligence(mut self, provider: Arc<dyn WorkspaceCodeIntelligence>) -> Self {
669        self.capabilities.code_intelligence = true;
670        self.code_intelligence = Some(provider);
671        self
672    }
673
674    /// Attach a typed, session-local workspace retrieval catalog.
675    pub fn chunk_catalog(mut self, catalog: Arc<WorkspaceChunkCatalog>) -> Self {
676        self.chunk_catalog = Some(catalog);
677        self
678    }
679
680    pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
681        self.capabilities.git = true;
682        self.git = Some(git);
683        self
684    }
685
686    pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
687        self.git_stash = Some(git_stash);
688        self
689    }
690
691    pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
692        self.git_worktree = Some(git_worktree);
693        self
694    }
695
696    /// Attach optional compare-and-swap file system extensions
697    /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write
698    /// cycles will pick this up via [`WorkspaceServices::read_for_edit`]
699    /// and [`WorkspaceServices::write_for_edit`].
700    pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
701        self.file_system_ext = Some(ext);
702        self
703    }
704
705    pub fn text_reader(mut self, reader: Arc<dyn WorkspaceTextReader>) -> Self {
706        self.text_reader = Some(reader);
707        self
708    }
709
710    /// Apply a default timeout to non-bash workspace operations (file system,
711    /// search, git). Backends that may stall — remote, browser, DFS — should
712    /// set this so tools surface a timeout error rather than hanging.
713    pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
714        self.operation_timeout = Some(timeout);
715        self
716    }
717
718    pub fn build(self) -> Arc<WorkspaceServices> {
719        let mut services = WorkspaceServices::new_with_git(
720            self.workspace_ref,
721            self.capabilities,
722            self.path_resolver,
723            self.file_system,
724            self.command_runner,
725            self.search,
726            self.git,
727        );
728        services.file_system_ext = self.file_system_ext;
729        services.text_reader = self.text_reader;
730        services.capabilities.code_intelligence = self.code_intelligence.is_some();
731        services.code_intelligence = self.code_intelligence;
732        services.chunk_catalog = self.chunk_catalog;
733        services.workspace_retrieval = None;
734        services.git_stash = self.git_stash;
735        services.git_worktree = self.git_worktree;
736        services.operation_timeout = self.operation_timeout;
737        Arc::new(services)
738    }
739}