1use harness_core::{ToolError, ToolErrorCode};
2use harness_read::is_binary;
3use serde_json::Value;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::constants::{BINARY_SAMPLE_BYTES, MAX_EDIT_FILE_SIZE};
8use crate::diff::{unified_diff, UnifiedDiffArgs};
9use crate::engine::{apply_edit, apply_pipeline, PipelineResult};
10use crate::fence::{fence_write, sha256_hex};
11use crate::format::{
12 format_edit_success, format_multi_edit_success, format_preview, format_write_success,
13 FormatEditArgs, FormatMultiEditArgs, FormatPreviewArgs, FormatWriteArgs,
14};
15use crate::ledger::LedgerEntry;
16use crate::schema::{
17 safe_parse_edit_params, safe_parse_multi_edit_params, safe_parse_write_params, EditParams,
18 EditSpec, MultiEditParams, WriteParams,
19};
20use crate::types::{
21 AnyMeta, EditMeta, EditResult, ErrorResult, MultiEditMeta, MultiEditResult, PreviewMeta,
22 PreviewResult, TextWriteResult, WriteMeta, WriteResult, WriteSessionConfig,
23};
24
25fn err_w(error: ToolError) -> WriteResult {
26 WriteResult::Error(ErrorResult { error })
27}
28fn err_e(error: ToolError) -> EditResult {
29 EditResult::Error(ErrorResult { error })
30}
31fn err_m(error: ToolError) -> MultiEditResult {
32 MultiEditResult::Error(ErrorResult { error })
33}
34
35fn now_ms() -> u64 {
36 SystemTime::now()
37 .duration_since(UNIX_EPOCH)
38 .map(|d| d.as_millis() as u64)
39 .unwrap_or(0)
40}
41
42pub async fn write(input: Value, session: &WriteSessionConfig) -> WriteResult {
45 let params = match safe_parse_write_params(&input) {
46 Ok(p) => p,
47 Err(e) => return err_w(ToolError::new(ToolErrorCode::InvalidParam, e.to_string())),
48 };
49
50 let resolved = resolve_path(&session.cwd, ¶ms.path).await;
51 if let Some(e) = fence_write(&session.permissions, &resolved) {
52 return err_w(e);
53 }
54
55 execute_write(session, &resolved, ¶ms).await
56}
57
58async fn execute_write(
59 session: &WriteSessionConfig,
60 resolved: &Path,
61 params: &WriteParams,
62) -> WriteResult {
63 let meta_res = tokio::fs::metadata(resolved).await;
64 let exists = meta_res.as_ref().map(|m| m.is_file()).unwrap_or(false);
65 let is_dir = meta_res.as_ref().map(|m| m.is_dir()).unwrap_or(false);
66 if is_dir {
67 return err_w(
68 ToolError::new(
69 ToolErrorCode::InvalidParam,
70 format!("Path is a directory, not a file: {}", resolved.to_string_lossy()),
71 )
72 .with_meta(serde_json::json!({ "path": resolved.to_string_lossy() })),
73 );
74 }
75
76 let mut previous_sha: Option<String> = None;
77 let mut previous_bytes: u64 = 0;
78
79 if exists {
80 let existing = match tokio::fs::read(resolved).await {
81 Ok(b) => b,
82 Err(e) => {
83 return err_w(ToolError::new(
84 ToolErrorCode::IoError,
85 format!("read failed: {}", e),
86 ));
87 }
88 };
89 previous_bytes = existing.len() as u64;
90 let cur_sha = sha256_hex(&existing);
91 previous_sha = Some(cur_sha.clone());
92
93 let ledger = session.ledger.get_latest(&resolved.to_string_lossy());
94 let entry = match ledger {
95 Some(e) => e,
96 None => {
97 return err_w(
98 ToolError::new(
99 ToolErrorCode::NotReadThisSession,
100 format!(
101 "Write refuses to overwrite a file that has not been Read in this session: {}\n\nCall Read on this path first, then retry Write.",
102 resolved.to_string_lossy()
103 ),
104 )
105 .with_meta(serde_json::json!({ "path": resolved.to_string_lossy() })),
106 );
107 }
108 };
109 if entry.sha256 != cur_sha {
110 return err_w(
111 ToolError::new(
112 ToolErrorCode::StaleRead,
113 format!(
114 "File has changed on disk since the last Read: {}\n\nOld sha256: {}\nNew sha256: {}\n\nRe-Read the file to refresh the ledger, then retry Write.",
115 resolved.to_string_lossy(),
116 entry.sha256,
117 cur_sha
118 ),
119 )
120 .with_meta(serde_json::json!({
121 "path": resolved.to_string_lossy(),
122 "ledger_sha256": entry.sha256,
123 "current_sha256": cur_sha,
124 })),
125 );
126 }
127 }
128
129 if !exists {
130 if let Some(parent) = resolved.parent() {
131 if let Err(e) = tokio::fs::create_dir_all(parent).await {
132 return err_w(ToolError::new(
133 ToolErrorCode::IoError,
134 format!("mkdir failed: {}", e),
135 ));
136 }
137 }
138 }
139
140 let bytes = params.content.as_bytes();
141 if let Err(e) = atomic_write(resolved, bytes).await {
142 return err_w(ToolError::new(
143 ToolErrorCode::IoError,
144 format!("write failed: {}", e),
145 ));
146 }
147
148 let new_sha = sha256_hex(bytes);
149 let mtime = tokio::fs::metadata(resolved)
150 .await
151 .ok()
152 .and_then(|m| m.modified().ok())
153 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
154 .map(|d| d.as_millis() as u64)
155 .unwrap_or_else(now_ms);
156
157 session.ledger.record(LedgerEntry {
158 path: resolved.to_string_lossy().into_owned(),
159 sha256: new_sha.clone(),
160 mtime_ms: mtime,
161 size_bytes: bytes.len() as u64,
162 timestamp_ms: now_ms(),
163 });
164
165 let output = format_write_success(FormatWriteArgs {
166 path: &resolved.to_string_lossy(),
167 created: !exists,
168 bytes_before: previous_bytes,
169 bytes_after: bytes.len() as u64,
170 });
171
172 WriteResult::Text(TextWriteResult {
173 output,
174 meta: AnyMeta::Write(WriteMeta {
175 path: resolved.to_string_lossy().into_owned(),
176 bytes_written: bytes.len() as u64,
177 sha256: new_sha,
178 mtime_ms: mtime,
179 created: !exists,
180 previous_sha256: previous_sha,
181 }),
182 })
183}
184
185struct Preflight {
188 existing_content: String,
189 existing_bytes: Vec<u8>,
190 previous_sha: String,
191}
192
193async fn preflight_mutation(
194 session: &WriteSessionConfig,
195 resolved: &Path,
196) -> Result<Preflight, ToolError> {
197 let meta = tokio::fs::metadata(resolved).await.map_err(|e| {
198 if e.kind() == std::io::ErrorKind::NotFound {
199 ToolError::new(
200 ToolErrorCode::NotFound,
201 format!(
202 "File not found: {}. Edit requires an existing file; use Write to create new files.",
203 resolved.to_string_lossy()
204 ),
205 )
206 .with_meta(serde_json::json!({ "path": resolved.to_string_lossy() }))
207 } else {
208 ToolError::new(
209 ToolErrorCode::IoError,
210 format!("stat failed: {}", e),
211 )
212 }
213 })?;
214
215 if meta.is_dir() {
216 return Err(ToolError::new(
217 ToolErrorCode::InvalidParam,
218 format!("Path is a directory, not a file: {}", resolved.to_string_lossy()),
219 )
220 .with_meta(serde_json::json!({ "path": resolved.to_string_lossy() })));
221 }
222
223 let max_size = session.max_file_size.unwrap_or(MAX_EDIT_FILE_SIZE);
224 if meta.len() > max_size {
225 return Err(ToolError::new(
226 ToolErrorCode::TooLarge,
227 format!(
228 "File size {} exceeds max {} for in-memory edit. Narrow the file or use a streaming tool.",
229 meta.len(),
230 max_size
231 ),
232 )
233 .with_meta(serde_json::json!({
234 "path": resolved.to_string_lossy(),
235 "size": meta.len(),
236 "max": max_size,
237 })));
238 }
239
240 let bytes = tokio::fs::read(resolved).await.map_err(|e| {
241 ToolError::new(
242 ToolErrorCode::IoError,
243 format!("read failed: {}", e),
244 )
245 })?;
246
247 let sample_end = BINARY_SAMPLE_BYTES.min(bytes.len());
248 if is_binary(&resolved.to_string_lossy(), &bytes[..sample_end]) {
249 return Err(ToolError::new(
250 ToolErrorCode::BinaryNotEditable,
251 format!(
252 "Cannot Edit binary file: {}. Use Write to replace binary content wholesale if intentional.",
253 resolved.to_string_lossy()
254 ),
255 )
256 .with_meta(serde_json::json!({ "path": resolved.to_string_lossy() })));
257 }
258
259 let current_sha = sha256_hex(&bytes);
260 let ledger = session.ledger.get_latest(&resolved.to_string_lossy());
261 let entry = match ledger {
262 Some(e) => e,
263 None => {
264 return Err(ToolError::new(
265 ToolErrorCode::NotReadThisSession,
266 format!(
267 "File has not been Read in this session: {}\n\nCall Read on this path first, then retry the edit.",
268 resolved.to_string_lossy()
269 ),
270 )
271 .with_meta(serde_json::json!({ "path": resolved.to_string_lossy() })));
272 }
273 };
274 if entry.sha256 != current_sha {
275 return Err(ToolError::new(
276 ToolErrorCode::StaleRead,
277 format!(
278 "File has changed on disk since the last Read: {}\n\nOld sha256: {}\nNew sha256: {}\n\nRe-Read the file to refresh the ledger, then retry the edit.",
279 resolved.to_string_lossy(),
280 entry.sha256,
281 current_sha
282 ),
283 )
284 .with_meta(serde_json::json!({
285 "path": resolved.to_string_lossy(),
286 "ledger_sha256": entry.sha256,
287 "current_sha256": current_sha,
288 })));
289 }
290
291 let content = String::from_utf8_lossy(&bytes).into_owned();
292 Ok(Preflight {
293 existing_content: content,
294 existing_bytes: bytes,
295 previous_sha: current_sha,
296 })
297}
298
299pub async fn edit(input: Value, session: &WriteSessionConfig) -> EditResult {
300 let params = match safe_parse_edit_params(&input) {
301 Ok(p) => p,
302 Err(e) => return err_e(ToolError::new(ToolErrorCode::InvalidParam, e.to_string())),
303 };
304
305 let resolved = resolve_path(&session.cwd, ¶ms.path).await;
306 if let Some(e) = fence_write(&session.permissions, &resolved) {
307 return err_e(e);
308 }
309
310 execute_edit(session, &resolved, ¶ms).await
311}
312
313async fn execute_edit(
314 session: &WriteSessionConfig,
315 resolved: &Path,
316 params: &EditParams,
317) -> EditResult {
318 let pre = match preflight_mutation(session, resolved).await {
319 Ok(p) => p,
320 Err(e) => return err_e(e),
321 };
322
323 let edit_spec = EditSpec {
324 old_string: params.old_string.clone(),
325 new_string: params.new_string.clone(),
326 replace_all: params.replace_all,
327 ignore_whitespace: params.ignore_whitespace,
328 };
329
330 let result = match apply_edit(&pre.existing_content, &edit_spec) {
331 Ok(r) => r,
332 Err(e) => return err_e(e),
333 };
334
335 let new_content = result.content;
336 let new_bytes = new_content.as_bytes();
337
338 if params.dry_run.unwrap_or(false) {
339 let diff = unified_diff(UnifiedDiffArgs {
340 old_path: &resolved.to_string_lossy(),
341 new_path: &resolved.to_string_lossy(),
342 old_content: &pre.existing_content,
343 new_content: &new_content,
344 });
345 return EditResult::Preview(PreviewResult {
346 output: format_preview(FormatPreviewArgs {
347 path: &resolved.to_string_lossy(),
348 diff: &diff,
349 would_write_bytes: new_bytes.len() as u64,
350 bytes_before: pre.existing_bytes.len() as u64,
351 }),
352 diff,
353 meta: PreviewMeta {
354 path: resolved.to_string_lossy().into_owned(),
355 would_write_bytes: new_bytes.len() as u64,
356 bytes_delta: new_bytes.len() as i64 - pre.existing_bytes.len() as i64,
357 previous_sha256: pre.previous_sha,
358 },
359 });
360 }
361
362 if let Err(e) = atomic_write(resolved, new_bytes).await {
363 return err_e(ToolError::new(
364 ToolErrorCode::IoError,
365 format!("write failed: {}", e),
366 ));
367 }
368
369 let new_sha = sha256_hex(new_bytes);
370 let mtime = tokio::fs::metadata(resolved)
371 .await
372 .ok()
373 .and_then(|m| m.modified().ok())
374 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
375 .map(|d| d.as_millis() as u64)
376 .unwrap_or_else(now_ms);
377
378 session.ledger.record(LedgerEntry {
379 path: resolved.to_string_lossy().into_owned(),
380 sha256: new_sha.clone(),
381 mtime_ms: mtime,
382 size_bytes: new_bytes.len() as u64,
383 timestamp_ms: now_ms(),
384 });
385
386 EditResult::Text(TextWriteResult {
387 output: format_edit_success(FormatEditArgs {
388 path: &resolved.to_string_lossy(),
389 replacements: result.replacements,
390 replace_all: params.replace_all.unwrap_or(false),
391 bytes_before: pre.existing_bytes.len() as u64,
392 bytes_after: new_bytes.len() as u64,
393 warnings: &result.warnings,
394 }),
395 meta: AnyMeta::Edit(EditMeta {
396 path: resolved.to_string_lossy().into_owned(),
397 replacements: result.replacements,
398 bytes_delta: new_bytes.len() as i64 - pre.existing_bytes.len() as i64,
399 sha256: new_sha,
400 mtime_ms: mtime,
401 previous_sha256: pre.previous_sha,
402 warnings: if result.warnings.is_empty() {
403 None
404 } else {
405 Some(result.warnings)
406 },
407 }),
408 })
409}
410
411pub async fn multi_edit(input: Value, session: &WriteSessionConfig) -> MultiEditResult {
414 let params = match safe_parse_multi_edit_params(&input) {
415 Ok(p) => p,
416 Err(e) => return err_m(ToolError::new(ToolErrorCode::InvalidParam, e.to_string())),
417 };
418
419 let resolved = resolve_path(&session.cwd, ¶ms.path).await;
420 if let Some(e) = fence_write(&session.permissions, &resolved) {
421 return err_m(e);
422 }
423
424 execute_multi_edit(session, &resolved, ¶ms).await
425}
426
427async fn execute_multi_edit(
428 session: &WriteSessionConfig,
429 resolved: &Path,
430 params: &MultiEditParams,
431) -> MultiEditResult {
432 let pre = match preflight_mutation(session, resolved).await {
433 Ok(p) => p,
434 Err(e) => return err_m(e),
435 };
436
437 let edits: Vec<EditSpec> = params
438 .edits
439 .iter()
440 .map(|e| EditSpec {
441 old_string: e.old_string.clone(),
442 new_string: e.new_string.clone(),
443 replace_all: e.replace_all,
444 ignore_whitespace: e.ignore_whitespace,
445 })
446 .collect();
447
448 let pipeline = apply_pipeline(&pre.existing_content, &edits);
449 let (new_content, total_replacements, warnings) = match pipeline {
450 PipelineResult::Ok {
451 content,
452 total_replacements,
453 warnings,
454 } => (content, total_replacements, warnings),
455 PipelineResult::Err { error, .. } => return err_m(error),
456 };
457
458 let new_bytes = new_content.as_bytes();
459
460 if params.dry_run.unwrap_or(false) {
461 let diff = unified_diff(UnifiedDiffArgs {
462 old_path: &resolved.to_string_lossy(),
463 new_path: &resolved.to_string_lossy(),
464 old_content: &pre.existing_content,
465 new_content: &new_content,
466 });
467 return MultiEditResult::Preview(PreviewResult {
468 output: format_preview(FormatPreviewArgs {
469 path: &resolved.to_string_lossy(),
470 diff: &diff,
471 would_write_bytes: new_bytes.len() as u64,
472 bytes_before: pre.existing_bytes.len() as u64,
473 }),
474 diff,
475 meta: PreviewMeta {
476 path: resolved.to_string_lossy().into_owned(),
477 would_write_bytes: new_bytes.len() as u64,
478 bytes_delta: new_bytes.len() as i64 - pre.existing_bytes.len() as i64,
479 previous_sha256: pre.previous_sha,
480 },
481 });
482 }
483
484 if let Err(e) = atomic_write(resolved, new_bytes).await {
485 return err_m(ToolError::new(
486 ToolErrorCode::IoError,
487 format!("write failed: {}", e),
488 ));
489 }
490
491 let new_sha = sha256_hex(new_bytes);
492 let mtime = tokio::fs::metadata(resolved)
493 .await
494 .ok()
495 .and_then(|m| m.modified().ok())
496 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
497 .map(|d| d.as_millis() as u64)
498 .unwrap_or_else(now_ms);
499
500 session.ledger.record(LedgerEntry {
501 path: resolved.to_string_lossy().into_owned(),
502 sha256: new_sha.clone(),
503 mtime_ms: mtime,
504 size_bytes: new_bytes.len() as u64,
505 timestamp_ms: now_ms(),
506 });
507
508 MultiEditResult::Text(TextWriteResult {
509 output: format_multi_edit_success(FormatMultiEditArgs {
510 path: &resolved.to_string_lossy(),
511 edits_applied: edits.len(),
512 total_replacements,
513 bytes_before: pre.existing_bytes.len() as u64,
514 bytes_after: new_bytes.len() as u64,
515 warnings: &warnings,
516 }),
517 meta: AnyMeta::MultiEdit(MultiEditMeta {
518 path: resolved.to_string_lossy().into_owned(),
519 edits_applied: edits.len(),
520 total_replacements,
521 bytes_delta: new_bytes.len() as i64 - pre.existing_bytes.len() as i64,
522 sha256: new_sha,
523 mtime_ms: mtime,
524 previous_sha256: pre.previous_sha,
525 warnings: if warnings.is_empty() { None } else { Some(warnings) },
526 }),
527 })
528}
529
530async fn resolve_path(cwd: &str, input: &str) -> PathBuf {
533 let abs: PathBuf = if Path::new(input).is_absolute() {
534 PathBuf::from(input)
535 } else {
536 Path::new(cwd).join(input)
537 };
538 tokio::fs::canonicalize(&abs).await.unwrap_or(abs)
539}
540
541async fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
542 let parent = path.parent().unwrap_or_else(|| Path::new("."));
543 let tmp_name = format!(".{}.tmp-{}", uuid::Uuid::new_v4(), std::process::id());
544 let tmp_path = parent.join(tmp_name);
545 tokio::fs::write(&tmp_path, bytes).await?;
546 tokio::fs::rename(&tmp_path, path).await?;
547 Ok(())
548}