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 };
328
329 let result = match apply_edit(&pre.existing_content, &edit_spec) {
330 Ok(r) => r,
331 Err(e) => return err_e(e),
332 };
333
334 let new_content = result.content;
335 let new_bytes = new_content.as_bytes();
336
337 if params.dry_run.unwrap_or(false) {
338 let diff = unified_diff(UnifiedDiffArgs {
339 old_path: &resolved.to_string_lossy(),
340 new_path: &resolved.to_string_lossy(),
341 old_content: &pre.existing_content,
342 new_content: &new_content,
343 });
344 return EditResult::Preview(PreviewResult {
345 output: format_preview(FormatPreviewArgs {
346 path: &resolved.to_string_lossy(),
347 diff: &diff,
348 would_write_bytes: new_bytes.len() as u64,
349 bytes_before: pre.existing_bytes.len() as u64,
350 }),
351 diff,
352 meta: PreviewMeta {
353 path: resolved.to_string_lossy().into_owned(),
354 would_write_bytes: new_bytes.len() as u64,
355 bytes_delta: new_bytes.len() as i64 - pre.existing_bytes.len() as i64,
356 previous_sha256: pre.previous_sha,
357 },
358 });
359 }
360
361 if let Err(e) = atomic_write(resolved, new_bytes).await {
362 return err_e(ToolError::new(
363 ToolErrorCode::IoError,
364 format!("write failed: {}", e),
365 ));
366 }
367
368 let new_sha = sha256_hex(new_bytes);
369 let mtime = tokio::fs::metadata(resolved)
370 .await
371 .ok()
372 .and_then(|m| m.modified().ok())
373 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
374 .map(|d| d.as_millis() as u64)
375 .unwrap_or_else(now_ms);
376
377 session.ledger.record(LedgerEntry {
378 path: resolved.to_string_lossy().into_owned(),
379 sha256: new_sha.clone(),
380 mtime_ms: mtime,
381 size_bytes: new_bytes.len() as u64,
382 timestamp_ms: now_ms(),
383 });
384
385 EditResult::Text(TextWriteResult {
386 output: format_edit_success(FormatEditArgs {
387 path: &resolved.to_string_lossy(),
388 replacements: result.replacements,
389 replace_all: params.replace_all.unwrap_or(false),
390 bytes_before: pre.existing_bytes.len() as u64,
391 bytes_after: new_bytes.len() as u64,
392 warnings: &result.warnings,
393 }),
394 meta: AnyMeta::Edit(EditMeta {
395 path: resolved.to_string_lossy().into_owned(),
396 replacements: result.replacements,
397 bytes_delta: new_bytes.len() as i64 - pre.existing_bytes.len() as i64,
398 sha256: new_sha,
399 mtime_ms: mtime,
400 previous_sha256: pre.previous_sha,
401 warnings: if result.warnings.is_empty() {
402 None
403 } else {
404 Some(result.warnings)
405 },
406 }),
407 })
408}
409
410pub async fn multi_edit(input: Value, session: &WriteSessionConfig) -> MultiEditResult {
413 let params = match safe_parse_multi_edit_params(&input) {
414 Ok(p) => p,
415 Err(e) => return err_m(ToolError::new(ToolErrorCode::InvalidParam, e.to_string())),
416 };
417
418 let resolved = resolve_path(&session.cwd, ¶ms.path).await;
419 if let Some(e) = fence_write(&session.permissions, &resolved) {
420 return err_m(e);
421 }
422
423 execute_multi_edit(session, &resolved, ¶ms).await
424}
425
426async fn execute_multi_edit(
427 session: &WriteSessionConfig,
428 resolved: &Path,
429 params: &MultiEditParams,
430) -> MultiEditResult {
431 let pre = match preflight_mutation(session, resolved).await {
432 Ok(p) => p,
433 Err(e) => return err_m(e),
434 };
435
436 let edits: Vec<EditSpec> = params
437 .edits
438 .iter()
439 .map(|e| EditSpec {
440 old_string: e.old_string.clone(),
441 new_string: e.new_string.clone(),
442 replace_all: e.replace_all,
443 })
444 .collect();
445
446 let pipeline = apply_pipeline(&pre.existing_content, &edits);
447 let (new_content, total_replacements, warnings) = match pipeline {
448 PipelineResult::Ok {
449 content,
450 total_replacements,
451 warnings,
452 } => (content, total_replacements, warnings),
453 PipelineResult::Err { error, .. } => return err_m(error),
454 };
455
456 let new_bytes = new_content.as_bytes();
457
458 if params.dry_run.unwrap_or(false) {
459 let diff = unified_diff(UnifiedDiffArgs {
460 old_path: &resolved.to_string_lossy(),
461 new_path: &resolved.to_string_lossy(),
462 old_content: &pre.existing_content,
463 new_content: &new_content,
464 });
465 return MultiEditResult::Preview(PreviewResult {
466 output: format_preview(FormatPreviewArgs {
467 path: &resolved.to_string_lossy(),
468 diff: &diff,
469 would_write_bytes: new_bytes.len() as u64,
470 bytes_before: pre.existing_bytes.len() as u64,
471 }),
472 diff,
473 meta: PreviewMeta {
474 path: resolved.to_string_lossy().into_owned(),
475 would_write_bytes: new_bytes.len() as u64,
476 bytes_delta: new_bytes.len() as i64 - pre.existing_bytes.len() as i64,
477 previous_sha256: pre.previous_sha,
478 },
479 });
480 }
481
482 if let Err(e) = atomic_write(resolved, new_bytes).await {
483 return err_m(ToolError::new(
484 ToolErrorCode::IoError,
485 format!("write failed: {}", e),
486 ));
487 }
488
489 let new_sha = sha256_hex(new_bytes);
490 let mtime = tokio::fs::metadata(resolved)
491 .await
492 .ok()
493 .and_then(|m| m.modified().ok())
494 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
495 .map(|d| d.as_millis() as u64)
496 .unwrap_or_else(now_ms);
497
498 session.ledger.record(LedgerEntry {
499 path: resolved.to_string_lossy().into_owned(),
500 sha256: new_sha.clone(),
501 mtime_ms: mtime,
502 size_bytes: new_bytes.len() as u64,
503 timestamp_ms: now_ms(),
504 });
505
506 MultiEditResult::Text(TextWriteResult {
507 output: format_multi_edit_success(FormatMultiEditArgs {
508 path: &resolved.to_string_lossy(),
509 edits_applied: edits.len(),
510 total_replacements,
511 bytes_before: pre.existing_bytes.len() as u64,
512 bytes_after: new_bytes.len() as u64,
513 warnings: &warnings,
514 }),
515 meta: AnyMeta::MultiEdit(MultiEditMeta {
516 path: resolved.to_string_lossy().into_owned(),
517 edits_applied: edits.len(),
518 total_replacements,
519 bytes_delta: new_bytes.len() as i64 - pre.existing_bytes.len() as i64,
520 sha256: new_sha,
521 mtime_ms: mtime,
522 previous_sha256: pre.previous_sha,
523 warnings: if warnings.is_empty() { None } else { Some(warnings) },
524 }),
525 })
526}
527
528async fn resolve_path(cwd: &str, input: &str) -> PathBuf {
531 let abs: PathBuf = if Path::new(input).is_absolute() {
532 PathBuf::from(input)
533 } else {
534 Path::new(cwd).join(input)
535 };
536 tokio::fs::canonicalize(&abs).await.unwrap_or(abs)
537}
538
539async fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
540 let parent = path.parent().unwrap_or_else(|| Path::new("."));
541 let tmp_name = format!(".{}.tmp-{}", uuid::Uuid::new_v4(), std::process::id());
542 let tmp_path = parent.join(tmp_name);
543 tokio::fs::write(&tmp_path, bytes).await?;
544 tokio::fs::rename(&tmp_path, path).await?;
545 Ok(())
546}