1#[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#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct WorkspaceRef {
47 pub id: String,
49 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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct WorkspaceDirEntry {
164 pub name: String,
165 pub kind: WorkspaceFileType,
166 pub size: u64,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct WorkspaceWriteOutcome {
172 pub bytes: usize,
173 pub lines: usize,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct WorkspaceGlobRequest {
179 pub base: WorkspacePath,
180 pub pattern: String,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct WorkspaceGlobResult {
186 pub matches: Vec<WorkspacePath>,
187}
188
189#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct WorkspaceGrepOutcome {
212 pub result: WorkspaceGrepResult,
213 pub matched_paths: Option<Vec<WorkspacePath>>,
216}
217
218#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct WorkspaceGitBranch {
240 pub name: String,
241 pub is_current: bool,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct WorkspaceGitCreateBranchRequest {
247 pub name: String,
248 pub base: String,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct WorkspaceGitCheckoutRequest {
254 pub refspec: String,
255 pub force: bool,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct WorkspaceGitCheckoutOutput {
261 pub stdout: String,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct WorkspaceGitDiffRequest {
267 pub target: Option<String>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct WorkspaceGitStash {
273 pub index: usize,
274 pub message: String,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct WorkspaceGitStashRequest {
280 pub message: Option<String>,
281 pub include_untracked: bool,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct WorkspaceGitRemote {
287 pub name: String,
288 pub url: String,
289 pub direction: String,
290}
291
292#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct WorkspaceGitRemoveWorktreeRequest {
312 pub path: String,
313 pub force: bool,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct WorkspaceGitWorktreeMutation {
319 pub path: String,
320 pub branch: Option<String>,
321}
322
323#[async_trait]
329pub trait CommandOutputObserver: Send + Sync {
330 async fn on_output_delta(&self, delta: &str);
331
332 async fn on_output_complete(&self, _summary: &CommandOutputSummary) {}
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub struct CommandOutputSummary {
343 pub total_bytes: usize,
345 pub captured_bytes: usize,
347 pub truncated: bool,
349 pub timed_out: bool,
351}
352
353#[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#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct CommandOutput {
376 pub output: String,
377 pub exit_code: i32,
378 pub timed_out: bool,
379}
380
381pub trait WorkspacePathResolver: Send + Sync {
383 fn normalize(&self, input: &str) -> Result<WorkspacePath>;
384}
385
386#[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#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct WorkspaceTextRange {
409 pub lines: Vec<String>,
410 pub next_offset: Option<usize>,
411 pub eof: bool,
412 pub total_lines: Option<usize>,
414}
415
416#[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#[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 pub actual: Option<String>,
444}
445
446#[async_trait]
458pub trait WorkspaceFileSystemExt: Send + Sync {
459 async fn read_text_with_version(
464 &self,
465 path: &WorkspacePath,
466 ) -> WorkspaceResult<(String, String)>;
467
468 async fn write_text_if_version(
474 &self,
475 path: &WorkspacePath,
476 content: &str,
477 expected_version: &str,
478 ) -> WorkspaceResult<WorkspaceWriteOutcome>;
479}
480
481#[async_trait]
483pub trait WorkspaceCommandRunner: Send + Sync {
484 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
485}
486
487#[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 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#[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#[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#[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;