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}