1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use super::Request;
12use crate::tools::policy::Object;
13
14pub const HEAD: usize = 20;
16
17const MAX_BYTES: usize = 1024 * 1024;
19
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct PresentContext {
23 pub root: Option<PathBuf>,
25 pub sandbox: Option<String>,
27 pub network: String,
29 pub file_text: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Choice {
36 pub choice: String,
38 pub key: char,
40 pub number: u8,
42 pub label: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Presentation {
49 pub request: Request,
51 pub title: String,
54 pub subject: String,
56 pub object: Vec<String>,
61 pub sources: Vec<String>,
64 pub notice: Option<String>,
66 pub body: Vec<String>,
68 pub full: Vec<String>,
70 pub sandbox: String,
72 pub options: Vec<Choice>,
74 pub scope: String,
76 pub old_text: Option<String>,
78 pub new_text: Option<String>,
80 pub file: Option<String>,
82}
83
84#[must_use]
87pub fn escape(text: &str) -> String {
88 text.chars()
89 .map(|c| match c {
90 '\x1b' => "␛".to_owned(),
91 '\n' | '\t' => c.to_string(),
92 c if (c as u32) < 0x20 => format!("^{}", char::from(b'@' + c as u8)),
93 '\x7f' => "^?".to_owned(),
94 c => c.to_string(),
95 })
96 .collect()
97}
98
99fn plain(a: &str) -> bool {
101 !a.is_empty()
102 && a.chars()
103 .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%".contains(c))
104}
105
106#[must_use]
108pub fn shell_quote(argv: &[String]) -> String {
109 argv.iter()
110 .map(|a| {
111 if plain(a) {
112 a.clone()
113 } else {
114 format!("'{}'", a.replace('\'', "'\\''"))
115 }
116 })
117 .collect::<Vec<_>>()
118 .join(" ")
119}
120
121#[must_use]
124pub fn scope_text(kind: &str) -> &'static str {
125 match kind {
126 "run" => "exactly this command in this directory",
127 "network" => "exactly this command in this directory, with the network",
128 "mcp" => "this tool as defined now, any arguments",
129 _ => "this file, any content",
130 }
131}
132
133fn first12(hex: &str) -> &str {
134 hex.get(..12).unwrap_or(hex)
135}
136
137#[must_use]
139pub fn object_lines(object: Option<&Object>, changed: Option<&str>) -> Vec<String> {
140 let mut out = Vec::new();
141 match object {
142 Some(Object::Path { canonical, asked }) => {
143 out.push(format!("file {}", escape(canonical)));
144 if asked != canonical {
145 out.push(format!(
146 "asked as {} (resolves to the file above)",
147 escape(asked)
148 ));
149 }
150 }
151 Some(Object::Command {
152 argv,
153 argv_sha256,
154 cwd,
155 executable,
156 network,
157 }) => {
158 let json = serde_json::to_string(argv).unwrap_or_default();
159 out.push(format!(
160 "argv {} #{}",
161 escape(&json),
162 first12(argv_sha256)
163 ));
164 out.push(format!("runs {} in {}", escape(executable), escape(cwd)));
165 if *network {
166 out.push("network yes".to_owned());
167 }
168 }
169 Some(Object::Tool { name, digest }) => {
170 out.push(format!("tool {} @{}", escape(name), first12(digest)));
171 }
172 None => {}
173 }
174 if let Some(c) = changed {
175 out.push(format!("changed since approved: {}", escape(c)));
176 }
177 out
178}
179
180#[must_use]
182pub fn source_lines(request: &Request) -> Vec<String> {
183 let mut out: Vec<String> = request
184 .sources
185 .iter()
186 .map(|s| {
187 let when = if s.turn == 0 {
188 "the attempt's prompt".to_owned()
189 } else {
190 format!("turn {}", s.turn)
191 };
192 format!(
193 "contains text read from {} ({when}): \"{}\"",
194 escape(&s.from),
195 escape(&s.argument)
196 )
197 })
198 .collect();
199 if request.more > 0 {
200 out.push(format!("and {} more", request.more));
201 }
202 out
203}
204
205#[must_use]
207pub fn budget_notice(request: &Request) -> Option<String> {
208 (request.budget > 0 && request.asked == request.budget && !request.batched).then(|| {
209 format!(
210 "This is prompt {0} of {0} for this run; further calls will wait in Needs You.",
211 request.budget
212 )
213 })
214}
215
216fn head(lines: &[String]) -> (Vec<String>, usize) {
217 let shown = lines.iter().take(HEAD).cloned().collect();
218 (shown, lines.len().saturating_sub(HEAD))
219}
220
221fn numbered(text: &str) -> Vec<String> {
223 text.lines()
224 .enumerate()
225 .map(|(i, l)| format!("{:>5} │ {}", i + 1, escape(l)))
226 .collect()
227}
228
229#[must_use]
231pub fn diff(old: &str, new: &str) -> Vec<String> {
232 let d = similar::TextDiff::from_lines(old, new);
233 let mut out = Vec::new();
234 for (i, group) in d.grouped_ops(3).iter().enumerate() {
235 if i > 0 {
236 out.push(" ┈".to_owned());
237 }
238 for op in group {
239 for change in d.iter_changes(op) {
240 let (sign, old_no, new_no) = match change.tag() {
241 similar::ChangeTag::Equal => (' ', change.old_index(), change.new_index()),
242 similar::ChangeTag::Delete => ('-', change.old_index(), None),
243 similar::ChangeTag::Insert => ('+', None, change.new_index()),
244 };
245 let no = |n: Option<usize>| n.map_or(String::new(), |n| (n + 1).to_string());
246 let line = format!(
247 "{:>5} {:>5} {sign} {}",
248 no(old_no),
249 no(new_no),
250 escape(change.value().trim_end_matches(['\n', '\r']))
251 );
252 out.push(line.trim_end().to_owned());
253 }
254 }
255 }
256 out
257}
258
259fn read(context: &PresentContext, path: &str) -> Result<Option<String>, String> {
261 if let Some(text) = &context.file_text {
262 return Ok(Some(text.clone()));
263 }
264 let Some(root) = context.root.as_deref() else {
265 return Ok(None);
266 };
267 let file = root.join(path);
268 let Ok(bytes) = std::fs::read(&file) else {
269 return Ok(None);
270 };
271 if bytes.len() > MAX_BYTES {
272 return Err(format!("{} bytes — too large to show", bytes.len()));
273 }
274 String::from_utf8(bytes)
275 .map(Some)
276 .map_err(|_| "a binary file — not shown".to_owned())
277}
278
279#[must_use]
281pub fn present(request: &Request, context: &PresentContext) -> Presentation {
282 let args = &request.arguments;
283 let s = |k: &str| args[k].as_str().unwrap_or_default().to_owned();
284 let path = s("path");
285 let kind = request.kind.as_str();
286 let mut old_text = None;
287 let mut new_text = None;
288 let (title, subject, full) = match kind {
289 "run" | "network" => {
290 let argv: Vec<String> = args["argv"]
291 .as_array()
292 .into_iter()
293 .flatten()
294 .filter_map(|v| v.as_str().map(str::to_owned))
295 .collect();
296 let cmd = escape(&shell_quote(&argv));
297 let cwd = args["cwd"]
298 .as_str()
299 .filter(|c| !c.is_empty())
300 .unwrap_or(".");
301 let net = if kind == "network" {
302 "this call".to_owned()
303 } else {
304 context.network.clone()
305 };
306 (
307 if kind == "network" {
308 "Use the network?"
309 } else {
310 "Run this command?"
311 },
312 cmd.clone(),
313 vec![format!("$ {cmd}"), format!("cwd: {cwd} · network: {net}")],
314 )
315 }
316 "mcp" => {
317 let pretty = serde_json::to_string_pretty(args).unwrap_or_default();
318 (
319 "Call MCP tool?",
320 request.tool.clone(),
321 pretty.lines().map(escape).collect(),
322 )
323 }
324 _ => {
325 let current = read(context, &path);
326 let title = if kind == "write" && matches!(current, Ok(None)) {
328 "Write this file?"
329 } else {
330 "Edit this file?"
331 };
332 let lines = match current {
333 Err(note) => vec![note],
334 Ok(current) => match (request.tool.as_str(), current) {
335 ("write_file", None) => {
336 let content = s("content");
337 new_text = Some(content.clone());
338 numbered(&content)
339 }
340 ("write_file", Some(old)) => {
341 let content = s("content");
342 let d = diff(&old, &content);
343 old_text = Some(old);
344 new_text = Some(content);
345 d
346 }
347 (_, Some(old)) => {
348 let (from, to) = (s("old_string"), s("new_string"));
349 let all = args["replace_all"] == true;
350 if !from.is_empty() && old.contains(&from) {
351 let new = if all {
352 old.replace(&from, &to)
353 } else {
354 old.replacen(&from, &to, 1)
355 };
356 let d = diff(&old, &new);
357 old_text = Some(old);
358 new_text = Some(new);
359 d
360 } else {
361 let mut l = vec![
362 "the text to replace is not in the file as it is now:".to_owned(),
363 ];
364 l.extend(from.lines().map(|x| format!(" - {}", escape(x))));
365 l.extend(to.lines().map(|x| format!(" + {}", escape(x))));
366 l
367 }
368 }
369 (_, None) => {
370 let (from, to) = (s("old_string"), s("new_string"));
371 let mut l: Vec<String> =
372 from.lines().map(|x| format!(" - {}", escape(x))).collect();
373 l.extend(to.lines().map(|x| format!(" + {}", escape(x))));
374 l
375 }
376 },
377 };
378 (title, escape(&path), lines)
379 }
380 };
381 let (mut body, more) = head(&full);
382 if more > 0 {
383 body.push(format!("… {more} more lines (v to view all)"));
384 }
385 let sandbox = match kind {
386 "run" | "network" => match &context.sandbox {
387 Some(m) => format!(
388 "sandbox on ({m}) · network: {} · writes: worktree",
389 if kind == "network" {
390 "this call"
391 } else {
392 context.network.as_str()
393 }
394 ),
395 None => "sandbox off — runs unconfined".to_owned(),
396 },
397 "mcp" => "the MCP server runs outside speccoder's sandbox".to_owned(),
398 _ => "a file write — nothing is executed".to_owned(),
399 };
400 let scope = scope_text(kind).to_owned();
401 let task = request.task.rsplit('/').next().unwrap_or(&request.task);
402 let options = request
403 .options
404 .iter()
405 .filter_map(|o| choice(o, task, &scope))
406 .collect();
407 Presentation {
408 request: request.clone(),
409 title: title.to_owned(),
410 subject,
411 object: object_lines(request.object.as_ref(), request.changed.as_deref()),
412 sources: source_lines(request),
413 notice: budget_notice(request),
414 body,
415 full,
416 sandbox,
417 options,
418 scope,
419 old_text,
420 new_text,
421 file: context
422 .root
423 .as_ref()
424 .filter(|_| !path.is_empty() && matches!(kind, "write" | "edit"))
425 .map(|r| r.join(&path).display().to_string()),
426 }
427}
428
429#[must_use]
433pub fn confirm_text(r: &super::ConfirmRequest) -> String {
434 let mut out = format!(
435 "Bug {} — {}: {} reproduction(s) fail on the untouched code by an assertion.\n",
436 r.bug,
437 r.title,
438 r.reproductions
439 .iter()
440 .filter(|x| x.statement.starts_with('E'))
441 .count()
442 );
443 for x in &r.reproductions {
444 out.push_str(&format!(
445 "\n{} — {}\n {} :: {}\n $ {}\n",
446 x.statement,
447 x.words,
448 x.file,
449 x.test,
450 x.argv.join(" ")
451 ));
452 for l in x.excerpt.lines().take(8) {
453 out.push_str(&format!(" | {l}\n"));
454 }
455 }
456 if let Some(w) = &r.warning {
457 out.push_str(&format!("\n! {w}\n"));
458 }
459 out.push_str(&format!("\ndigest {}\n", r.digest));
460 out
461}
462
463#[must_use]
466pub fn choice(o: &str, task: &str, scope: &str) -> Option<Choice> {
467 let (key, number, label) = match o {
468 "once" => ('y', 1, "Yes, once".to_owned()),
469 "task" => ('t', 2, format!("Yes, for the rest of {task}: {scope}")),
470 "run" => ('r', 3, format!("Yes, for this run: {scope}, in any task")),
471 "always" => (
472 'a',
473 4,
474 "Always allow exactly this command (adds it to speccoder.toml)".to_owned(),
475 ),
476 "reject" => ('n', 5, "No, and tell the model why …".to_owned()),
477 "stop" => (
478 's',
479 6,
480 "Stop this task here (it waits; decide later)".to_owned(),
481 ),
482 _ => return None,
483 };
484 Some(Choice {
485 choice: o.to_owned(),
486 key,
487 number,
488 label,
489 })
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use serde_json::json;
496 use std::path::Path;
497
498 fn request(tool: &str, arguments: serde_json::Value, kind: &str, risk: &str) -> Request {
499 Request {
500 task: "001-slugify/T004".to_owned(),
501 task_title: "Handle Unicode input".to_owned(),
502 turn: 8,
503 tool: tool.to_owned(),
504 arguments,
505 kind: kind.to_owned(),
506 reason: "why".to_owned(),
507 risk: risk.to_owned(),
508 options: crate::approvals::options(risk, true, false, kind, risk),
509 ..Request::default()
510 }
511 }
512
513 fn ctx(root: &Path) -> PresentContext {
514 PresentContext {
515 root: Some(root.to_path_buf()),
516 sandbox: Some("Seatbelt".to_owned()),
517 network: "none".to_owned(),
518 file_text: None,
519 }
520 }
521
522 #[test]
523 fn a_new_file_shows_its_head() {
524 let d = tempfile::tempdir().unwrap();
525 let content: String = (1..=25).map(|i| format!("line {i}\n")).collect();
526 let p = present(
527 &request(
528 "write_file",
529 json!({ "path": "src/new.rs", "content": content }),
530 "write",
531 "low",
532 ),
533 &ctx(d.path()),
534 );
535 assert_eq!(
536 (p.title.as_str(), p.subject.as_str()),
537 ("Write this file?", "src/new.rs")
538 );
539 assert_eq!(p.body[0], " 1 │ line 1");
540 assert_eq!(p.body.last().unwrap(), "… 5 more lines (v to view all)");
541 assert_eq!(p.full.len(), 25);
542 assert_eq!(p.sandbox, "a file write — nothing is executed");
543 assert!(p.old_text.is_none() && p.new_text.is_some());
544 }
545
546 #[test]
547 fn an_edit_is_a_numbered_diff() {
548 let d = tempfile::tempdir().unwrap();
549 std::fs::write(
550 d.path().join("Cargo.toml"),
551 "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n",
552 )
553 .unwrap();
554 let p = present(
555 &request(
556 "edit_file",
557 json!({ "path": "Cargo.toml", "old_string": "serde = \"1\"\n", "new_string": "serde = \"1\"\nunicode-normalization = \"0.1\"\n" }),
558 "edit",
559 "medium",
560 ),
561 &ctx(d.path()),
562 );
563 assert_eq!(p.title, "Edit this file?");
564 assert!(
565 p.body
566 .contains(&" 6 + unicode-normalization = \"0.1\"".to_owned()),
567 "{:#?}",
568 p.body
569 );
570 assert!(
571 p.body.contains(&" 5 5 serde = \"1\"".to_owned()),
572 "{:#?}",
573 p.body
574 );
575 let labels: Vec<&str> = p.options.iter().map(|o| o.label.as_str()).collect();
576 assert_eq!(
577 labels[1],
578 "Yes, for the rest of T004: this file, any content"
579 );
580 assert_eq!(
581 labels[2],
582 "Yes, for this run: this file, any content, in any task"
583 );
584 }
585
586 #[test]
587 fn a_missing_old_string_is_said_and_shown() {
588 let d = tempfile::tempdir().unwrap();
589 std::fs::write(d.path().join("a.rs"), "fn a() {}\n").unwrap();
590 let p = present(
591 &request(
592 "edit_file",
593 json!({ "path": "a.rs", "old_string": "fn b() {}", "new_string": "fn c() {}" }),
594 "edit",
595 "low",
596 ),
597 &ctx(d.path()),
598 );
599 assert_eq!(
600 p.body[0],
601 "the text to replace is not in the file as it is now:"
602 );
603 assert_eq!(
604 p.body[1..],
605 [" - fn b() {}".to_owned(), " + fn c() {}".to_owned()]
606 );
607 }
608
609 #[test]
610 fn commands_network_and_mcp() {
611 let d = tempfile::tempdir().unwrap();
612 let p = present(
613 &request(
614 "run_command",
615 json!({ "argv": ["sh", "-c", "echo 'hi' > x"] }),
616 "run",
617 "high",
618 ),
619 &ctx(d.path()),
620 );
621 assert_eq!(p.title, "Run this command?");
622 assert_eq!(
623 p.body,
624 ["$ sh -c 'echo '\\''hi'\\'' > x'", "cwd: . · network: none"]
625 );
626 assert_eq!(
627 p.sandbox,
628 "sandbox on (Seatbelt) · network: none · writes: worktree"
629 );
630 let numbers: Vec<u8> = p.options.iter().map(|o| o.number).collect();
632 assert_eq!(numbers, [1, 2, 5, 6]);
633 let p = present(
634 &request(
635 "run_command",
636 json!({ "argv": ["curl", "x"], "network": true }),
637 "network",
638 "high",
639 ),
640 &PresentContext {
641 sandbox: None,
642 ..ctx(d.path())
643 },
644 );
645 assert_eq!(p.title, "Use the network?");
646 assert_eq!(p.body[1], "cwd: . · network: this call");
647 assert_eq!(p.sandbox, "sandbox off — runs unconfined");
648 let p = present(
649 &request(
650 "mcp__gh__create_issue",
651 json!({ "title": "x" }),
652 "mcp",
653 "medium",
654 ),
655 &ctx(d.path()),
656 );
657 assert_eq!(
658 (p.title.as_str(), p.subject.as_str()),
659 ("Call MCP tool?", "mcp__gh__create_issue")
660 );
661 assert_eq!(p.sandbox, "the MCP server runs outside speccoder's sandbox");
662 assert!(
663 p.options[1]
664 .label
665 .ends_with("this tool as defined now, any arguments")
666 );
667 }
668
669 #[test]
670 fn model_text_cannot_drive_the_terminal() {
671 assert_eq!(escape("a\x1b[2Jb\x07c\nd"), "a␛[2Jb^Gc\nd");
672 let d = tempfile::tempdir().unwrap();
673 std::fs::write(d.path().join("bin"), [0u8, 159, 146, 150]).unwrap();
674 let p = present(
675 &request(
676 "write_file",
677 json!({ "path": "bin", "content": "x" }),
678 "edit",
679 "low",
680 ),
681 &ctx(d.path()),
682 );
683 assert_eq!(p.body, ["a binary file — not shown"]);
684 }
685
686 #[test]
687 fn the_object_sources_and_budget_are_shown() {
688 let d = tempfile::tempdir().unwrap();
689 let mut r = request(
690 "run_command",
691 json!({ "argv": ["make", "check"] }),
692 "run",
693 "low",
694 );
695 r.object = Some(Object::command(
696 vec!["make".to_owned(), "check".to_owned()],
697 ".".to_owned(),
698 "/w/bin/make".to_owned(),
699 false,
700 ));
701 r.changed = Some("executable /usr/bin/make → /w/bin/make".to_owned());
702 r.sources = vec![crate::approvals::Source {
703 argument: "make check".to_owned(),
704 from: "README.md".to_owned(),
705 turn: 2,
706 }];
707 r.more = 2;
708 r.asked = 12;
709 r.budget = 12;
710 r.options = crate::approvals::options("low", true, false, "run", "low");
711 let p = present(&r, &ctx(d.path()));
712 assert_eq!(
713 p.object[0],
714 format!(
715 "argv [\"make\",\"check\"] #{}",
716 &crate::tools::policy::argv_sha256(&["make".to_owned(), "check".to_owned()])[..12]
717 )
718 );
719 assert_eq!(p.object[1], "runs /w/bin/make in .");
720 assert_eq!(
721 p.object[2],
722 "changed since approved: executable /usr/bin/make → /w/bin/make"
723 );
724 assert_eq!(
725 p.sources,
726 [
727 "contains text read from README.md (turn 2): \"make check\"",
728 "and 2 more"
729 ]
730 );
731 assert!(
732 p.notice
733 .as_deref()
734 .is_some_and(|n| n.starts_with("This is prompt 12 of 12"))
735 );
736 let keys: Vec<char> = p.options.iter().map(|o| o.key).collect();
737 assert_eq!(keys, ['y', 't', 'r', 'a', 'n', 's']);
738 let mut w = request(
740 "write_file",
741 json!({ "path": "src/a/x.rs", "content": "" }),
742 "write",
743 "low",
744 );
745 w.object = Some(Object::Path {
746 canonical: "src/b/x.rs".to_owned(),
747 asked: "src/a/x.rs".to_owned(),
748 });
749 let p = present(&w, &ctx(d.path()));
750 assert_eq!(
751 p.object,
752 [
753 "file src/b/x.rs",
754 "asked as src/a/x.rs (resolves to the file above)"
755 ]
756 );
757 }
758}