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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct WorkspaceRoot {
13 absolute: PathBuf,
14}
15
16impl WorkspaceRoot {
17 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 pub fn as_path(&self) -> &Path {
44 &self.absolute
45 }
46
47 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#[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 pub fn root() -> Self {
104 Self {
105 relative: PathBuf::new(),
106 }
107 }
108
109 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 pub fn as_path(&self) -> &Path {
182 &self.relative
183 }
184
185 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct ResolvedPath {
287 pub root: WorkspaceRoot,
289 pub workspace_path: WorkspacePath,
291 pub absolute: PathBuf,
293 pub kind: ResolvedPathKind,
295 pub symlink_target: Option<PathBuf>,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[non_exhaustive]
302pub enum ResolvedPathKind {
303 File,
305 Directory,
307 Symlink,
309 Missing,
311}
312
313#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
315#[non_exhaustive]
316pub enum SymlinkPolicy {
317 #[default]
320 FollowReadInsideRoot,
321 NoFollow,
323 RejectAll,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[non_exhaustive]
330pub enum WorkspaceAccess {
331 Read,
333 List,
335 Create,
337 Modify,
339 Delete,
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[non_exhaustive]
346pub enum TextEncoding {
347 Utf8,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct FileContent {
354 pub path: WorkspacePath,
356 pub version: FileVersion,
358 pub body: FileBody,
360 pub truncated: bool,
362 pub metadata: RunMetadata,
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[non_exhaustive]
369pub enum FileBody {
370 Text {
372 text: String,
374 encoding: TextEncoding,
376 },
377 Binary {
380 bytes: Vec<u8>,
382 media_type: Option<String>,
384 },
385}
386
387#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
389pub struct ContentDigest {
390 pub algorithm: String,
392 pub value: String,
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct FileVersion {
399 pub path: WorkspacePath,
401 pub digest: ContentDigest,
403 pub len: u64,
405 pub modified: Option<SystemTime>,
407}
408
409#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
411pub struct FileReadOptions {
412 pub max_bytes: Option<usize>,
414 pub include_binary: bool,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420#[non_exhaustive]
421pub enum FileWriteContent {
422 Text(String),
424 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub struct WriteFileRequest {
440 pub path: WorkspacePath,
442 pub content: FileWriteContent,
444 pub expected_version: Option<FileVersion>,
446 pub create: bool,
448 pub overwrite: bool,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454pub struct FileWriteResult {
455 pub path: WorkspacePath,
457 pub previous_version: Option<FileVersion>,
459 pub new_version: FileVersion,
461 pub created: bool,
463 pub bytes_written: u64,
465 pub metadata: RunMetadata,
467}
468
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
471pub struct ListFilesQuery {
472 pub path: WorkspacePath,
474 pub recursive: bool,
476 pub max_entries: Option<usize>,
478 pub include_hidden: bool,
480 pub respect_gitignore: bool,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486pub struct WorkspaceEntry {
487 pub path: WorkspacePath,
489 pub kind: ResolvedPathKind,
491 pub len: Option<u64>,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
497pub struct SnapshotRequest {
498 pub paths: Vec<WorkspacePath>,
500 pub recursive: bool,
502}
503
504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506pub struct WorkspaceSnapshot {
507 pub id: String,
509 pub root: WorkspaceRoot,
511 pub files: Vec<FileVersion>,
513 pub git_head: Option<String>,
515 pub metadata: RunMetadata,
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521pub struct DiffRequest {
522 pub before: WorkspaceSnapshot,
524 pub after: WorkspaceSnapshot,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530pub struct WorkspaceDiff {
531 pub changed_files: Vec<WorkspacePath>,
533 pub text: String,
535 pub truncated: bool,
537 pub metadata: RunMetadata,
539}
540
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
543pub struct Patch {
544 pub files: Vec<FilePatch>,
546 pub original_text: Option<String>,
548 pub metadata: RunMetadata,
550}
551
552#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554pub struct FilePatch {
555 pub path: WorkspacePath,
557 pub operation: PatchOperation,
559 pub expected_version: Option<FileVersion>,
561 pub hunks: Vec<PatchHunk>,
563}
564
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
567#[non_exhaustive]
568pub enum PatchOperation {
569 Create,
571 Modify,
573 Delete,
575 Rename {
577 from: WorkspacePath,
579 },
580}
581
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
584pub struct PatchHunk {
585 pub old_text: String,
588 pub new_text: String,
590}
591
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct PatchRequest {
595 pub patch: Patch,
597 pub dry_run: bool,
599 pub allow_partial: bool,
603}
604
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607pub struct PatchConflict {
608 pub path: WorkspacePath,
610 pub message: String,
612 pub expected_version: Option<FileVersion>,
614 pub actual_version: Option<FileVersion>,
616}
617
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
620pub struct PatchResult {
621 pub applied: bool,
623 pub changed_files: Vec<WorkspacePath>,
625 pub conflicts: Vec<PatchConflict>,
627 pub diff: WorkspaceDiff,
629 pub snapshot_before: WorkspaceSnapshot,
631 pub snapshot_after: Option<WorkspaceSnapshot>,
633 pub metadata: RunMetadata,
635}
636
637#[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 pub fn new() -> Self {
652 Self::default()
653 }
654
655 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 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 pub fn clear(&self) {
680 self.changes
681 .lock()
682 .expect("AgentChangeTracker lock poisoned")
683 .clear();
684 }
685}
686
687#[async_trait]
689pub trait Workspace: Send + Sync {
690 async fn root(&self) -> WorkspaceRoot;
692
693 async fn resolve(
695 &self,
696 path: &WorkspacePath,
697 access: WorkspaceAccess,
698 ) -> Result<ResolvedPath, WorkspaceError>;
699
700 async fn read_file(
702 &self,
703 path: &WorkspacePath,
704 options: FileReadOptions,
705 ) -> Result<FileContent, WorkspaceError>;
706
707 async fn write_file(
709 &self,
710 request: WriteFileRequest,
711 ) -> Result<FileWriteResult, WorkspaceError>;
712
713 async fn list_files(
715 &self,
716 query: ListFilesQuery,
717 ) -> Result<Vec<WorkspaceEntry>, WorkspaceError>;
718
719 async fn snapshot(&self, request: SnapshotRequest)
721 -> Result<WorkspaceSnapshot, WorkspaceError>;
722
723 async fn diff(&self, request: DiffRequest) -> Result<WorkspaceDiff, WorkspaceError>;
725
726 async fn apply_patch(&self, request: PatchRequest) -> Result<PatchResult, WorkspaceError>;
728}
729
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(default)]
733#[non_exhaustive]
734pub struct LocalWorkspaceConfig {
735 pub(crate) symlink_policy: SymlinkPolicy,
737 pub(crate) max_read_bytes: usize,
739 pub(crate) max_list_entries: usize,
741 pub(crate) include_hidden_by_default: bool,
743 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 pub fn new() -> Self {
763 Self::default()
764 }
765
766 pub fn symlink_policy(&self) -> SymlinkPolicy {
768 self.symlink_policy
769 }
770
771 pub fn with_symlink_policy(mut self, symlink_policy: SymlinkPolicy) -> Self {
773 self.symlink_policy = symlink_policy;
774 self
775 }
776
777 pub fn max_read_bytes(&self) -> usize {
779 self.max_read_bytes
780 }
781
782 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 pub fn max_list_entries(&self) -> usize {
790 self.max_list_entries
791 }
792
793 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 pub fn include_hidden_by_default(&self) -> bool {
801 self.include_hidden_by_default
802 }
803
804 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 pub fn respect_gitignore_by_default(&self) -> bool {
812 self.respect_gitignore_by_default
813 }
814
815 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#[derive(Debug, Clone)]
824pub struct LocalWorkspace {
825 root: WorkspaceRoot,
826 config: LocalWorkspaceConfig,
827 changes: AgentChangeTracker,
828}
829
830impl LocalWorkspace {
831 pub fn new(root: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
838 Self::with_config(root, LocalWorkspaceConfig::default())
839 }
840
841 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 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
1849#[non_exhaustive]
1850pub enum WorkspaceError {
1851 #[error("invalid workspace path: {message}")]
1853 InvalidPath {
1854 message: String,
1856 },
1857 #[error("path escapes workspace root: {path} is outside {root}")]
1859 OutsideRoot {
1860 path: String,
1862 root: String,
1864 },
1865 #[error("symlink escapes workspace root: {path} -> {target}")]
1867 SymlinkEscapesRoot {
1868 path: String,
1870 target: String,
1872 },
1873 #[error("workspace path not found: {path}: {message}")]
1875 NotFound {
1876 path: WorkspacePath,
1878 message: String,
1880 },
1881 #[error("workspace conflict: {conflict:?}")]
1883 Conflict {
1884 conflict: Box<PatchConflict>,
1886 },
1887 #[error("workspace path is not UTF-8: {path}")]
1889 NonUtf8Path {
1890 path: String,
1892 },
1893 #[error("workspace I/O error: {message}")]
1895 Io {
1896 message: String,
1898 },
1899 #[error("unsupported workspace operation: {message}")]
1901 Unsupported {
1902 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}