Skip to main content

speccoder_runtime/approvals/
present.rs

1//! What a person sees when a call needs them: built live from the
2//! journaled [`Request`] and what only the live run knows — the file as it
3//! is now, the sandbox — and never journaled itself, so replay does not
4//! depend on the worktree. The terminal prompt, editors (ACP), the
5//! `approvals` listing and the dashboard all render this one value.
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use super::Request;
12use crate::tools::policy::Object;
13
14/// Lines of a body shown before "v to view all".
15pub const HEAD: usize = 20;
16
17/// Files larger than this are described, not shown.
18const MAX_BYTES: usize = 1024 * 1024;
19
20/// What only the live run knows.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct PresentContext {
23    /// The worktree, to read the file a write or edit changes.
24    pub root: Option<PathBuf>,
25    /// The sandbox mechanism (`Seatbelt`, `Landlock+seccomp`); `None`: off.
26    pub sandbox: Option<String>,
27    /// What a command's network is without asking (`none`, `loopback`).
28    pub network: String,
29    /// The file's text, given instead of read from `root` (a saved attempt's).
30    pub file_text: Option<String>,
31}
32
33/// One choice: `once`, `task`, `run`, `always`, `reject` or `stop`.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Choice {
36    /// Its name.
37    pub choice: String,
38    /// Its key (`y`, `t`, `r`, `a`, `n`, `s`).
39    pub key: char,
40    /// Its number, fixed whether or not the others are offered.
41    pub number: u8,
42    /// What it does, the scope included.
43    pub label: String,
44}
45
46/// The call as a person sees it.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Presentation {
49    /// The question.
50    pub request: Request,
51    /// `Write this file?`, `Edit this file?`, `Run this command?`,
52    /// `Use the network?`, `Call MCP tool?`.
53    pub title: String,
54    /// The path, the whole command, or the MCP tool — never cut.
55    pub subject: String,
56    /// The object a decision covers, as lines: the file (and the name as
57    /// asked when it differs), the argv with its hash and the executable,
58    /// the tool with its digest; and what changed since an approval of the
59    /// same name, when something did.
60    pub object: Vec<String>,
61    /// Where arguments of the call were copied from: `contains text read
62    /// from README.md (turn 3): "…"`, then `and N more`.
63    pub sources: Vec<String>,
64    /// Said once, on the prompt that uses the run's asking budget.
65    pub notice: Option<String>,
66    /// The body as shown: a diff, a file's head, the command, arguments.
67    pub body: Vec<String>,
68    /// The whole body (`v`).
69    pub full: Vec<String>,
70    /// What the call can reach.
71    pub sandbox: String,
72    /// The choices, in order.
73    pub options: Vec<Choice>,
74    /// What an approval for the task or run covers (`this path, any content`).
75    pub scope: String,
76    /// The file's text before and after, for editors that show a diff.
77    pub old_text: Option<String>,
78    /// The file's text after the call.
79    pub new_text: Option<String>,
80    /// The file's absolute path, for a write or edit in a known worktree.
81    pub file: Option<String>,
82}
83
84/// Model text made safe for a terminal: escape (`\x1b`) as `␛`, other
85/// control characters as `^X`; newlines and tabs kept.
86#[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
99/// A word an argv element needs no quotes for.
100fn plain(a: &str) -> bool {
101    !a.is_empty()
102        && a.chars()
103            .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%".contains(c))
104}
105
106/// The argv as a person would type it.
107#[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/// What "for the task" or "for this run" covers, by kind: always this
122/// exact object.
123#[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/// The object's lines: what a decision covers, shown in full.
138#[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/// The source lines of a request.
181#[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/// The notice on the prompt that uses the run's asking budget.
206#[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
221/// Numbered lines of a new file.
222fn 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/// A unified diff with three lines of context and old/new line numbers.
230#[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
259/// The text of `path` under `root`, or why it cannot be shown.
260fn 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/// Builds what the person sees.
280#[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            // A write over a file that exists now is an edit of it.
327            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/// The text a terminal shows for a bug's reproductions: each with its
430/// statement, file, test and how it failed, then the digest the answer is
431/// bound to.
432#[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/// The choice `o` as every interface offers it: its key, its fixed number
464/// and its label.
465#[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        // High risk: no "for this run", no "always"; the numbers stay fixed.
631        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        // A path asked by another name shows both.
739        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}