Skip to main content

atman_runtime/tools/
hunk.rs

1use std::path::PathBuf;
2
3use crate::error::RuntimeError;
4use crate::hunk::EditProposal;
5use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8pub struct FsEdit;
9
10impl Tool for FsEdit {
11    fn name(&self) -> &str {
12        "hunk.plan_edit"
13    }
14
15    fn tier(&self) -> Tier {
16        Tier::Zero
17    }
18
19    fn description(&self) -> Option<&str> {
20        Some(
21            "Compute a hunk-level EditProposal for replacing a file with new content. \
22             Nothing is written; feed the proposal into hunk.review or hunk.apply. \
23             For straightforward str_replace edits, prefer fs.edit instead.",
24        )
25    }
26
27    fn input_schema(&self) -> serde_json::Value {
28        serde_json::json!({
29            "type": "object",
30            "properties": {
31                "path": {"type": "string", "description": "File to edit."},
32                "new_content": {"type": "string", "description": "Proposed replacement content."}
33            },
34            "required": ["path", "new_content"]
35        })
36    }
37
38    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
39        Box::pin(async move {
40            let path = ctx.resolve_path(&extract_path(&args, "path", 0)?)?;
41            let new_content = extract_string(&args, "new_content", 1)?;
42            let original = tokio::fs::read_to_string(&path).await.map_err(|e| {
43                RuntimeError::ToolFailed(format!("fs.edit({}): {e}", path.display()))
44            })?;
45            let proposal = EditProposal::compute(path, original, new_content);
46            Ok(Value::EditProposal(Box::new(proposal)))
47        })
48    }
49}
50
51pub struct HunkReview;
52
53impl Tool for HunkReview {
54    fn name(&self) -> &str {
55        "hunk.review"
56    }
57
58    fn tier(&self) -> Tier {
59        Tier::One
60    }
61
62    fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> crate::tool::ApprovalLevel {
63        crate::tool::ApprovalLevel::Auto
64    }
65
66    fn description(&self) -> Option<&str> {
67        Some(
68            "Present an EditProposal to a human reviewer (or auto-approve if no resolver is \
69             configured). Returns a struct with mode = auto|resolved and a hunks id list \
70             the caller should pass to hunk.apply.",
71        )
72    }
73
74    fn input_schema(&self) -> serde_json::Value {
75        serde_json::json!({
76            "type": "object",
77            "properties": {
78                "proposal": {"description": "EditProposal value from hunk.plan_edit."},
79                "timeout_secs": {"type": "integer", "description": "Seconds to wait for a reviewer answer (default 300)."}
80            },
81            "required": ["proposal"]
82        })
83    }
84
85    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
86        Box::pin(async move {
87            let proposal = extract_proposal(&args)?;
88            let timeout_secs = match args.named("timeout_secs") {
89                Some(Value::Int(n)) if *n > 0 => *n as u64,
90                _ => 300,
91            };
92            let default_selection: Vec<u32> = proposal.hunks.iter().map(|h| h.id).collect();
93            let Some(resolver) = ctx.prompt_resolver.clone() else {
94                return Ok(Value::Struct(vec![
95                    ("mode".into(), Value::Str("auto".into())),
96                    (
97                        "hunks".into(),
98                        Value::List(
99                            default_selection
100                                .into_iter()
101                                .map(|id| Value::Int(id as i64))
102                                .collect(),
103                        ),
104                    ),
105                ]));
106            };
107            let id = crate::rendezvous::PromptId::now();
108            let payload = hunk_review_payload(&proposal);
109            let answer = crate::rendezvous::await_prompt_with_payload_cancel(
110                &resolver,
111                id,
112                "hunk_selection",
113                payload,
114                std::time::Duration::from_secs(timeout_secs),
115                &ctx.cancel,
116            )
117            .await?;
118            let selection = parse_answer_hunk_ids(&answer, &default_selection)?;
119            Ok(Value::Struct(vec![
120                ("mode".into(), Value::Str("resolved".into())),
121                ("prompt_id".into(), Value::Str(id.to_string())),
122                (
123                    "hunks".into(),
124                    Value::List(
125                        selection
126                            .into_iter()
127                            .map(|id| Value::Int(id as i64))
128                            .collect(),
129                    ),
130                ),
131            ]))
132        })
133    }
134}
135
136fn hunk_review_payload(proposal: &EditProposal) -> serde_json::Value {
137    let hunks: Vec<serde_json::Value> = proposal
138        .hunks
139        .iter()
140        .map(|h| {
141            let mut diff = String::new();
142            for line in &h.lines {
143                match line {
144                    crate::hunk::HunkLine::Add { text } => {
145                        diff.push('+');
146                        diff.push_str(text);
147                        if !text.ends_with('\n') {
148                            diff.push('\n');
149                        }
150                    }
151                    crate::hunk::HunkLine::Delete { text } => {
152                        diff.push('-');
153                        diff.push_str(text);
154                        if !text.ends_with('\n') {
155                            diff.push('\n');
156                        }
157                    }
158                    crate::hunk::HunkLine::Context { text } => {
159                        diff.push(' ');
160                        diff.push_str(text);
161                        if !text.ends_with('\n') {
162                            diff.push('\n');
163                        }
164                    }
165                }
166            }
167            serde_json::json!({
168                "id": h.id,
169                "old_start": h.old_start,
170                "old_len": h.old_len,
171                "new_start": h.new_start,
172                "new_len": h.new_len,
173                "unified_diff": diff,
174            })
175        })
176        .collect();
177    serde_json::json!({
178        "path": proposal.path.display().to_string(),
179        "hunks": hunks,
180        "options": ["all", "none", "select"],
181    })
182}
183
184fn parse_answer_hunk_ids(
185    answer: &serde_json::Value,
186    default: &[u32],
187) -> Result<Vec<u32>, RuntimeError> {
188    if answer.is_null() {
189        return Ok(default.to_vec());
190    }
191    if let Some(s) = answer.as_str() {
192        match s {
193            "all" => return Ok(default.to_vec()),
194            "none" => return Ok(Vec::new()),
195            other => {
196                return Err(RuntimeError::ToolFailed(format!(
197                    "hunk.review answer: unknown string `{other}`"
198                )));
199            }
200        }
201    }
202    if let Some(hunks) = answer.get("hunks").and_then(|v| v.as_array()) {
203        let mut ids = Vec::with_capacity(hunks.len());
204        for h in hunks {
205            let n = h.as_u64().ok_or_else(|| {
206                RuntimeError::ToolFailed(format!("hunk.review answer: hunk id not u64: {h:?}"))
207            })?;
208            ids.push(n as u32);
209        }
210        return Ok(ids);
211    }
212    Err(RuntimeError::ToolFailed(format!(
213        "hunk.review answer: unrecognized shape: {answer:?}"
214    )))
215}
216
217pub struct HunkApply;
218
219impl Tool for HunkApply {
220    fn name(&self) -> &str {
221        "hunk.apply"
222    }
223
224    fn tier(&self) -> Tier {
225        Tier::Two
226    }
227
228    fn description(&self) -> Option<&str> {
229        Some(
230            "Apply selected hunks from an EditProposal to disk. `hunks` is a list of hunk ids \
231             (usually from a hunk.review result). Returns which ids were applied vs skipped.",
232        )
233    }
234
235    fn input_schema(&self) -> serde_json::Value {
236        serde_json::json!({
237            "type": "object",
238            "properties": {
239                "proposal": {"description": "EditProposal value from hunk.plan_edit."},
240                "hunks": {
241                    "type": "array",
242                    "items": {"type": "integer"},
243                    "description": "Hunk ids to apply. Omit or pass \"all\" to apply everything."
244                }
245            },
246            "required": ["proposal"]
247        })
248    }
249
250    fn invocation_provenance(
251        &self,
252        args: &ToolArgs,
253        ctx: &ToolCtx,
254    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
255        let proposal = extract_proposal(args)?;
256        Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
257            .with_path(ctx, &proposal.path)?
258            .with_risk(crate::trust::RiskKind::FilesystemWrite))
259    }
260
261    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
262        Box::pin(async move {
263            let proposal = extract_proposal(&args)?;
264            crate::fs_access::authorize_write(ctx, &proposal.path, self.name(), true).await?;
265            let selection = resolve_selection(&args, &proposal)?;
266            let applied = proposal
267                .apply_selected(&selection)
268                .map_err(|e| RuntimeError::ToolFailed(format!("hunk.apply: {e}")))?;
269            tokio::fs::write(&proposal.path, applied.as_bytes())
270                .await
271                .map_err(|e| {
272                    RuntimeError::ToolFailed(format!(
273                        "hunk.apply write {}: {e}",
274                        proposal.path.display()
275                    ))
276                })?;
277            crate::activity::emit_file_edit_applied(
278                ctx,
279                self.name(),
280                &proposal.path,
281                &proposal.original,
282                &applied,
283            );
284            let all_ids: Vec<u32> = proposal.hunks.iter().map(|h| h.id).collect();
285            let skipped: Vec<Value> = all_ids
286                .iter()
287                .filter(|id| !selection.contains(id))
288                .map(|id| Value::Int(*id as i64))
289                .collect();
290            let applied_ids: Vec<Value> =
291                selection.iter().map(|id| Value::Int(*id as i64)).collect();
292            Ok(Value::Struct(vec![
293                ("status".into(), Value::Str("applied".into())),
294                ("path".into(), Value::Path(proposal.path.clone())),
295                ("applied_hunks".into(), Value::List(applied_ids)),
296                ("skipped_hunks".into(), Value::List(skipped)),
297                ("total_hunks".into(), Value::Int(all_ids.len() as i64)),
298            ]))
299        })
300    }
301}
302
303fn resolve_selection(args: &ToolArgs, proposal: &EditProposal) -> Result<Vec<u32>, RuntimeError> {
304    let value = args
305        .named("hunks")
306        .cloned()
307        .or_else(|| args.positional(1).ok().cloned())
308        .unwrap_or(Value::Str("all".into()));
309    match value {
310        Value::Str(s) => match s.as_str() {
311            "all" => Ok(proposal.hunks.iter().map(|h| h.id).collect()),
312            "none" => Ok(Vec::new()),
313            other => Err(RuntimeError::ToolFailed(format!(
314                "hunk.apply: unknown selection string `{other}` (want `all` | `none` | [1,3,...])"
315            ))),
316        },
317        Value::List(items) => {
318            let mut out = Vec::with_capacity(items.len());
319            for item in items {
320                match item {
321                    Value::Int(n) if n > 0 => out.push(n as u32),
322                    other => {
323                        return Err(RuntimeError::TypeMismatch {
324                            expected: "positive int (hunk id)".into(),
325                            actual: other.kind_name().into(),
326                        });
327                    }
328                }
329            }
330            Ok(out)
331        }
332        other => Err(RuntimeError::TypeMismatch {
333            expected: "`all` | `none` | list of int (hunk ids)".into(),
334            actual: other.kind_name().into(),
335        }),
336    }
337}
338
339fn extract_proposal(args: &ToolArgs) -> Result<EditProposal, RuntimeError> {
340    let value = match args.named("proposal") {
341        Some(v) => v,
342        None => args.positional(0)?,
343    };
344    match value {
345        Value::EditProposal(p) => Ok((**p).clone()),
346        other => Err(RuntimeError::TypeMismatch {
347            expected: "edit_proposal".into(),
348            actual: other.kind_name().into(),
349        }),
350    }
351}
352
353fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
354    let value = match args.named(name) {
355        Some(v) => v,
356        None => args.positional(pos)?,
357    };
358    match value {
359        Value::Str(s) => Ok(s.clone()),
360        other => Err(RuntimeError::TypeMismatch {
361            expected: "string".into(),
362            actual: other.kind_name().into(),
363        }),
364    }
365}
366
367fn extract_path(args: &ToolArgs, name: &str, pos: usize) -> Result<PathBuf, RuntimeError> {
368    let value = match args.named(name) {
369        Some(v) => v,
370        None => args.positional(pos)?,
371    };
372    match value {
373        Value::Path(p) => Ok(p.clone()),
374        Value::Str(s) => Ok(PathBuf::from(s)),
375        other => Err(RuntimeError::TypeMismatch {
376            expected: "path".into(),
377            actual: other.kind_name().into(),
378        }),
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[tokio::test]
387    async fn fs_edit_returns_edit_proposal_with_hunks() {
388        let dir = tempfile::tempdir().unwrap();
389        let path = dir.path().join("f.txt");
390        std::fs::write(&path, "a\nb\nc\n").unwrap();
391        let ctx = ToolCtx::new();
392        let args = ToolArgs {
393            positional: vec![Value::Path(path.clone()), Value::Str("a\nB\nc\n".into())],
394            named: vec![],
395        };
396        let v = FsEdit.call(args, &ctx).await.unwrap();
397        let Value::EditProposal(p) = v else {
398            panic!("expected EditProposal");
399        };
400        assert_eq!(p.hunks.len(), 1);
401        assert_eq!(p.original, "a\nb\nc\n");
402        assert_eq!(p.proposed, "a\nB\nc\n");
403    }
404
405    #[tokio::test]
406    async fn hunk_apply_all_writes_full_proposed() {
407        let dir = tempfile::tempdir().unwrap();
408        let path = dir.path().join("f.txt");
409        std::fs::write(&path, "a\nb\nc\n").unwrap();
410        let ctx = ToolCtx::new();
411        let proposal = FsEdit
412            .call(
413                ToolArgs {
414                    positional: vec![Value::Path(path.clone()), Value::Str("a\nB\nc\n".into())],
415                    named: vec![],
416                },
417                &ctx,
418            )
419            .await
420            .unwrap();
421        let apply_args = ToolArgs {
422            positional: vec![proposal, Value::Str("all".into())],
423            named: vec![],
424        };
425        let out = HunkApply.call(apply_args, &ctx).await.unwrap();
426        let Value::Struct(fields) = out else {
427            panic!("expected struct");
428        };
429        assert!(matches!(
430            fields.iter().find(|(k, _)| k == "status").unwrap().1,
431            Value::Str(ref s) if s == "applied"
432        ));
433        let on_disk = std::fs::read_to_string(&path).unwrap();
434        assert_eq!(on_disk, "a\nB\nc\n");
435    }
436
437    #[tokio::test]
438    async fn hunk_apply_none_leaves_file_untouched() {
439        let dir = tempfile::tempdir().unwrap();
440        let path = dir.path().join("f.txt");
441        std::fs::write(&path, "a\nb\nc\n").unwrap();
442        let ctx = ToolCtx::new();
443        let proposal = FsEdit
444            .call(
445                ToolArgs {
446                    positional: vec![Value::Path(path.clone()), Value::Str("a\nB\nc\n".into())],
447                    named: vec![],
448                },
449                &ctx,
450            )
451            .await
452            .unwrap();
453        let apply_args = ToolArgs {
454            positional: vec![proposal],
455            named: vec![("hunks".into(), Value::Str("none".into()))],
456        };
457        HunkApply.call(apply_args, &ctx).await.unwrap();
458        let on_disk = std::fs::read_to_string(&path).unwrap();
459        assert_eq!(on_disk, "a\nb\nc\n");
460    }
461
462    #[tokio::test]
463    async fn hunk_apply_with_id_list_writes_only_selected() {
464        let dir = tempfile::tempdir().unwrap();
465        let path = dir.path().join("f.txt");
466        let original: String = (0..20).map(|i| format!("l{i}\n")).collect();
467        std::fs::write(&path, &original).unwrap();
468        let mut proposed = original.clone();
469        proposed = proposed.replace("l3\n", "L3\n");
470        proposed = proposed.replace("l15\n", "L15\n");
471        let ctx = ToolCtx::new();
472        let proposal_v = FsEdit
473            .call(
474                ToolArgs {
475                    positional: vec![Value::Path(path.clone()), Value::Str(proposed.clone())],
476                    named: vec![],
477                },
478                &ctx,
479            )
480            .await
481            .unwrap();
482        let apply_args = ToolArgs {
483            positional: vec![proposal_v],
484            named: vec![("hunks".into(), Value::List(vec![Value::Int(1)]))],
485        };
486        let out = HunkApply.call(apply_args, &ctx).await.unwrap();
487        let Value::Struct(fields) = out else {
488            panic!("expected struct");
489        };
490        let f = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
491        assert!(matches!(f("total_hunks"), Some(Value::Int(2))));
492        let on_disk = std::fs::read_to_string(&path).unwrap();
493        assert!(on_disk.contains("L3\n"));
494        assert!(!on_disk.contains("L15\n"));
495        assert!(on_disk.contains("l15\n"));
496    }
497
498    #[tokio::test]
499    async fn hunk_apply_rejects_external_path_without_changing_file() {
500        let workspace = tempfile::tempdir().unwrap();
501        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
502            .join("target")
503            .join(format!("r4-hunk-{}.txt", uuid::Uuid::now_v7()));
504        std::fs::create_dir_all(fixture.parent().unwrap()).unwrap();
505        std::fs::write(&fixture, "original\n").unwrap();
506        let proposal = Value::EditProposal(Box::new(EditProposal::compute(
507            fixture.clone(),
508            "original\n".into(),
509            "changed\n".into(),
510        )));
511        let ctx = ToolCtx::new()
512            .with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
513                workspace.path().into(),
514            ))
515            .with_workspace(crate::git_workspace::WorkspaceBinding {
516                workspace_id: "test".into(),
517                repository_root: workspace.path().into(),
518                path: workspace.path().into(),
519                branch: None,
520            });
521        let error = HunkApply
522            .call(
523                ToolArgs {
524                    positional: vec![proposal],
525                    named: vec![],
526                },
527                &ctx,
528            )
529            .await
530            .unwrap_err();
531        assert!(error.to_string().contains("outside workspace"));
532        assert_eq!(std::fs::read_to_string(&fixture).unwrap(), "original\n");
533        std::fs::remove_file(fixture).unwrap();
534    }
535
536    #[tokio::test]
537    async fn hunk_apply_rejects_unknown_selection_string() {
538        let dir = tempfile::tempdir().unwrap();
539        let path = dir.path().join("f.txt");
540        std::fs::write(&path, "a\n").unwrap();
541        let ctx = ToolCtx::new();
542        let proposal = FsEdit
543            .call(
544                ToolArgs {
545                    positional: vec![Value::Path(path), Value::Str("b\n".into())],
546                    named: vec![],
547                },
548                &ctx,
549            )
550            .await
551            .unwrap();
552        let args = ToolArgs {
553            positional: vec![proposal, Value::Str("some".into())],
554            named: vec![],
555        };
556        let err = HunkApply.call(args, &ctx).await.unwrap_err();
557        assert!(format!("{err}").contains("unknown selection"));
558    }
559}