dsh-ops 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,371 @@
1
+ //! File-identity locks that serialize edits across aliases, processes, and server instances.
2
+
3
+ use fs2::FileExt;
4
+ use sha2::{Digest, Sha256};
5
+ #[cfg(any(test, unix))]
6
+ use std::fs;
7
+ use std::fs::File;
8
+ use std::path::Path;
9
+
10
+ /// Stable identity used for same-file decisions and lock ordering.
11
+ #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
12
+ pub(crate) struct PathIdentity {
13
+ material: Vec<u8>,
14
+ }
15
+
16
+ impl PathIdentity {
17
+ /// Resolves an existing file by filesystem identity, or a missing file by parent identity/name.
18
+ #[cfg(test)]
19
+ pub(crate) fn for_path(path: &Path) -> Result<Self, String> {
20
+ match fs::metadata(path) {
21
+ Ok(_) => existing_identity(path).map(|material| Self { material }),
22
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
23
+ missing_identity(path).map(|material| Self { material })
24
+ }
25
+ Err(error) => Err(crate::paths::io_error_message(path, &error)),
26
+ }
27
+ }
28
+
29
+ /// Returns the stable canonical-parent/name identity regardless of whether the entry exists.
30
+ pub(crate) fn for_name(path: &Path) -> Result<Self, String> {
31
+ missing_identity(path).map(|material| Self { material })
32
+ }
33
+
34
+ /// Returns the filesystem identity of an entry that must already exist.
35
+ pub(crate) fn for_existing(path: &Path) -> Result<Self, String> {
36
+ existing_identity(path).map(|material| Self { material })
37
+ }
38
+
39
+ fn lock_key(&self) -> String {
40
+ hex::encode(Sha256::digest(&self.material))
41
+ }
42
+ }
43
+
44
+ pub(crate) struct FilePathLock {
45
+ file: File,
46
+ }
47
+
48
+ #[cfg(test)]
49
+ pub(crate) fn is_lock_contended(error: &std::io::Error) -> bool {
50
+ if error.kind() == std::io::ErrorKind::WouldBlock {
51
+ return true;
52
+ }
53
+ #[cfg(windows)]
54
+ {
55
+ error.raw_os_error() == Some(33)
56
+ }
57
+ #[cfg(not(windows))]
58
+ {
59
+ false
60
+ }
61
+ }
62
+
63
+ impl FilePathLock {
64
+ pub(crate) fn acquire(identity: &PathIdentity, path: &Path) -> Result<Self, String> {
65
+ let directory = super::private_storage::edit_lock_directory();
66
+ super::private_storage::ensure_private_directory(&directory, "edit-lock")?;
67
+ let lock_path = directory.join(format!("{}.lock", identity.lock_key()));
68
+ let file = super::private_storage::open_lock_file(&lock_path, "edit lock")?;
69
+ file.lock_exclusive().map_err(|error| {
70
+ format!(
71
+ "Cannot lock {} for editing: {error}. Retry after the other FastCtx process finishes.",
72
+ crate::paths::display_path(path)
73
+ )
74
+ })?;
75
+ Ok(Self { file })
76
+ }
77
+
78
+ #[cfg(test)]
79
+ fn try_acquire(identity: &PathIdentity, path: &Path) -> Result<Option<Self>, String> {
80
+ let directory = super::private_storage::edit_lock_directory();
81
+ super::private_storage::ensure_private_directory(&directory, "edit-lock")?;
82
+ let lock_path = directory.join(format!("{}.lock", identity.lock_key()));
83
+ let file = super::private_storage::open_lock_file(&lock_path, "edit lock")?;
84
+ match file.try_lock_exclusive() {
85
+ Ok(()) => Ok(Some(Self { file })),
86
+ Err(error) if is_lock_contended(&error) => Ok(None),
87
+ Err(error) => Err(format!(
88
+ "Cannot try-lock {} for editing: {error}.",
89
+ crate::paths::display_path(path)
90
+ )),
91
+ }
92
+ }
93
+ }
94
+
95
+ impl Drop for FilePathLock {
96
+ fn drop(&mut self) {
97
+ let _ = fs2::FileExt::unlock(&self.file);
98
+ }
99
+ }
100
+
101
+ fn missing_identity(path: &Path) -> Result<Vec<u8>, String> {
102
+ let parent = path.parent().ok_or_else(|| {
103
+ format!(
104
+ "Parent directory does not exist: {}",
105
+ crate::paths::display_path(path)
106
+ )
107
+ })?;
108
+ let parent = crate::paths::canonical_existing(parent).map_err(|_| {
109
+ format!(
110
+ "Parent directory does not exist: {}",
111
+ crate::paths::display_path(parent)
112
+ )
113
+ })?;
114
+ if !parent.is_dir() {
115
+ return Err(format!(
116
+ "Parent directory does not exist: {}",
117
+ crate::paths::display_path(&parent)
118
+ ));
119
+ }
120
+ let file_name = path.file_name().ok_or_else(|| {
121
+ format!(
122
+ "Parent directory does not exist: {}",
123
+ crate::paths::display_path(path)
124
+ )
125
+ })?;
126
+ let mut material = b"missing\0".to_vec();
127
+ material.extend_from_slice(&existing_identity(&parent)?);
128
+ material.push(0);
129
+ append_normalized_file_name(&mut material, file_name);
130
+ Ok(material)
131
+ }
132
+
133
+ #[cfg(unix)]
134
+ fn existing_identity(path: &Path) -> Result<Vec<u8>, String> {
135
+ use std::os::unix::fs::MetadataExt;
136
+
137
+ let metadata =
138
+ fs::metadata(path).map_err(|error| crate::paths::io_error_message(path, &error))?;
139
+ let mut material = b"unix\0".to_vec();
140
+ material.extend_from_slice(&metadata.dev().to_le_bytes());
141
+ material.extend_from_slice(&metadata.ino().to_le_bytes());
142
+ Ok(material)
143
+ }
144
+
145
+ #[cfg(windows)]
146
+ fn existing_identity(path: &Path) -> Result<Vec<u8>, String> {
147
+ use std::mem::MaybeUninit;
148
+ use std::os::windows::ffi::OsStrExt;
149
+ use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
150
+ use windows_sys::Win32::Storage::FileSystem::{
151
+ BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES,
152
+ FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileInformationByHandle,
153
+ OPEN_EXISTING,
154
+ };
155
+
156
+ let wide = path
157
+ .as_os_str()
158
+ .encode_wide()
159
+ .chain(Some(0))
160
+ .collect::<Vec<_>>();
161
+ let handle = unsafe {
162
+ CreateFileW(
163
+ wide.as_ptr(),
164
+ FILE_READ_ATTRIBUTES,
165
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
166
+ std::ptr::null(),
167
+ OPEN_EXISTING,
168
+ FILE_FLAG_BACKUP_SEMANTICS,
169
+ std::ptr::null_mut(),
170
+ )
171
+ };
172
+ if handle == INVALID_HANDLE_VALUE {
173
+ return Err(crate::paths::io_error_message(
174
+ path,
175
+ &std::io::Error::last_os_error(),
176
+ ));
177
+ }
178
+ let mut information = MaybeUninit::<BY_HANDLE_FILE_INFORMATION>::zeroed();
179
+ let success = unsafe { GetFileInformationByHandle(handle, information.as_mut_ptr()) };
180
+ let error = if success == 0 {
181
+ Some(std::io::Error::last_os_error())
182
+ } else {
183
+ None
184
+ };
185
+ unsafe {
186
+ CloseHandle(handle);
187
+ }
188
+ if let Some(error) = error {
189
+ return Err(crate::paths::io_error_message(path, &error));
190
+ }
191
+ let information = unsafe { information.assume_init() };
192
+ let file_index = ((information.nFileIndexHigh as u64) << 32) | information.nFileIndexLow as u64;
193
+ let mut material = b"windows\0".to_vec();
194
+ material.extend_from_slice(&information.dwVolumeSerialNumber.to_le_bytes());
195
+ material.extend_from_slice(&file_index.to_le_bytes());
196
+ Ok(material)
197
+ }
198
+
199
+ #[cfg(not(any(unix, windows)))]
200
+ fn existing_identity(path: &Path) -> Result<Vec<u8>, String> {
201
+ let canonical = crate::paths::canonical_existing(path)
202
+ .map_err(|error| crate::paths::io_error_message(path, &error))?;
203
+ Ok(format!("other\0{}", canonical.to_string_lossy()).into_bytes())
204
+ }
205
+
206
+ #[cfg(unix)]
207
+ fn append_normalized_file_name(material: &mut Vec<u8>, file_name: &std::ffi::OsStr) {
208
+ use std::os::unix::ffi::OsStrExt;
209
+ material.extend_from_slice(file_name.as_bytes());
210
+ }
211
+
212
+ #[cfg(windows)]
213
+ fn append_normalized_file_name(material: &mut Vec<u8>, file_name: &std::ffi::OsStr) {
214
+ let normalized = file_name
215
+ .to_string_lossy()
216
+ .to_lowercase()
217
+ .trim_end_matches([' ', '.'])
218
+ .to_string();
219
+ material.extend_from_slice(normalized.as_bytes());
220
+ }
221
+
222
+ #[cfg(not(any(unix, windows)))]
223
+ fn append_normalized_file_name(material: &mut Vec<u8>, file_name: &std::ffi::OsStr) {
224
+ material.extend_from_slice(file_name.to_string_lossy().as_bytes());
225
+ }
226
+
227
+ #[cfg(test)]
228
+ mod tests {
229
+ use super::{FilePathLock, PathIdentity};
230
+ use std::path::PathBuf;
231
+ use std::time::{Duration, Instant};
232
+
233
+ struct ChildGuard(Option<std::process::Child>);
234
+
235
+ impl ChildGuard {
236
+ fn new(child: std::process::Child) -> Self {
237
+ Self(Some(child))
238
+ }
239
+
240
+ fn kill_and_wait(&mut self) {
241
+ if let Some(mut child) = self.0.take() {
242
+ let _ = child.kill();
243
+ child.wait().expect("failed to reap lock helper");
244
+ }
245
+ }
246
+ }
247
+
248
+ impl Drop for ChildGuard {
249
+ fn drop(&mut self) {
250
+ if let Some(mut child) = self.0.take() {
251
+ let _ = child.kill();
252
+ let _ = child.wait();
253
+ }
254
+ }
255
+ }
256
+
257
+ #[test]
258
+ fn identity_uses_the_file_not_its_spelling() {
259
+ let temp = tempfile::tempdir().unwrap();
260
+ let target = temp.path().join("target.txt");
261
+ let alias = temp.path().join("alias.txt");
262
+ std::fs::write(&target, b"identity").unwrap();
263
+ std::fs::hard_link(&target, &alias).unwrap();
264
+ assert_eq!(
265
+ PathIdentity::for_path(&target).unwrap(),
266
+ PathIdentity::for_path(&alias).unwrap()
267
+ );
268
+ #[cfg(unix)]
269
+ {
270
+ use std::os::unix::fs::symlink;
271
+ let symlink_path = temp.path().join("symlink.txt");
272
+ symlink(&target, &symlink_path).unwrap();
273
+ assert_eq!(
274
+ PathIdentity::for_path(&target).unwrap(),
275
+ PathIdentity::for_path(&symlink_path).unwrap()
276
+ );
277
+ }
278
+
279
+ let missing = temp.path().join("New.txt");
280
+ let same_spelling = temp.path().join("New.txt");
281
+ assert_eq!(
282
+ PathIdentity::for_path(&missing).unwrap(),
283
+ PathIdentity::for_path(&same_spelling).unwrap()
284
+ );
285
+ let name_before_create = PathIdentity::for_name(&missing).unwrap();
286
+ std::fs::write(&missing, b"created").unwrap();
287
+ assert_eq!(
288
+ name_before_create,
289
+ PathIdentity::for_name(&missing).unwrap(),
290
+ "the stable parent/name lock must bridge missing to existing"
291
+ );
292
+ #[cfg(windows)]
293
+ {
294
+ assert_eq!(
295
+ name_before_create,
296
+ PathIdentity::for_name(&temp.path().join("new.TXT")).unwrap()
297
+ );
298
+ assert_eq!(
299
+ PathIdentity::for_name(&temp.path().join("name")).unwrap(),
300
+ PathIdentity::for_name(&temp.path().join("name... ")).unwrap()
301
+ );
302
+ }
303
+ }
304
+
305
+ #[test]
306
+ #[ignore]
307
+ fn cross_process_lock_helper() {
308
+ let Some(path) = std::env::var_os("FASTCTX_LOCK_HELPER_PATH") else {
309
+ return;
310
+ };
311
+ let ready = std::env::var_os("FASTCTX_LOCK_HELPER_READY").unwrap();
312
+ let path = PathBuf::from(path);
313
+ let identity = PathIdentity::for_path(&path).unwrap();
314
+ let _guard = FilePathLock::acquire(&identity, &path).unwrap();
315
+ std::fs::write(ready, b"ready").unwrap();
316
+ loop {
317
+ std::thread::park();
318
+ }
319
+ }
320
+
321
+ #[test]
322
+ fn cross_process_alias_lock_blocks_and_a_crash_releases_it() {
323
+ let temp = tempfile::tempdir().unwrap();
324
+ let target = temp.path().join("target.txt");
325
+ let alias = temp.path().join("alias.txt");
326
+ let ready = temp.path().join("ready");
327
+ std::fs::write(&target, b"locked").unwrap();
328
+ std::fs::hard_link(&target, &alias).unwrap();
329
+
330
+ let child = std::process::Command::new(std::env::current_exe().unwrap())
331
+ .arg("edit::locks::tests::cross_process_lock_helper")
332
+ .arg("--exact")
333
+ .arg("--ignored")
334
+ .arg("--nocapture")
335
+ .env("FASTCTX_LOCK_HELPER_PATH", &target)
336
+ .env("FASTCTX_LOCK_HELPER_READY", &ready)
337
+ .spawn()
338
+ .unwrap();
339
+ let mut child = ChildGuard::new(child);
340
+ let deadline = Instant::now() + Duration::from_secs(10);
341
+ while !ready.exists() && Instant::now() < deadline {
342
+ std::thread::sleep(Duration::from_millis(10));
343
+ }
344
+ assert!(
345
+ ready.exists(),
346
+ "child never acquired the cross-process lock"
347
+ );
348
+
349
+ let identity = PathIdentity::for_path(&alias).unwrap();
350
+ assert!(
351
+ FilePathLock::try_acquire(&identity, &alias)
352
+ .unwrap()
353
+ .is_none(),
354
+ "the alias unexpectedly bypassed the held identity lock"
355
+ );
356
+
357
+ child.kill_and_wait();
358
+ let deadline = Instant::now() + Duration::from_secs(10);
359
+ loop {
360
+ if let Some(guard) = FilePathLock::try_acquire(&identity, &alias).unwrap() {
361
+ drop(guard);
362
+ break;
363
+ }
364
+ assert!(
365
+ Instant::now() < deadline,
366
+ "the kernel did not release the lock after the holder crashed"
367
+ );
368
+ std::thread::yield_now();
369
+ }
370
+ }
371
+ }
@@ -0,0 +1,213 @@
1
+ //! Byte-preserving batch replacement with optimistic and cross-process write protection.
2
+
3
+ mod document;
4
+ mod locks;
5
+ pub(crate) mod private_storage;
6
+ mod replace;
7
+
8
+ use crate::budget::token_budget;
9
+ use crate::glob_filter::GlobPatterns;
10
+ use crate::model::ToolResponse;
11
+ use locks::PathIdentity;
12
+ use schemars::JsonSchema;
13
+ use serde::Deserialize;
14
+ use std::collections::BTreeMap;
15
+ use std::sync::{Arc, Mutex, Weak};
16
+
17
+ /// Parameters for deterministic batch replacement across one file or a project tree.
18
+ #[derive(Clone, Debug, Deserialize, JsonSchema)]
19
+ pub struct ReplaceRequest {
20
+ /// The regular expression to replace (Rust regex; escape literal braces).
21
+ pub pattern: String,
22
+ /// Replacement text; $1/${name} reference groups, $$ is a literal $, empty deletes the match.
23
+ pub replacement: String,
24
+ /// File or directory to edit.
25
+ #[schemars(description = crate::model_guidance::local_path_description(
26
+ "File or directory to edit."
27
+ ))]
28
+ pub path: String,
29
+ /// Globs for directory targets, e.g. ["**/*.rs", "!tests/**"]. A leading `!` excludes and always wins; negative-only lists include every other file.
30
+ // Published as a plain string array; see the note on GlobRequest::pattern. (2026-08-17)
31
+ #[schemars(with = "Option<Vec<String>>")]
32
+ pub glob: Option<GlobPatterns>,
33
+ /// Treat pattern as a literal string, not a regex.
34
+ pub literal: Option<bool>,
35
+ /// Case-insensitive matching.
36
+ pub case_insensitive: Option<bool>,
37
+ /// `.` also matches newlines (spanning-line matches); `\n` also matches `\r\n`.
38
+ pub dot_all: Option<bool>,
39
+ /// Refuse to write if the total match count exceeds this guard.
40
+ pub max_replacements: Option<usize>,
41
+ /// Preview matches and counts without writing anything.
42
+ pub dry_run: Option<bool>,
43
+ /// Single-file target only: decode with this WHATWG label.
44
+ pub encoding: Option<String>,
45
+ /// Directory target: fallback encoding for otherwise unresolved files.
46
+ pub fallback_encoding: Option<String>,
47
+ }
48
+
49
+ /// Stateful replacement service sharing in-process locks across concurrent calls.
50
+ #[derive(Clone, Debug)]
51
+ pub struct ReplaceService {
52
+ path_locks: Arc<PathLocks>,
53
+ }
54
+
55
+ #[derive(Debug, Default)]
56
+ struct PathLocks {
57
+ locks: Mutex<BTreeMap<PathIdentity, Weak<Mutex<()>>>>,
58
+ }
59
+
60
+ impl PathLocks {
61
+ fn for_identity(&self, identity: &PathIdentity) -> Arc<Mutex<()>> {
62
+ let key = identity.clone();
63
+ let mut locks = self.locks.lock().unwrap();
64
+ locks.retain(|_, lock| lock.strong_count() > 0);
65
+ if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
66
+ return lock;
67
+ }
68
+ let lock = Arc::new(Mutex::new(()));
69
+ locks.insert(key, Arc::downgrade(&lock));
70
+ lock
71
+ }
72
+ }
73
+
74
+ impl ReplaceService {
75
+ /// Creates a replacement service with shared target-identity locks.
76
+ pub fn new() -> Self {
77
+ Self {
78
+ path_locks: Arc::new(PathLocks::default()),
79
+ }
80
+ }
81
+
82
+ #[cfg(test)]
83
+ pub(crate) fn shares_locks_with(&self, other: &Self) -> bool {
84
+ Arc::ptr_eq(&self.path_locks, &other.path_locks)
85
+ }
86
+
87
+ /// Applies one regex plan across all candidates after a full blast-radius count.
88
+ pub fn replace(&self, request: ReplaceRequest) -> ToolResponse {
89
+ self.replace_with_limit(
90
+ request,
91
+ crate::control::settings::DEFAULT_REPLACE_FILE_LIMIT_MIB as u64,
92
+ )
93
+ }
94
+
95
+ pub(crate) fn replace_with_limit(
96
+ &self,
97
+ request: ReplaceRequest,
98
+ max_file_size_mib: u64,
99
+ ) -> ToolResponse {
100
+ replace::replace(self, request, max_file_size_mib)
101
+ }
102
+ }
103
+
104
+ impl Default for ReplaceService {
105
+ fn default() -> Self {
106
+ Self::new()
107
+ }
108
+ }
109
+
110
+ fn edit_token_budget() -> Result<usize, String> {
111
+ token_budget().map_err(|message| format!("{message} Fix the env var and restart the session."))
112
+ }
113
+
114
+ fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
115
+ if count == 1 { singular } else { plural }
116
+ }
117
+
118
+ #[cfg(test)]
119
+ mod tests {
120
+ use super::{ReplaceRequest, ReplaceService};
121
+ use crate::edit::document::set_before_commit_hook;
122
+
123
+ fn response_text(response: &crate::ToolResponse) -> &str {
124
+ match response.content.as_slice() {
125
+ [crate::ToolContent::Text(text)] => text,
126
+ content => panic!("expected one text block, got {content:?}"),
127
+ }
128
+ }
129
+
130
+ fn request(path: &std::path::Path, pattern: &str, replacement: &str) -> ReplaceRequest {
131
+ ReplaceRequest {
132
+ pattern: pattern.to_string(),
133
+ replacement: replacement.to_string(),
134
+ path: crate::paths::display_path(path),
135
+ glob: None,
136
+ literal: None,
137
+ case_insensitive: None,
138
+ dot_all: None,
139
+ max_replacements: None,
140
+ dry_run: None,
141
+ encoding: None,
142
+ fallback_encoding: None,
143
+ }
144
+ }
145
+
146
+ #[test]
147
+ fn replace_cas_rejects_external_changes() {
148
+ let temp = tempfile::tempdir().unwrap();
149
+ let editor = ReplaceService::new();
150
+ let path = temp.path().join("replace.txt");
151
+ std::fs::write(&path, b"old").unwrap();
152
+ set_before_commit_hook(|path| std::fs::write(path, b"external replace").unwrap());
153
+
154
+ let response = editor.replace(request(&path, "old", "new"));
155
+
156
+ assert!(!response.is_error);
157
+ assert!(response_text(&response).contains("changed on disk during the edit"));
158
+ assert!(response_text(&response).contains(
159
+ "(Partial: 0 replacements written in 0 files; 1 file failed — see the report above.)"
160
+ ));
161
+ assert_eq!(std::fs::read(&path).unwrap(), b"external replace");
162
+ }
163
+
164
+ #[test]
165
+ fn candidate_set_is_frozen_before_the_first_replace_write() {
166
+ let temp = tempfile::tempdir().unwrap();
167
+ let editor = ReplaceService::new();
168
+ let root = temp.path().join("tree");
169
+ std::fs::create_dir(&root).unwrap();
170
+ std::fs::write(root.join("a.txt"), b"hit\n").unwrap();
171
+ std::fs::write(root.join("b.txt"), b"hit\n").unwrap();
172
+ std::fs::write(root.join(".gitignore"), b"").unwrap();
173
+ let ignore = root.join(".gitignore");
174
+ set_before_commit_hook(move |_| std::fs::write(ignore, b"*.txt\n").unwrap());
175
+
176
+ let response = editor.replace(request(&root, "hit", "done"));
177
+
178
+ assert!(!response.is_error, "{}", response_text(&response));
179
+ assert_eq!(std::fs::read(root.join("a.txt")).unwrap(), b"done\n");
180
+ assert_eq!(std::fs::read(root.join("b.txt")).unwrap(), b"done\n");
181
+ }
182
+
183
+ #[cfg(unix)]
184
+ #[test]
185
+ fn replace_recognizes_symlink_aliases_as_one_target_identity() {
186
+ use std::os::unix::fs::symlink;
187
+
188
+ let temp = tempfile::tempdir().unwrap();
189
+ let editor = ReplaceService::new();
190
+ let root = temp.path().join("tree");
191
+ std::fs::create_dir(&root).unwrap();
192
+ let target = root.join("target.txt");
193
+ let alias = root.join("alias.txt");
194
+ std::fs::write(&target, b"hit\n").unwrap();
195
+ symlink(&target, &alias).unwrap();
196
+
197
+ let response = editor.replace(request(&root, "hit", "done"));
198
+
199
+ assert!(!response.is_error, "{}", response_text(&response));
200
+ assert!(
201
+ response_text(&response).ends_with("(Complete: 1 replacement in 1 file.)"),
202
+ "{}",
203
+ response_text(&response)
204
+ );
205
+ assert_eq!(std::fs::read(&target).unwrap(), b"done\n");
206
+ assert!(
207
+ std::fs::symlink_metadata(alias)
208
+ .unwrap()
209
+ .file_type()
210
+ .is_symlink()
211
+ );
212
+ }
213
+ }