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        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
411// ---- multiedit ----
412
413pub 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, &params.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, &params).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
530// ---- helpers ----
531
532async 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}