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