Skip to main content

harness_write/
run.rs

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
42// ---- write ----
43
44pub 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, &params.path).await;
51    if let Some(e) = fence_write(&session.permissions, &resolved) {
52        return err_w(e);
53    }
54
55    execute_write(session, &resolved, &params).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
185// ---- edit ----
186
187struct 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, &params.path).await;
306    if let Some(e) = fence_write(&session.permissions, &resolved) {
307        return err_e(e);
308    }
309
310    execute_edit(session, &resolved, &params).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
410// ---- multiedit ----
411
412pub 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, &params.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, &params).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
528// ---- helpers ----
529
530async 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}