Skip to main content

molo_coding/coding/
workspace.rs

1use crate::RunMetadata;
2use async_trait::async_trait;
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::collections::{BTreeMap, BTreeSet};
5use std::fmt;
6use std::path::{Component, Path, PathBuf};
7use std::sync::{Arc, Mutex};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// Canonical root directory that bounds workspace filesystem access.
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct WorkspaceRoot {
13    absolute: PathBuf,
14}
15
16impl WorkspaceRoot {
17    /// Canonicalizes and validates an existing workspace root directory.
18    ///
19    /// # Errors
20    ///
21    /// Returns [`WorkspaceError::NotFound`] when the path does not exist,
22    /// [`WorkspaceError::Unsupported`] when it is not a directory, and
23    /// [`WorkspaceError::Io`] for canonicalization failures.
24    pub fn new(path: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
25        let path = path.as_ref();
26        let canonical = std::fs::canonicalize(path).map_err(|error| WorkspaceError::Io {
27            message: format!("failed to canonicalize workspace root: {error}"),
28        })?;
29        let metadata = std::fs::metadata(&canonical).map_err(|error| WorkspaceError::Io {
30            message: format!("failed to inspect workspace root: {error}"),
31        })?;
32        if !metadata.is_dir() {
33            return Err(WorkspaceError::Unsupported {
34                message: format!("workspace root is not a directory: {}", canonical.display()),
35            });
36        }
37        Ok(Self {
38            absolute: canonical,
39        })
40    }
41
42    /// Returns the absolute canonical root path.
43    pub fn as_path(&self) -> &Path {
44        &self.absolute
45    }
46
47    /// Joins a validated workspace path onto this root.
48    pub fn join(&self, path: &WorkspacePath) -> PathBuf {
49        self.absolute.join(path.as_path())
50    }
51
52    fn strip_absolute(&self, path: &Path) -> Result<WorkspacePath, WorkspaceError> {
53        let relative =
54            path.strip_prefix(&self.absolute)
55                .map_err(|_| WorkspaceError::OutsideRoot {
56                    path: path.display().to_string(),
57                    root: self.absolute.display().to_string(),
58                })?;
59        WorkspacePath::from_relative_pathbuf(relative.to_path_buf())
60    }
61}
62
63impl Serialize for WorkspaceRoot {
64    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
65    where
66        S: Serializer,
67    {
68        let Some(path) = self.absolute.to_str() else {
69            return Err(serde::ser::Error::custom(
70                "workspace root path is not valid UTF-8",
71            ));
72        };
73        serializer.serialize_str(path)
74    }
75}
76
77impl<'de> Deserialize<'de> for WorkspaceRoot {
78    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
79    where
80        D: Deserializer<'de>,
81    {
82        let path = String::deserialize(deserializer)?;
83        WorkspaceRoot::new(path).map_err(serde::de::Error::custom)
84    }
85}
86
87/// A root-relative path validated for workspace operations.
88#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
89pub struct WorkspacePath {
90    relative: PathBuf,
91}
92
93impl fmt::Debug for WorkspacePath {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.debug_tuple("WorkspacePath")
96            .field(&self.display())
97            .finish()
98    }
99}
100
101impl WorkspacePath {
102    /// Returns the workspace root path.
103    pub fn root() -> Self {
104        Self {
105            relative: PathBuf::new(),
106        }
107    }
108
109    /// Parses a model- or user-facing path as a root-relative workspace path.
110    ///
111    /// Absolute paths, `.` and `..` traversal, empty components, NUL bytes,
112    /// Windows-style separators, and platform roots or prefixes are rejected.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`WorkspaceError::InvalidPath`] when the path is not a safe
117    /// root-relative path.
118    pub fn parse(path: impl AsRef<str>) -> Result<Self, WorkspaceError> {
119        let path = path.as_ref();
120        if path.is_empty() {
121            return Ok(Self::root());
122        }
123        if path.contains('\0') {
124            return Err(WorkspaceError::InvalidPath {
125                message: "workspace path contains NUL byte".to_string(),
126            });
127        }
128        if path.contains('\\') {
129            return Err(WorkspaceError::InvalidPath {
130                message: "workspace path must use forward slashes".to_string(),
131            });
132        }
133        if path.split('/').any(str::is_empty) {
134            return Err(WorkspaceError::InvalidPath {
135                message: "workspace path contains an empty component".to_string(),
136            });
137        }
138        if path.split('/').any(|component| component == ".") {
139            return Err(WorkspaceError::InvalidPath {
140                message: "workspace path must not contain `.`".to_string(),
141            });
142        }
143        if path.split('/').any(|component| component == "..") {
144            return Err(WorkspaceError::InvalidPath {
145                message: "workspace path must not contain `..`".to_string(),
146            });
147        }
148
149        let candidate = Path::new(path);
150        if candidate.is_absolute() {
151            return Err(WorkspaceError::InvalidPath {
152                message: "workspace path must be relative".to_string(),
153            });
154        }
155
156        let mut relative = PathBuf::new();
157        for component in candidate.components() {
158            match component {
159                Component::Normal(part) => relative.push(part),
160                Component::CurDir => {
161                    return Err(WorkspaceError::InvalidPath {
162                        message: "workspace path must not contain `.`".to_string(),
163                    });
164                }
165                Component::ParentDir => {
166                    return Err(WorkspaceError::InvalidPath {
167                        message: "workspace path must not contain `..`".to_string(),
168                    });
169                }
170                Component::RootDir | Component::Prefix(_) => {
171                    return Err(WorkspaceError::InvalidPath {
172                        message: "workspace path must not contain a root or prefix".to_string(),
173                    });
174                }
175            }
176        }
177        Self::from_relative_pathbuf(relative)
178    }
179
180    /// Returns the path as a root-relative [`Path`].
181    pub fn as_path(&self) -> &Path {
182        &self.relative
183    }
184
185    /// Returns a UTF-8 display form suitable for JSON payloads.
186    pub fn display(&self) -> String {
187        if self.relative.as_os_str().is_empty() {
188            return String::new();
189        }
190        self.relative
191            .to_string_lossy()
192            .replace(std::path::MAIN_SEPARATOR, "/")
193    }
194
195    /// Appends a single relative component path and validates the result.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`WorkspaceError::InvalidPath`] if the resulting path would no
200    /// longer be a valid workspace path.
201    pub fn join(&self, path: impl AsRef<str>) -> Result<Self, WorkspaceError> {
202        let suffix = WorkspacePath::parse(path)?;
203        if self.relative.as_os_str().is_empty() {
204            return Ok(suffix);
205        }
206        if suffix.relative.as_os_str().is_empty() {
207            return Ok(self.clone());
208        }
209        Self::from_relative_pathbuf(self.relative.join(suffix.relative))
210    }
211
212    fn parent(&self) -> Self {
213        self.relative
214            .parent()
215            .map(|path| Self {
216                relative: path.to_path_buf(),
217            })
218            .unwrap_or_else(Self::root)
219    }
220
221    fn from_relative_pathbuf(relative: PathBuf) -> Result<Self, WorkspaceError> {
222        if relative.is_absolute() {
223            return Err(WorkspaceError::InvalidPath {
224                message: "workspace path must be relative".to_string(),
225            });
226        }
227        for component in relative.components() {
228            match component {
229                Component::Normal(_) => {}
230                Component::CurDir => {
231                    return Err(WorkspaceError::InvalidPath {
232                        message: "workspace path must not contain `.`".to_string(),
233                    });
234                }
235                Component::ParentDir => {
236                    return Err(WorkspaceError::InvalidPath {
237                        message: "workspace path must not contain `..`".to_string(),
238                    });
239                }
240                Component::RootDir | Component::Prefix(_) => {
241                    return Err(WorkspaceError::InvalidPath {
242                        message: "workspace path must not contain a root or prefix".to_string(),
243                    });
244                }
245            }
246        }
247        if relative.as_os_str().is_empty() {
248            return Ok(Self::root());
249        }
250        if relative.to_str().is_none() {
251            return Err(WorkspaceError::NonUtf8Path {
252                path: relative.display().to_string(),
253            });
254        }
255        Ok(Self { relative })
256    }
257}
258
259impl Serialize for WorkspacePath {
260    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
261    where
262        S: Serializer,
263    {
264        serializer.serialize_str(&self.display())
265    }
266}
267
268impl<'de> Deserialize<'de> for WorkspacePath {
269    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
270    where
271        D: Deserializer<'de>,
272    {
273        let path = String::deserialize(deserializer)?;
274        WorkspacePath::parse(path).map_err(serde::de::Error::custom)
275    }
276}
277
278impl fmt::Display for WorkspacePath {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        f.write_str(&self.display())
281    }
282}
283
284/// Resolved workspace path with canonicalization metadata.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct ResolvedPath {
287    /// Workspace root used for resolution.
288    pub root: WorkspaceRoot,
289    /// Original validated workspace path.
290    pub workspace_path: WorkspacePath,
291    /// Absolute path selected for the requested access.
292    pub absolute: PathBuf,
293    /// Filesystem kind observed at resolution time.
294    pub kind: ResolvedPathKind,
295    /// Canonical symlink target, when the path itself is a symlink.
296    pub symlink_target: Option<PathBuf>,
297}
298
299/// Filesystem kind observed when resolving a workspace path.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[non_exhaustive]
302pub enum ResolvedPathKind {
303    /// Regular file.
304    File,
305    /// Directory.
306    Directory,
307    /// Symlink.
308    Symlink,
309    /// Path does not exist.
310    Missing,
311}
312
313/// Symlink behavior for local workspace operations.
314#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
315#[non_exhaustive]
316pub enum SymlinkPolicy {
317    /// Read and list may follow symlinks whose canonical target remains
318    /// inside the workspace root.
319    #[default]
320    FollowReadInsideRoot,
321    /// Do not follow symlinks.
322    NoFollow,
323    /// Reject any operation targeting a symlink.
324    RejectAll,
325}
326
327/// Requested workspace access mode.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[non_exhaustive]
330pub enum WorkspaceAccess {
331    /// Read file content or metadata.
332    Read,
333    /// List directory entries.
334    List,
335    /// Create a new file.
336    Create,
337    /// Modify an existing file.
338    Modify,
339    /// Delete an existing file.
340    Delete,
341}
342
343/// Encoding of text file content.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[non_exhaustive]
346pub enum TextEncoding {
347    /// UTF-8 text.
348    Utf8,
349}
350
351/// File content returned by a workspace read.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct FileContent {
354    /// File path.
355    pub path: WorkspacePath,
356    /// Version observed while reading.
357    pub version: FileVersion,
358    /// File body.
359    pub body: FileBody,
360    /// Whether the body was truncated by byte budget.
361    pub truncated: bool,
362    /// Host-owned metadata.
363    pub metadata: RunMetadata,
364}
365
366/// File body with text and binary separated explicitly.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[non_exhaustive]
369pub enum FileBody {
370    /// UTF-8 text body.
371    Text {
372        /// Text content.
373        text: String,
374        /// Text encoding.
375        encoding: TextEncoding,
376    },
377    /// Binary body. When binary reads are not explicitly enabled, `bytes`
378    /// is empty and metadata/version still describe the file.
379    Binary {
380        /// Binary bytes returned to the caller.
381        bytes: Vec<u8>,
382        /// Optional media type.
383        media_type: Option<String>,
384    },
385}
386
387/// Stable content digest used in file version preconditions.
388#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
389pub struct ContentDigest {
390    /// Digest algorithm name.
391    pub algorithm: String,
392    /// Hex-encoded digest.
393    pub value: String,
394}
395
396/// File version used to detect stale writes and patch conflicts.
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct FileVersion {
399    /// File path.
400    pub path: WorkspacePath,
401    /// Content digest.
402    pub digest: ContentDigest,
403    /// File byte length.
404    pub len: u64,
405    /// Last modification time, when available.
406    pub modified: Option<SystemTime>,
407}
408
409/// Options for workspace file reads.
410#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
411pub struct FileReadOptions {
412    /// Maximum bytes to read before truncating.
413    pub max_bytes: Option<usize>,
414    /// Whether binary bytes may be returned.
415    pub include_binary: bool,
416}
417
418/// Content to write into a workspace file.
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420#[non_exhaustive]
421pub enum FileWriteContent {
422    /// UTF-8 text.
423    Text(String),
424    /// Raw bytes.
425    Bytes(Vec<u8>),
426}
427
428impl FileWriteContent {
429    fn into_bytes(self) -> Vec<u8> {
430        match self {
431            Self::Text(text) => text.into_bytes(),
432            Self::Bytes(bytes) => bytes,
433        }
434    }
435}
436
437/// Request to write a workspace file.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub struct WriteFileRequest {
440    /// File path.
441    pub path: WorkspacePath,
442    /// Content to write.
443    pub content: FileWriteContent,
444    /// Required currently observed version.
445    pub expected_version: Option<FileVersion>,
446    /// Whether a missing file may be created.
447    pub create: bool,
448    /// Whether an existing file may be overwritten.
449    pub overwrite: bool,
450}
451
452/// Result of a successful workspace write.
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454pub struct FileWriteResult {
455    /// File path.
456    pub path: WorkspacePath,
457    /// Version before writing, when the file existed.
458    pub previous_version: Option<FileVersion>,
459    /// Version after writing.
460    pub new_version: FileVersion,
461    /// Whether the file was newly created.
462    pub created: bool,
463    /// Number of bytes written.
464    pub bytes_written: u64,
465    /// Host-owned metadata.
466    pub metadata: RunMetadata,
467}
468
469/// Query for deterministic workspace listing.
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
471pub struct ListFilesQuery {
472    /// Directory or file path to list.
473    pub path: WorkspacePath,
474    /// Whether to recurse into directories.
475    pub recursive: bool,
476    /// Maximum number of entries returned.
477    pub max_entries: Option<usize>,
478    /// Whether hidden path components are included.
479    pub include_hidden: bool,
480    /// Whether simple `.gitignore` patterns are respected.
481    pub respect_gitignore: bool,
482}
483
484/// Workspace entry returned by listing.
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486pub struct WorkspaceEntry {
487    /// Entry path.
488    pub path: WorkspacePath,
489    /// Entry kind.
490    pub kind: ResolvedPathKind,
491    /// File length, when available.
492    pub len: Option<u64>,
493}
494
495/// Request for a lightweight workspace snapshot.
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
497pub struct SnapshotRequest {
498    /// Paths to include. Empty means the workspace root.
499    pub paths: Vec<WorkspacePath>,
500    /// Whether directory paths are captured recursively.
501    pub recursive: bool,
502}
503
504/// Lightweight snapshot of file versions.
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506pub struct WorkspaceSnapshot {
507    /// Snapshot id.
508    pub id: String,
509    /// Workspace root.
510    pub root: WorkspaceRoot,
511    /// File versions.
512    pub files: Vec<FileVersion>,
513    /// Git head at capture time, when known.
514    pub git_head: Option<String>,
515    /// Host-owned metadata.
516    pub metadata: RunMetadata,
517}
518
519/// Request to diff two snapshots.
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521pub struct DiffRequest {
522    /// Earlier snapshot.
523    pub before: WorkspaceSnapshot,
524    /// Later snapshot.
525    pub after: WorkspaceSnapshot,
526}
527
528/// Workspace diff summary.
529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530pub struct WorkspaceDiff {
531    /// Changed paths.
532    pub changed_files: Vec<WorkspacePath>,
533    /// Text summary suitable for users and models.
534    pub text: String,
535    /// Whether the diff text was truncated.
536    pub truncated: bool,
537    /// Host-owned metadata.
538    pub metadata: RunMetadata,
539}
540
541/// Structured patch containing one or more file patches.
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
543pub struct Patch {
544    /// File patches.
545    pub files: Vec<FilePatch>,
546    /// Original patch text, when imported from a textual format.
547    pub original_text: Option<String>,
548    /// Host-owned metadata.
549    pub metadata: RunMetadata,
550}
551
552/// Patch for one file.
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554pub struct FilePatch {
555    /// Destination path, or current path for non-rename operations.
556    pub path: WorkspacePath,
557    /// Patch operation.
558    pub operation: PatchOperation,
559    /// Required version for the file being modified, deleted, or renamed.
560    pub expected_version: Option<FileVersion>,
561    /// Text hunks applied in order.
562    pub hunks: Vec<PatchHunk>,
563}
564
565/// File patch operation.
566#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
567#[non_exhaustive]
568pub enum PatchOperation {
569    /// Create a new file.
570    Create,
571    /// Modify an existing file.
572    Modify,
573    /// Delete an existing file.
574    Delete,
575    /// Rename an existing file to `FilePatch::path`.
576    Rename {
577        /// Source path.
578        from: WorkspacePath,
579    },
580}
581
582/// Text hunk used by the local workspace patch applier.
583#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
584pub struct PatchHunk {
585    /// Text expected in the current file. For create operations this may be
586    /// empty.
587    pub old_text: String,
588    /// Replacement text.
589    pub new_text: String,
590}
591
592/// Request to apply or dry-run a patch.
593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct PatchRequest {
595    /// Patch to apply.
596    pub patch: Patch,
597    /// Validate without writing.
598    pub dry_run: bool,
599    /// Whether non-conflicting file patches may be applied when another file
600    /// conflicts. The local workspace currently reports conflicts without
601    /// partial writes.
602    pub allow_partial: bool,
603}
604
605/// Patch conflict with model-safe details.
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607pub struct PatchConflict {
608    /// Path that conflicted.
609    pub path: WorkspacePath,
610    /// Human-readable conflict explanation.
611    pub message: String,
612    /// Expected version, when supplied.
613    pub expected_version: Option<FileVersion>,
614    /// Actual version, when observed.
615    pub actual_version: Option<FileVersion>,
616}
617
618/// Result from applying or dry-running a patch.
619#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
620pub struct PatchResult {
621    /// Whether changes were written.
622    pub applied: bool,
623    /// Files that would change or did change.
624    pub changed_files: Vec<WorkspacePath>,
625    /// Patch conflicts.
626    pub conflicts: Vec<PatchConflict>,
627    /// Diff summary.
628    pub diff: WorkspaceDiff,
629    /// Snapshot before validation.
630    pub snapshot_before: WorkspaceSnapshot,
631    /// Snapshot after writing, absent for dry-run or conflict results.
632    pub snapshot_after: Option<WorkspaceSnapshot>,
633    /// Host-owned metadata.
634    pub metadata: RunMetadata,
635}
636
637/// Tracks files changed by the agent layer.
638#[derive(Debug, Clone, Default)]
639pub struct AgentChangeTracker {
640    changes: Arc<Mutex<BTreeMap<WorkspacePath, FileChange>>>,
641}
642
643#[derive(Debug, Clone, PartialEq, Eq)]
644struct FileChange {
645    before: Option<FileVersion>,
646    after: Option<FileVersion>,
647}
648
649impl AgentChangeTracker {
650    /// Constructs an empty change tracker.
651    pub fn new() -> Self {
652        Self::default()
653    }
654
655    /// Records a file write, delete, or rename target.
656    pub fn record(
657        &self,
658        path: WorkspacePath,
659        before: Option<FileVersion>,
660        after: Option<FileVersion>,
661    ) {
662        self.changes
663            .lock()
664            .expect("AgentChangeTracker lock poisoned")
665            .insert(path, FileChange { before, after });
666    }
667
668    /// Returns paths changed by the agent so far.
669    pub fn changed_files(&self) -> Vec<WorkspacePath> {
670        self.changes
671            .lock()
672            .expect("AgentChangeTracker lock poisoned")
673            .keys()
674            .cloned()
675            .collect()
676    }
677
678    /// Clears recorded changes.
679    pub fn clear(&self) {
680        self.changes
681            .lock()
682            .expect("AgentChangeTracker lock poisoned")
683            .clear();
684    }
685}
686
687/// Workspace abstraction for coding workloads.
688#[async_trait]
689pub trait Workspace: Send + Sync {
690    /// Returns this workspace's root.
691    async fn root(&self) -> WorkspaceRoot;
692
693    /// Resolves a root-relative path for a specific access mode.
694    async fn resolve(
695        &self,
696        path: &WorkspacePath,
697        access: WorkspaceAccess,
698    ) -> Result<ResolvedPath, WorkspaceError>;
699
700    /// Reads a file through workspace policy.
701    async fn read_file(
702        &self,
703        path: &WorkspacePath,
704        options: FileReadOptions,
705    ) -> Result<FileContent, WorkspaceError>;
706
707    /// Writes a file through workspace policy.
708    async fn write_file(
709        &self,
710        request: WriteFileRequest,
711    ) -> Result<FileWriteResult, WorkspaceError>;
712
713    /// Lists files through workspace policy.
714    async fn list_files(
715        &self,
716        query: ListFilesQuery,
717    ) -> Result<Vec<WorkspaceEntry>, WorkspaceError>;
718
719    /// Captures a lightweight snapshot of file versions.
720    async fn snapshot(&self, request: SnapshotRequest)
721    -> Result<WorkspaceSnapshot, WorkspaceError>;
722
723    /// Diffs two snapshots.
724    async fn diff(&self, request: DiffRequest) -> Result<WorkspaceDiff, WorkspaceError>;
725
726    /// Applies or dry-runs a structured patch.
727    async fn apply_patch(&self, request: PatchRequest) -> Result<PatchResult, WorkspaceError>;
728}
729
730/// Local filesystem workspace configuration.
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(default)]
733#[non_exhaustive]
734pub struct LocalWorkspaceConfig {
735    /// Symlink behavior.
736    pub(crate) symlink_policy: SymlinkPolicy,
737    /// Default maximum bytes returned by read operations.
738    pub(crate) max_read_bytes: usize,
739    /// Default maximum entries returned by list operations.
740    pub(crate) max_list_entries: usize,
741    /// Whether hidden files are included when a query does not opt in.
742    pub(crate) include_hidden_by_default: bool,
743    /// Whether simple `.gitignore` patterns are respected when a query does
744    /// not opt out.
745    pub(crate) respect_gitignore_by_default: bool,
746}
747
748impl Default for LocalWorkspaceConfig {
749    fn default() -> Self {
750        Self {
751            symlink_policy: SymlinkPolicy::FollowReadInsideRoot,
752            max_read_bytes: 64 * 1024,
753            max_list_entries: 10_000,
754            include_hidden_by_default: false,
755            respect_gitignore_by_default: true,
756        }
757    }
758}
759
760impl LocalWorkspaceConfig {
761    /// Constructs a config with default values.
762    pub fn new() -> Self {
763        Self::default()
764    }
765
766    /// Symlink behavior.
767    pub fn symlink_policy(&self) -> SymlinkPolicy {
768        self.symlink_policy
769    }
770
771    /// Returns a config with updated symlink behavior.
772    pub fn with_symlink_policy(mut self, symlink_policy: SymlinkPolicy) -> Self {
773        self.symlink_policy = symlink_policy;
774        self
775    }
776
777    /// Default maximum bytes returned by read operations.
778    pub fn max_read_bytes(&self) -> usize {
779        self.max_read_bytes
780    }
781
782    /// Returns a config with an updated read byte cap.
783    pub fn with_max_read_bytes(mut self, max_read_bytes: usize) -> Self {
784        self.max_read_bytes = max_read_bytes;
785        self
786    }
787
788    /// Default maximum entries returned by list operations.
789    pub fn max_list_entries(&self) -> usize {
790        self.max_list_entries
791    }
792
793    /// Returns a config with an updated list entry cap.
794    pub fn with_max_list_entries(mut self, max_list_entries: usize) -> Self {
795        self.max_list_entries = max_list_entries;
796        self
797    }
798
799    /// Whether hidden files are included when a query does not opt in.
800    pub fn include_hidden_by_default(&self) -> bool {
801        self.include_hidden_by_default
802    }
803
804    /// Returns a config with updated hidden-file behavior.
805    pub fn with_include_hidden_by_default(mut self, include_hidden_by_default: bool) -> Self {
806        self.include_hidden_by_default = include_hidden_by_default;
807        self
808    }
809
810    /// Whether `.gitignore` patterns are respected when a query does not opt out.
811    pub fn respect_gitignore_by_default(&self) -> bool {
812        self.respect_gitignore_by_default
813    }
814
815    /// Returns a config with updated `.gitignore` behavior.
816    pub fn with_respect_gitignore_by_default(mut self, respect_gitignore_by_default: bool) -> Self {
817        self.respect_gitignore_by_default = respect_gitignore_by_default;
818        self
819    }
820}
821
822/// Local filesystem implementation of [`Workspace`].
823#[derive(Debug, Clone)]
824pub struct LocalWorkspace {
825    root: WorkspaceRoot,
826    config: LocalWorkspaceConfig,
827    changes: AgentChangeTracker,
828}
829
830impl LocalWorkspace {
831    /// Constructs a local workspace from a root directory.
832    ///
833    /// # Errors
834    ///
835    /// Returns [`WorkspaceError`] if the root cannot be canonicalized or is
836    /// not an existing directory.
837    pub fn new(root: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
838        Self::with_config(root, LocalWorkspaceConfig::default())
839    }
840
841    /// Constructs a local workspace with explicit configuration.
842    ///
843    /// # Errors
844    ///
845    /// Returns [`WorkspaceError`] if the root cannot be canonicalized or is
846    /// not an existing directory.
847    pub fn with_config(
848        root: impl AsRef<Path>,
849        config: LocalWorkspaceConfig,
850    ) -> Result<Self, WorkspaceError> {
851        Ok(Self {
852            root: WorkspaceRoot::new(root)?,
853            config,
854            changes: AgentChangeTracker::new(),
855        })
856    }
857
858    /// Returns the local change tracker.
859    pub fn change_tracker(&self) -> AgentChangeTracker {
860        self.changes.clone()
861    }
862
863    fn check_inside_root(&self, absolute: &Path) -> Result<(), WorkspaceError> {
864        if absolute.starts_with(self.root.as_path()) {
865            Ok(())
866        } else {
867            Err(WorkspaceError::OutsideRoot {
868                path: absolute.display().to_string(),
869                root: self.root.as_path().display().to_string(),
870            })
871        }
872    }
873
874    fn resolve_existing_kind(metadata: &std::fs::Metadata) -> ResolvedPathKind {
875        if metadata.is_dir() {
876            ResolvedPathKind::Directory
877        } else {
878            ResolvedPathKind::File
879        }
880    }
881
882    async fn version_for_absolute(
883        &self,
884        path: &WorkspacePath,
885        absolute: &Path,
886    ) -> Result<FileVersion, WorkspaceError> {
887        let bytes = tokio::fs::read(absolute)
888            .await
889            .map_err(|error| WorkspaceError::Io {
890                message: format!("failed to read file for version: {error}"),
891            })?;
892        let metadata = tokio::fs::metadata(absolute)
893            .await
894            .map_err(|error| WorkspaceError::Io {
895                message: format!("failed to inspect file for version: {error}"),
896            })?;
897        Ok(FileVersion {
898            path: path.clone(),
899            digest: digest_bytes(&bytes),
900            len: metadata.len(),
901            modified: metadata.modified().ok(),
902        })
903    }
904
905    async fn read_text_file(
906        &self,
907        path: &WorkspacePath,
908        max_bytes: usize,
909    ) -> Result<(String, FileVersion, bool), WorkspaceError> {
910        let content = self
911            .read_file(
912                path,
913                FileReadOptions {
914                    max_bytes: Some(max_bytes),
915                    include_binary: false,
916                },
917            )
918            .await?;
919        let text = match content.body {
920            FileBody::Text { text, .. } => text,
921            FileBody::Binary { .. } => {
922                return Err(WorkspaceError::Unsupported {
923                    message: format!("file is binary: {}", path.display()),
924                });
925            }
926        };
927        Ok((text, content.version, content.truncated))
928    }
929
930    async fn snapshot_paths(
931        &self,
932        paths: Vec<WorkspacePath>,
933        recursive: bool,
934    ) -> Result<WorkspaceSnapshot, WorkspaceError> {
935        let paths = if paths.is_empty() {
936            vec![WorkspacePath::root()]
937        } else {
938            paths
939        };
940        let mut versions = BTreeMap::new();
941        for path in paths {
942            let resolved = self.resolve(&path, WorkspaceAccess::Read).await?;
943            match resolved.kind {
944                ResolvedPathKind::Missing => {}
945                ResolvedPathKind::Directory => {
946                    let entries = self
947                        .list_files(ListFilesQuery {
948                            path: path.clone(),
949                            recursive,
950                            max_entries: Some(self.config.max_list_entries),
951                            include_hidden: self.config.include_hidden_by_default,
952                            respect_gitignore: self.config.respect_gitignore_by_default,
953                        })
954                        .await?;
955                    for entry in entries {
956                        if entry.kind != ResolvedPathKind::File {
957                            continue;
958                        }
959                        let absolute = self.root.join(&entry.path);
960                        let version = self.version_for_absolute(&entry.path, &absolute).await?;
961                        versions.insert(entry.path, version);
962                    }
963                }
964                ResolvedPathKind::File => {
965                    let version = self.version_for_absolute(&path, &resolved.absolute).await?;
966                    versions.insert(path, version);
967                }
968                ResolvedPathKind::Symlink => {}
969            }
970        }
971        Ok(WorkspaceSnapshot {
972            id: generated_snapshot_id(),
973            root: self.root.clone(),
974            files: versions.into_values().collect(),
975            git_head: None,
976            metadata: RunMetadata::new(),
977        })
978    }
979}
980
981#[async_trait]
982impl Workspace for LocalWorkspace {
983    async fn root(&self) -> WorkspaceRoot {
984        self.root.clone()
985    }
986
987    async fn resolve(
988        &self,
989        path: &WorkspacePath,
990        access: WorkspaceAccess,
991    ) -> Result<ResolvedPath, WorkspaceError> {
992        let joined = self.root.join(path);
993        if !joined.starts_with(self.root.as_path()) {
994            return Err(WorkspaceError::OutsideRoot {
995                path: joined.display().to_string(),
996                root: self.root.as_path().display().to_string(),
997            });
998        }
999
1000        match std::fs::symlink_metadata(&joined) {
1001            Ok(symlink_metadata) => {
1002                if symlink_metadata.file_type().is_symlink() {
1003                    let target =
1004                        std::fs::read_link(&joined).map_err(|error| WorkspaceError::Io {
1005                            message: format!("failed to read symlink: {error}"),
1006                        })?;
1007                    let target_absolute = if target.is_absolute() {
1008                        target
1009                    } else {
1010                        joined
1011                            .parent()
1012                            .unwrap_or_else(|| self.root.as_path())
1013                            .join(target)
1014                    };
1015                    let canonical_target =
1016                        std::fs::canonicalize(&target_absolute).map_err(|error| {
1017                            WorkspaceError::Io {
1018                                message: format!("failed to canonicalize symlink target: {error}"),
1019                            }
1020                        })?;
1021                    if !canonical_target.starts_with(self.root.as_path()) {
1022                        return Err(WorkspaceError::SymlinkEscapesRoot {
1023                            path: joined.display().to_string(),
1024                            target: canonical_target.display().to_string(),
1025                        });
1026                    }
1027                    return match self.config.symlink_policy {
1028                        SymlinkPolicy::RejectAll => Err(WorkspaceError::Unsupported {
1029                            message: format!("symlink rejected: {}", path.display()),
1030                        }),
1031                        SymlinkPolicy::NoFollow => Ok(ResolvedPath {
1032                            root: self.root.clone(),
1033                            workspace_path: path.clone(),
1034                            absolute: joined,
1035                            kind: ResolvedPathKind::Symlink,
1036                            symlink_target: Some(canonical_target),
1037                        }),
1038                        SymlinkPolicy::FollowReadInsideRoot
1039                            if matches!(access, WorkspaceAccess::Read | WorkspaceAccess::List) =>
1040                        {
1041                            let metadata =
1042                                std::fs::metadata(&canonical_target).map_err(|error| {
1043                                    WorkspaceError::Io {
1044                                        message: format!(
1045                                            "failed to inspect symlink target: {error}"
1046                                        ),
1047                                    }
1048                                })?;
1049                            Ok(ResolvedPath {
1050                                root: self.root.clone(),
1051                                workspace_path: path.clone(),
1052                                absolute: canonical_target.clone(),
1053                                kind: Self::resolve_existing_kind(&metadata),
1054                                symlink_target: Some(canonical_target),
1055                            })
1056                        }
1057                        SymlinkPolicy::FollowReadInsideRoot => Err(WorkspaceError::Unsupported {
1058                            message: format!(
1059                                "write/delete through symlink rejected: {}",
1060                                path.display()
1061                            ),
1062                        }),
1063                    };
1064                }
1065
1066                let canonical =
1067                    std::fs::canonicalize(&joined).map_err(|error| WorkspaceError::Io {
1068                        message: format!("failed to canonicalize workspace path: {error}"),
1069                    })?;
1070                self.check_inside_root(&canonical)?;
1071                Ok(ResolvedPath {
1072                    root: self.root.clone(),
1073                    workspace_path: path.clone(),
1074                    absolute: canonical,
1075                    kind: Self::resolve_existing_kind(&symlink_metadata),
1076                    symlink_target: None,
1077                })
1078            }
1079            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1080                let parent = path.parent();
1081                let parent_absolute = self.root.join(&parent);
1082                let canonical_parent =
1083                    std::fs::canonicalize(&parent_absolute).map_err(|parent_error| {
1084                        WorkspaceError::NotFound {
1085                            path: parent.clone(),
1086                            message: format!("parent does not exist: {parent_error}"),
1087                        }
1088                    })?;
1089                self.check_inside_root(&canonical_parent)?;
1090                Ok(ResolvedPath {
1091                    root: self.root.clone(),
1092                    workspace_path: path.clone(),
1093                    absolute: joined,
1094                    kind: ResolvedPathKind::Missing,
1095                    symlink_target: None,
1096                })
1097            }
1098            Err(error) => Err(WorkspaceError::Io {
1099                message: format!("failed to inspect workspace path: {error}"),
1100            }),
1101        }
1102    }
1103
1104    async fn read_file(
1105        &self,
1106        path: &WorkspacePath,
1107        options: FileReadOptions,
1108    ) -> Result<FileContent, WorkspaceError> {
1109        let resolved = self.resolve(path, WorkspaceAccess::Read).await?;
1110        match resolved.kind {
1111            ResolvedPathKind::Missing => {
1112                return Err(WorkspaceError::NotFound {
1113                    path: path.clone(),
1114                    message: "file does not exist".to_string(),
1115                });
1116            }
1117            ResolvedPathKind::Directory => {
1118                return Err(WorkspaceError::Unsupported {
1119                    message: format!("path is a directory: {}", path.display()),
1120                });
1121            }
1122            ResolvedPathKind::Symlink => {
1123                return Err(WorkspaceError::Unsupported {
1124                    message: format!("symlink was not followed: {}", path.display()),
1125                });
1126            }
1127            ResolvedPathKind::File => {}
1128        }
1129
1130        let max_bytes = options.max_bytes.unwrap_or(self.config.max_read_bytes);
1131        let mut bytes =
1132            tokio::fs::read(&resolved.absolute)
1133                .await
1134                .map_err(|error| WorkspaceError::Io {
1135                    message: format!("failed to read file: {error}"),
1136                })?;
1137        let total_len = bytes.len();
1138        let truncated = bytes.len() > max_bytes;
1139        if truncated {
1140            bytes.truncate(max_bytes);
1141        }
1142        let metadata = tokio::fs::metadata(&resolved.absolute)
1143            .await
1144            .map_err(|error| WorkspaceError::Io {
1145                message: format!("failed to inspect file: {error}"),
1146            })?;
1147        let version = FileVersion {
1148            path: path.clone(),
1149            digest: digest_bytes(&tokio::fs::read(&resolved.absolute).await.map_err(|error| {
1150                WorkspaceError::Io {
1151                    message: format!("failed to read file for digest: {error}"),
1152                }
1153            })?),
1154            len: metadata.len(),
1155            modified: metadata.modified().ok(),
1156        };
1157        let body = match String::from_utf8(bytes) {
1158            Ok(text) => FileBody::Text {
1159                text,
1160                encoding: TextEncoding::Utf8,
1161            },
1162            Err(error) if options.include_binary => FileBody::Binary {
1163                bytes: error.into_bytes(),
1164                media_type: None,
1165            },
1166            Err(_) => FileBody::Binary {
1167                bytes: Vec::new(),
1168                media_type: None,
1169            },
1170        };
1171        let mut metadata = RunMetadata::new();
1172        metadata.insert("bytes_read".to_string(), serde_json::json!(total_len));
1173        Ok(FileContent {
1174            path: path.clone(),
1175            version,
1176            body,
1177            truncated,
1178            metadata,
1179        })
1180    }
1181
1182    async fn write_file(
1183        &self,
1184        request: WriteFileRequest,
1185    ) -> Result<FileWriteResult, WorkspaceError> {
1186        let joined = self.root.join(&request.path);
1187        let exists = std::fs::symlink_metadata(&joined).is_ok();
1188        let access = if exists {
1189            WorkspaceAccess::Modify
1190        } else {
1191            WorkspaceAccess::Create
1192        };
1193        let resolved = self.resolve(&request.path, access).await?;
1194        let previous_version = if exists {
1195            if resolved.kind != ResolvedPathKind::File {
1196                return Err(WorkspaceError::Unsupported {
1197                    message: format!("path is not a regular file: {}", request.path.display()),
1198                });
1199            }
1200            Some(
1201                self.version_for_absolute(&request.path, &resolved.absolute)
1202                    .await?,
1203            )
1204        } else {
1205            None
1206        };
1207
1208        if previous_version.is_some() && !request.overwrite {
1209            return Err(WorkspaceError::Conflict {
1210                conflict: Box::new(PatchConflict {
1211                    path: request.path.clone(),
1212                    message: "file exists and overwrite is false".to_string(),
1213                    expected_version: request.expected_version.clone(),
1214                    actual_version: previous_version,
1215                }),
1216            });
1217        }
1218        if previous_version.is_none() && !request.create {
1219            return Err(WorkspaceError::NotFound {
1220                path: request.path,
1221                message: "file does not exist and create is false".to_string(),
1222            });
1223        }
1224        if let Some(expected) = &request.expected_version
1225            && previous_version.as_ref() != Some(expected)
1226        {
1227            return Err(WorkspaceError::Conflict {
1228                conflict: Box::new(PatchConflict {
1229                    path: request.path.clone(),
1230                    message: "stale file version".to_string(),
1231                    expected_version: Some(expected.clone()),
1232                    actual_version: previous_version,
1233                }),
1234            });
1235        }
1236
1237        let bytes = request.content.into_bytes();
1238        let parent = resolved
1239            .absolute
1240            .parent()
1241            .ok_or_else(|| WorkspaceError::InvalidPath {
1242                message: "file path has no parent".to_string(),
1243            })?;
1244        let tmp = parent.join(format!(
1245            ".molo-write-{}-{}",
1246            std::process::id(),
1247            monotonic_nanos()
1248        ));
1249        tokio::fs::write(&tmp, &bytes)
1250            .await
1251            .map_err(|error| WorkspaceError::Io {
1252                message: format!("failed to write temporary file: {error}"),
1253            })?;
1254        tokio::fs::rename(&tmp, &resolved.absolute)
1255            .await
1256            .map_err(|error| WorkspaceError::Io {
1257                message: format!("failed to commit file write: {error}"),
1258            })?;
1259        let new_version = self
1260            .version_for_absolute(&request.path, &resolved.absolute)
1261            .await?;
1262        self.changes.record(
1263            request.path.clone(),
1264            previous_version.clone(),
1265            Some(new_version.clone()),
1266        );
1267        Ok(FileWriteResult {
1268            path: request.path,
1269            previous_version,
1270            new_version,
1271            created: !exists,
1272            bytes_written: bytes.len() as u64,
1273            metadata: RunMetadata::new(),
1274        })
1275    }
1276
1277    async fn list_files(
1278        &self,
1279        query: ListFilesQuery,
1280    ) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
1281        let resolved = self.resolve(&query.path, WorkspaceAccess::List).await?;
1282        if resolved.kind == ResolvedPathKind::Missing {
1283            return Err(WorkspaceError::NotFound {
1284                path: query.path,
1285                message: "path does not exist".to_string(),
1286            });
1287        }
1288        let max_entries = query.max_entries.unwrap_or(self.config.max_list_entries);
1289        let ignore = if query.respect_gitignore {
1290            SimpleIgnore::load(self.root.as_path())
1291        } else {
1292            SimpleIgnore::default()
1293        };
1294        let mut entries = Vec::new();
1295        collect_entries(
1296            &self.root,
1297            &resolved.absolute,
1298            query.recursive,
1299            query.include_hidden,
1300            &ignore,
1301            max_entries,
1302            &mut entries,
1303        )?;
1304        entries.sort_by(|left, right| left.path.cmp(&right.path));
1305        if entries.len() > max_entries {
1306            entries.truncate(max_entries);
1307        }
1308        Ok(entries)
1309    }
1310
1311    async fn snapshot(
1312        &self,
1313        request: SnapshotRequest,
1314    ) -> Result<WorkspaceSnapshot, WorkspaceError> {
1315        self.snapshot_paths(request.paths, request.recursive).await
1316    }
1317
1318    async fn diff(&self, request: DiffRequest) -> Result<WorkspaceDiff, WorkspaceError> {
1319        Ok(diff_snapshots(&request.before, &request.after))
1320    }
1321
1322    async fn apply_patch(&self, request: PatchRequest) -> Result<PatchResult, WorkspaceError> {
1323        let before = self
1324            .snapshot(SnapshotRequest {
1325                paths: Vec::new(),
1326                recursive: true,
1327            })
1328            .await?;
1329        let mut conflicts = Vec::new();
1330        let mut writes: BTreeMap<WorkspacePath, PlannedWrite> = BTreeMap::new();
1331        let mut deletes: BTreeMap<WorkspacePath, FileVersion> = BTreeMap::new();
1332
1333        for file_patch in &request.patch.files {
1334            match validate_file_patch(self, file_patch).await {
1335                Ok(plan) => match plan {
1336                    PatchPlan::Write { path, bytes } => {
1337                        writes.insert(
1338                            path,
1339                            PlannedWrite {
1340                                bytes,
1341                                expected_version: None,
1342                                create: true,
1343                                overwrite: false,
1344                            },
1345                        );
1346                    }
1347                    PatchPlan::Modify {
1348                        path,
1349                        bytes,
1350                        expected_version,
1351                    } => {
1352                        writes.insert(
1353                            path,
1354                            PlannedWrite {
1355                                bytes,
1356                                expected_version: Some(expected_version),
1357                                create: false,
1358                                overwrite: true,
1359                            },
1360                        );
1361                    }
1362                    PatchPlan::Delete {
1363                        path,
1364                        expected_version,
1365                    } => {
1366                        deletes.insert(path, expected_version);
1367                    }
1368                    PatchPlan::Rename {
1369                        from,
1370                        to,
1371                        bytes,
1372                        expected_version,
1373                    } => {
1374                        deletes.insert(from, expected_version);
1375                        writes.insert(
1376                            to,
1377                            PlannedWrite {
1378                                bytes,
1379                                expected_version: None,
1380                                create: true,
1381                                overwrite: false,
1382                            },
1383                        );
1384                    }
1385                },
1386                Err(conflict) => conflicts.push(conflict),
1387            }
1388        }
1389
1390        let changed_files: Vec<_> = writes
1391            .keys()
1392            .chain(deletes.keys())
1393            .cloned()
1394            .collect::<BTreeSet<_>>()
1395            .into_iter()
1396            .collect();
1397        if !conflicts.is_empty() || request.dry_run {
1398            let diff = WorkspaceDiff {
1399                changed_files: changed_files.clone(),
1400                text: if conflicts.is_empty() {
1401                    format!("patch dry-run would change {} file(s)", changed_files.len())
1402                } else {
1403                    format!("patch has {} conflict(s)", conflicts.len())
1404                },
1405                truncated: false,
1406                metadata: RunMetadata::new(),
1407            };
1408            return Ok(PatchResult {
1409                applied: false,
1410                changed_files,
1411                conflicts,
1412                diff,
1413                snapshot_before: before,
1414                snapshot_after: None,
1415                metadata: RunMetadata::new(),
1416            });
1417        }
1418
1419        for (path, expected_version) in deletes {
1420            let resolved = self.resolve(&path, WorkspaceAccess::Delete).await?;
1421            let actual_version = self.version_for_absolute(&path, &resolved.absolute).await?;
1422            if actual_version != expected_version {
1423                return Err(WorkspaceError::Conflict {
1424                    conflict: Box::new(PatchConflict {
1425                        path,
1426                        message: "stale file version before delete".to_string(),
1427                        expected_version: Some(expected_version),
1428                        actual_version: Some(actual_version),
1429                    }),
1430                });
1431            }
1432            tokio::fs::remove_file(&resolved.absolute)
1433                .await
1434                .map_err(|error| WorkspaceError::Io {
1435                    message: format!("failed to delete file: {error}"),
1436                })?;
1437            self.changes.record(path, Some(expected_version), None);
1438        }
1439        for (path, planned) in writes {
1440            let text = match String::from_utf8(planned.bytes.clone()) {
1441                Ok(text) => FileWriteContent::Text(text),
1442                Err(_) => FileWriteContent::Bytes(planned.bytes),
1443            };
1444            self.write_file(WriteFileRequest {
1445                path,
1446                content: text,
1447                expected_version: planned.expected_version,
1448                create: planned.create,
1449                overwrite: planned.overwrite,
1450            })
1451            .await?;
1452        }
1453
1454        let after = self
1455            .snapshot(SnapshotRequest {
1456                paths: Vec::new(),
1457                recursive: true,
1458            })
1459            .await?;
1460        let diff = diff_snapshots(&before, &after);
1461        Ok(PatchResult {
1462            applied: true,
1463            changed_files: diff.changed_files.clone(),
1464            conflicts: Vec::new(),
1465            diff,
1466            snapshot_before: before,
1467            snapshot_after: Some(after),
1468            metadata: RunMetadata::new(),
1469        })
1470    }
1471}
1472
1473#[derive(Debug)]
1474enum PatchPlan {
1475    Write {
1476        path: WorkspacePath,
1477        bytes: Vec<u8>,
1478    },
1479    Modify {
1480        path: WorkspacePath,
1481        bytes: Vec<u8>,
1482        expected_version: FileVersion,
1483    },
1484    Delete {
1485        path: WorkspacePath,
1486        expected_version: FileVersion,
1487    },
1488    Rename {
1489        from: WorkspacePath,
1490        to: WorkspacePath,
1491        bytes: Vec<u8>,
1492        expected_version: FileVersion,
1493    },
1494}
1495
1496#[derive(Debug)]
1497struct PlannedWrite {
1498    bytes: Vec<u8>,
1499    expected_version: Option<FileVersion>,
1500    create: bool,
1501    overwrite: bool,
1502}
1503
1504async fn validate_file_patch(
1505    workspace: &LocalWorkspace,
1506    file_patch: &FilePatch,
1507) -> Result<PatchPlan, PatchConflict> {
1508    match &file_patch.operation {
1509        PatchOperation::Create => {
1510            if std::fs::symlink_metadata(workspace.root.join(&file_patch.path)).is_ok() {
1511                return Err(PatchConflict {
1512                    path: file_patch.path.clone(),
1513                    message: "create target already exists".to_string(),
1514                    expected_version: file_patch.expected_version.clone(),
1515                    actual_version: None,
1516                });
1517            }
1518            let bytes = file_patch
1519                .hunks
1520                .iter()
1521                .map(|hunk| hunk.new_text.as_str())
1522                .collect::<String>()
1523                .into_bytes();
1524            Ok(PatchPlan::Write {
1525                path: file_patch.path.clone(),
1526                bytes,
1527            })
1528        }
1529        PatchOperation::Modify => {
1530            let (mut text, version, truncated) = workspace
1531                .read_text_file(&file_patch.path, workspace.config.max_read_bytes)
1532                .await
1533                .map_err(|error| PatchConflict {
1534                    path: file_patch.path.clone(),
1535                    message: error.to_string(),
1536                    expected_version: file_patch.expected_version.clone(),
1537                    actual_version: None,
1538                })?;
1539            if truncated {
1540                return Err(PatchConflict {
1541                    path: file_patch.path.clone(),
1542                    message: "file is too large for local patch applier".to_string(),
1543                    expected_version: file_patch.expected_version.clone(),
1544                    actual_version: Some(version),
1545                });
1546            }
1547            if let Some(expected) = &file_patch.expected_version
1548                && expected != &version
1549            {
1550                return Err(PatchConflict {
1551                    path: file_patch.path.clone(),
1552                    message: "stale file version".to_string(),
1553                    expected_version: Some(expected.clone()),
1554                    actual_version: Some(version),
1555                });
1556            }
1557            for hunk in &file_patch.hunks {
1558                let Some(index) = text.find(&hunk.old_text) else {
1559                    return Err(PatchConflict {
1560                        path: file_patch.path.clone(),
1561                        message: "patch hunk did not match".to_string(),
1562                        expected_version: file_patch.expected_version.clone(),
1563                        actual_version: Some(version),
1564                    });
1565                };
1566                text.replace_range(index..index + hunk.old_text.len(), &hunk.new_text);
1567            }
1568            Ok(PatchPlan::Modify {
1569                path: file_patch.path.clone(),
1570                bytes: text.into_bytes(),
1571                expected_version: version,
1572            })
1573        }
1574        PatchOperation::Delete => {
1575            let resolved = workspace
1576                .resolve(&file_patch.path, WorkspaceAccess::Delete)
1577                .await
1578                .map_err(|error| PatchConflict {
1579                    path: file_patch.path.clone(),
1580                    message: error.to_string(),
1581                    expected_version: file_patch.expected_version.clone(),
1582                    actual_version: None,
1583                })?;
1584            if resolved.kind != ResolvedPathKind::File {
1585                return Err(PatchConflict {
1586                    path: file_patch.path.clone(),
1587                    message: "delete target is not a regular file".to_string(),
1588                    expected_version: file_patch.expected_version.clone(),
1589                    actual_version: None,
1590                });
1591            }
1592            let version = workspace
1593                .version_for_absolute(&file_patch.path, &resolved.absolute)
1594                .await
1595                .map_err(|error| PatchConflict {
1596                    path: file_patch.path.clone(),
1597                    message: error.to_string(),
1598                    expected_version: file_patch.expected_version.clone(),
1599                    actual_version: None,
1600                })?;
1601            if let Some(expected) = &file_patch.expected_version
1602                && expected != &version
1603            {
1604                return Err(PatchConflict {
1605                    path: file_patch.path.clone(),
1606                    message: "stale file version".to_string(),
1607                    expected_version: Some(expected.clone()),
1608                    actual_version: Some(version),
1609                });
1610            }
1611            Ok(PatchPlan::Delete {
1612                path: file_patch.path.clone(),
1613                expected_version: version,
1614            })
1615        }
1616        PatchOperation::Rename { from } => {
1617            let (text, version, truncated) = workspace
1618                .read_text_file(from, workspace.config.max_read_bytes)
1619                .await
1620                .map_err(|error| PatchConflict {
1621                    path: from.clone(),
1622                    message: error.to_string(),
1623                    expected_version: file_patch.expected_version.clone(),
1624                    actual_version: None,
1625                })?;
1626            if truncated {
1627                return Err(PatchConflict {
1628                    path: from.clone(),
1629                    message: "file is too large for local patch applier".to_string(),
1630                    expected_version: file_patch.expected_version.clone(),
1631                    actual_version: Some(version),
1632                });
1633            }
1634            if std::fs::symlink_metadata(workspace.root.join(&file_patch.path)).is_ok() {
1635                return Err(PatchConflict {
1636                    path: file_patch.path.clone(),
1637                    message: "rename target already exists".to_string(),
1638                    expected_version: None,
1639                    actual_version: None,
1640                });
1641            }
1642            let mut new_text = text;
1643            for hunk in &file_patch.hunks {
1644                if hunk.old_text.is_empty() {
1645                    continue;
1646                }
1647                let Some(index) = new_text.find(&hunk.old_text) else {
1648                    return Err(PatchConflict {
1649                        path: from.clone(),
1650                        message: "rename hunk did not match".to_string(),
1651                        expected_version: file_patch.expected_version.clone(),
1652                        actual_version: Some(version),
1653                    });
1654                };
1655                new_text.replace_range(index..index + hunk.old_text.len(), &hunk.new_text);
1656            }
1657            Ok(PatchPlan::Rename {
1658                from: from.clone(),
1659                to: file_patch.path.clone(),
1660                bytes: new_text.into_bytes(),
1661                expected_version: version,
1662            })
1663        }
1664    }
1665}
1666
1667fn collect_entries(
1668    root: &WorkspaceRoot,
1669    absolute: &Path,
1670    recursive: bool,
1671    include_hidden: bool,
1672    ignore: &SimpleIgnore,
1673    max_entries: usize,
1674    entries: &mut Vec<WorkspaceEntry>,
1675) -> Result<(), WorkspaceError> {
1676    if entries.len() >= max_entries {
1677        return Ok(());
1678    }
1679    let metadata = std::fs::metadata(absolute).map_err(|error| WorkspaceError::Io {
1680        message: format!("failed to inspect list entry: {error}"),
1681    })?;
1682    if metadata.is_file() {
1683        entries.push(WorkspaceEntry {
1684            path: root.strip_absolute(absolute)?,
1685            kind: ResolvedPathKind::File,
1686            len: Some(metadata.len()),
1687        });
1688        return Ok(());
1689    }
1690
1691    let mut children = Vec::new();
1692    for entry in std::fs::read_dir(absolute).map_err(|error| WorkspaceError::Io {
1693        message: format!("failed to list directory: {error}"),
1694    })? {
1695        let entry = entry.map_err(|error| WorkspaceError::Io {
1696            message: format!("failed to read directory entry: {error}"),
1697        })?;
1698        children.push(entry.path());
1699    }
1700    children.sort();
1701
1702    for child in children {
1703        if entries.len() >= max_entries {
1704            break;
1705        }
1706        let relative = root.strip_absolute(&child)?;
1707        if !include_hidden && is_hidden(&relative) {
1708            continue;
1709        }
1710        if ignore.is_ignored(&relative) {
1711            continue;
1712        }
1713        let metadata = std::fs::symlink_metadata(&child).map_err(|error| WorkspaceError::Io {
1714            message: format!("failed to inspect list entry: {error}"),
1715        })?;
1716        let kind = if metadata.file_type().is_symlink() {
1717            ResolvedPathKind::Symlink
1718        } else if metadata.is_dir() {
1719            ResolvedPathKind::Directory
1720        } else {
1721            ResolvedPathKind::File
1722        };
1723        entries.push(WorkspaceEntry {
1724            path: relative,
1725            kind,
1726            len: metadata.is_file().then_some(metadata.len()),
1727        });
1728        if recursive && kind == ResolvedPathKind::Directory {
1729            collect_entries(
1730                root,
1731                &child,
1732                true,
1733                include_hidden,
1734                ignore,
1735                max_entries,
1736                entries,
1737            )?;
1738        }
1739    }
1740    Ok(())
1741}
1742
1743#[derive(Debug, Default)]
1744struct SimpleIgnore {
1745    patterns: Vec<String>,
1746}
1747
1748impl SimpleIgnore {
1749    fn load(root: &Path) -> Self {
1750        let mut ignore = Self {
1751            patterns: vec![".git".to_string()],
1752        };
1753        let path = root.join(".gitignore");
1754        let Ok(text) = std::fs::read_to_string(path) else {
1755            return ignore;
1756        };
1757        for line in text.lines() {
1758            let line = line.trim();
1759            if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
1760                continue;
1761            }
1762            ignore.patterns.push(line.trim_end_matches('/').to_string());
1763        }
1764        ignore
1765    }
1766
1767    fn is_ignored(&self, path: &WorkspacePath) -> bool {
1768        let display = path.display();
1769        self.patterns.iter().any(|pattern| {
1770            display == *pattern
1771                || display.starts_with(&format!("{pattern}/"))
1772                || display
1773                    .split('/')
1774                    .any(|component| component == pattern.as_str())
1775        })
1776    }
1777}
1778
1779fn is_hidden(path: &WorkspacePath) -> bool {
1780    path.display()
1781        .split('/')
1782        .any(|component| component.starts_with('.') && component != "." && !component.is_empty())
1783}
1784
1785fn digest_bytes(bytes: &[u8]) -> ContentDigest {
1786    let mut hash = 0xcbf29ce484222325u64;
1787    for byte in bytes {
1788        hash ^= u64::from(*byte);
1789        hash = hash.wrapping_mul(0x100000001b3);
1790    }
1791    ContentDigest {
1792        algorithm: "fnv1a64".to_string(),
1793        value: format!("{hash:016x}"),
1794    }
1795}
1796
1797fn diff_snapshots(before: &WorkspaceSnapshot, after: &WorkspaceSnapshot) -> WorkspaceDiff {
1798    let before_map: BTreeMap<_, _> = before
1799        .files
1800        .iter()
1801        .map(|version| (version.path.clone(), version))
1802        .collect();
1803    let after_map: BTreeMap<_, _> = after
1804        .files
1805        .iter()
1806        .map(|version| (version.path.clone(), version))
1807        .collect();
1808    let paths: BTreeSet<_> = before_map.keys().chain(after_map.keys()).cloned().collect();
1809    let mut changed = Vec::new();
1810    let mut lines = Vec::new();
1811    for path in paths {
1812        match (before_map.get(&path), after_map.get(&path)) {
1813            (None, Some(_)) => {
1814                changed.push(path.clone());
1815                lines.push(format!("created {}", path.display()));
1816            }
1817            (Some(_), None) => {
1818                changed.push(path.clone());
1819                lines.push(format!("deleted {}", path.display()));
1820            }
1821            (Some(left), Some(right)) if left.digest != right.digest || left.len != right.len => {
1822                changed.push(path.clone());
1823                lines.push(format!("modified {}", path.display()));
1824            }
1825            _ => {}
1826        }
1827    }
1828    WorkspaceDiff {
1829        changed_files: changed,
1830        text: lines.join("\n"),
1831        truncated: false,
1832        metadata: RunMetadata::new(),
1833    }
1834}
1835
1836fn monotonic_nanos() -> u128 {
1837    SystemTime::now()
1838        .duration_since(UNIX_EPOCH)
1839        .unwrap_or_default()
1840        .as_nanos()
1841}
1842
1843fn generated_snapshot_id() -> String {
1844    format!("snapshot-{}-{:#x}", std::process::id(), monotonic_nanos())
1845}
1846
1847/// Workspace operation errors.
1848#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
1849#[non_exhaustive]
1850pub enum WorkspaceError {
1851    /// The path syntax is invalid.
1852    #[error("invalid workspace path: {message}")]
1853    InvalidPath {
1854        /// Model-safe explanation.
1855        message: String,
1856    },
1857    /// The resolved path escapes the workspace root.
1858    #[error("path escapes workspace root: {path} is outside {root}")]
1859    OutsideRoot {
1860        /// Requested or resolved path.
1861        path: String,
1862        /// Workspace root.
1863        root: String,
1864    },
1865    /// A symlink points outside the workspace root.
1866    #[error("symlink escapes workspace root: {path} -> {target}")]
1867    SymlinkEscapesRoot {
1868        /// Symlink path.
1869        path: String,
1870        /// Canonical symlink target.
1871        target: String,
1872    },
1873    /// A required path was not found.
1874    #[error("workspace path not found: {path}: {message}")]
1875    NotFound {
1876        /// Workspace path.
1877        path: WorkspacePath,
1878        /// Model-safe explanation.
1879        message: String,
1880    },
1881    /// A write or patch conflict was detected.
1882    #[error("workspace conflict: {conflict:?}")]
1883    Conflict {
1884        /// Conflict detail.
1885        conflict: Box<PatchConflict>,
1886    },
1887    /// A path could not be displayed as UTF-8.
1888    #[error("workspace path is not UTF-8: {path}")]
1889    NonUtf8Path {
1890        /// Lossy path display.
1891        path: String,
1892    },
1893    /// I/O failed.
1894    #[error("workspace I/O error: {message}")]
1895    Io {
1896        /// Model-safe explanation.
1897        message: String,
1898    },
1899    /// Operation is not supported by this workspace.
1900    #[error("unsupported workspace operation: {message}")]
1901    Unsupported {
1902        /// Model-safe explanation.
1903        message: String,
1904    },
1905}
1906
1907#[cfg(test)]
1908mod tests {
1909    use super::*;
1910
1911    fn temp_dir(tag: &str) -> PathBuf {
1912        let dir = std::env::temp_dir().join(format!(
1913            "molo-coding-workspace-{}-{tag}",
1914            std::process::id()
1915        ));
1916        let _ = std::fs::remove_dir_all(&dir);
1917        std::fs::create_dir_all(&dir).unwrap();
1918        dir
1919    }
1920
1921    #[test]
1922    fn workspace_path_rejects_escape() {
1923        assert!(WorkspacePath::parse("../secret").is_err());
1924        assert!(WorkspacePath::parse("/etc/passwd").is_err());
1925        assert!(WorkspacePath::parse("a//b").is_err());
1926        assert!(WorkspacePath::parse("./a").is_err());
1927        assert!(WorkspacePath::parse("a\\b").is_err());
1928        assert!(WorkspacePath::parse("a\0b").is_err());
1929        assert_eq!(
1930            WorkspacePath::parse("src/lib.rs").unwrap().display(),
1931            "src/lib.rs"
1932        );
1933    }
1934
1935    #[test]
1936    fn workspace_path_rejects_escape_corpus() {
1937        let invalid = [
1938            "..",
1939            "../a",
1940            "a/../b",
1941            "a/./b",
1942            "./a",
1943            "/absolute",
1944            "a//b",
1945            "a\\b",
1946            "a/\0/b",
1947            "a/",
1948            "/",
1949        ];
1950        for candidate in invalid {
1951            assert!(
1952                WorkspacePath::parse(candidate).is_err(),
1953                "candidate must be rejected: {candidate:?}"
1954            );
1955        }
1956
1957        let valid = ["", "src/lib.rs", "nested/path/file.txt", "unicode/你好.txt"];
1958        for candidate in valid {
1959            assert_eq!(
1960                WorkspacePath::parse(candidate).unwrap().display(),
1961                candidate
1962            );
1963        }
1964    }
1965
1966    #[tokio::test]
1967    async fn local_workspace_rejects_outside_symlink() {
1968        let root = temp_dir("symlink");
1969        let outside = root.with_extension("outside");
1970        let _ = std::fs::remove_dir_all(&outside);
1971        std::fs::create_dir_all(&outside).unwrap();
1972        std::fs::write(outside.join("secret"), "secret").unwrap();
1973        #[cfg(unix)]
1974        std::os::unix::fs::symlink(outside.join("secret"), root.join("link")).unwrap();
1975        #[cfg(windows)]
1976        std::os::windows::fs::symlink_file(outside.join("secret"), root.join("link")).unwrap();
1977
1978        let workspace = LocalWorkspace::new(&root).unwrap();
1979        let err = workspace
1980            .read_file(
1981                &WorkspacePath::parse("link").unwrap(),
1982                FileReadOptions::default(),
1983            )
1984            .await
1985            .unwrap_err();
1986        assert!(matches!(err, WorkspaceError::SymlinkEscapesRoot { .. }));
1987        let _ = std::fs::remove_dir_all(root);
1988        let _ = std::fs::remove_dir_all(outside);
1989    }
1990
1991    #[tokio::test]
1992    async fn local_workspace_rejects_symlink_chain_escape() {
1993        let root = temp_dir("symlink-chain");
1994        let outside = root.with_extension("outside-chain");
1995        let _ = std::fs::remove_dir_all(&outside);
1996        std::fs::create_dir_all(&outside).unwrap();
1997        std::fs::write(outside.join("secret"), "secret").unwrap();
1998        #[cfg(unix)]
1999        {
2000            std::os::unix::fs::symlink(outside.join("secret"), root.join("outside-link")).unwrap();
2001            std::os::unix::fs::symlink(root.join("outside-link"), root.join("chain-link")).unwrap();
2002        }
2003        #[cfg(windows)]
2004        {
2005            std::os::windows::fs::symlink_file(outside.join("secret"), root.join("outside-link"))
2006                .unwrap();
2007            std::os::windows::fs::symlink_file(root.join("outside-link"), root.join("chain-link"))
2008                .unwrap();
2009        }
2010
2011        let workspace = LocalWorkspace::new(&root).unwrap();
2012        let err = workspace
2013            .read_file(
2014                &WorkspacePath::parse("chain-link").unwrap(),
2015                FileReadOptions::default(),
2016            )
2017            .await
2018            .unwrap_err();
2019        assert!(matches!(err, WorkspaceError::SymlinkEscapesRoot { .. }));
2020        let _ = std::fs::remove_dir_all(root);
2021        let _ = std::fs::remove_dir_all(outside);
2022    }
2023
2024    #[tokio::test]
2025    async fn write_stale_version_conflicts() {
2026        let root = temp_dir("stale");
2027        std::fs::write(root.join("a.txt"), "one").unwrap();
2028        let workspace = LocalWorkspace::new(&root).unwrap();
2029        let path = WorkspacePath::parse("a.txt").unwrap();
2030        let content = workspace
2031            .read_file(&path, FileReadOptions::default())
2032            .await
2033            .unwrap();
2034        std::fs::write(root.join("a.txt"), "two").unwrap();
2035        let err = workspace
2036            .write_file(WriteFileRequest {
2037                path,
2038                content: FileWriteContent::Text("three".to_string()),
2039                expected_version: Some(content.version),
2040                create: false,
2041                overwrite: true,
2042            })
2043            .await
2044            .unwrap_err();
2045        assert!(matches!(err, WorkspaceError::Conflict { .. }));
2046        let _ = std::fs::remove_dir_all(root);
2047    }
2048
2049    #[tokio::test]
2050    async fn patch_is_all_or_nothing_on_conflict() {
2051        let root = temp_dir("patch");
2052        std::fs::write(root.join("a.txt"), "alpha").unwrap();
2053        std::fs::write(root.join("b.txt"), "bravo").unwrap();
2054        let workspace = LocalWorkspace::new(&root).unwrap();
2055        let result = workspace
2056            .apply_patch(PatchRequest {
2057                patch: Patch {
2058                    files: vec![
2059                        FilePatch {
2060                            path: WorkspacePath::parse("a.txt").unwrap(),
2061                            operation: PatchOperation::Modify,
2062                            expected_version: None,
2063                            hunks: vec![PatchHunk {
2064                                old_text: "alpha".to_string(),
2065                                new_text: "ALPHA".to_string(),
2066                            }],
2067                        },
2068                        FilePatch {
2069                            path: WorkspacePath::parse("b.txt").unwrap(),
2070                            operation: PatchOperation::Modify,
2071                            expected_version: None,
2072                            hunks: vec![PatchHunk {
2073                                old_text: "missing".to_string(),
2074                                new_text: "BRAVO".to_string(),
2075                            }],
2076                        },
2077                    ],
2078                    original_text: None,
2079                    metadata: RunMetadata::new(),
2080                },
2081                dry_run: false,
2082                allow_partial: false,
2083            })
2084            .await
2085            .unwrap();
2086        assert!(!result.applied);
2087        assert_eq!(
2088            std::fs::read_to_string(root.join("a.txt")).unwrap(),
2089            "alpha"
2090        );
2091        let _ = std::fs::remove_dir_all(root);
2092    }
2093
2094    #[tokio::test]
2095    async fn patch_stale_precondition_reports_conflict_without_write() {
2096        let root = temp_dir("patch-stale");
2097        std::fs::write(root.join("a.txt"), "alpha").unwrap();
2098        let workspace = LocalWorkspace::new(&root).unwrap();
2099        let path = WorkspacePath::parse("a.txt").unwrap();
2100        let content = workspace
2101            .read_file(&path, FileReadOptions::default())
2102            .await
2103            .unwrap();
2104        std::fs::write(root.join("a.txt"), "changed by user").unwrap();
2105
2106        let result = workspace
2107            .apply_patch(PatchRequest {
2108                patch: Patch {
2109                    files: vec![FilePatch {
2110                        path: path.clone(),
2111                        operation: PatchOperation::Modify,
2112                        expected_version: Some(content.version),
2113                        hunks: vec![PatchHunk {
2114                            old_text: "alpha".to_string(),
2115                            new_text: "ALPHA".to_string(),
2116                        }],
2117                    }],
2118                    original_text: None,
2119                    metadata: RunMetadata::new(),
2120                },
2121                dry_run: false,
2122                allow_partial: false,
2123            })
2124            .await
2125            .unwrap();
2126
2127        assert!(!result.applied);
2128        assert_eq!(result.conflicts.len(), 1);
2129        assert_eq!(
2130            std::fs::read_to_string(root.join("a.txt")).unwrap(),
2131            "changed by user"
2132        );
2133        let _ = std::fs::remove_dir_all(root);
2134    }
2135}