Skip to main content

aft/
subc_format.rs

1//! Agent-facing text formatters for subc-mode tool results (parity with TS plugins).
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use crate::protocol::Response;
7use crate::subc_translate::resolve_path_from_project_root;
8use serde_json::Value;
9
10const MAX_UNCHECKED_FILES_IN_FOOTER: usize = 10;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum OutlineMode {
14    Text,
15    Files,
16    DirectoryJson,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct FormatContext {
21    pub agent_specified_range: bool,
22    pub outline_mode: OutlineMode,
23    pub callgraph_op: Option<String>,
24    pub callgraph_include_unresolved: bool,
25    pub zoom_target_label: Option<String>,
26    pub ast_dry_run: bool,
27    pub import_op: Option<String>,
28    pub import_remove_name: Option<String>,
29    pub import_file_arg: Option<String>,
30    pub import_module_arg: Option<String>,
31    pub refactor_op: Option<String>,
32    pub refactor_symbol_arg: Option<String>,
33    pub refactor_name_arg: Option<String>,
34    pub refactor_file_arg: Option<String>,
35    pub move_file_arg: Option<String>,
36    pub move_dest_arg: Option<String>,
37    pub safety_op: Option<String>,
38    pub safety_file_arg: Option<String>,
39    pub safety_name_arg: Option<String>,
40}
41
42impl Default for FormatContext {
43    fn default() -> Self {
44        Self {
45            agent_specified_range: false,
46            outline_mode: OutlineMode::Text,
47            callgraph_op: None,
48            callgraph_include_unresolved: false,
49            zoom_target_label: None,
50            ast_dry_run: false,
51            import_op: None,
52            import_remove_name: None,
53            import_file_arg: None,
54            import_module_arg: None,
55            refactor_op: None,
56            refactor_symbol_arg: None,
57            refactor_name_arg: None,
58            refactor_file_arg: None,
59            move_file_arg: None,
60            move_dest_arg: None,
61            safety_op: None,
62            safety_file_arg: None,
63            safety_name_arg: None,
64        }
65    }
66}
67
68impl FormatContext {
69    pub fn from_tool_call(bare_name: &str, arguments: &Value, project_root: &Path) -> Self {
70        Self {
71            agent_specified_range: agent_specified_read_range(arguments),
72            outline_mode: outline_mode_for_call(bare_name, arguments, project_root),
73            callgraph_op: callgraph_op_for_call(bare_name, arguments),
74            callgraph_include_unresolved: callgraph_include_unresolved_for_call(
75                bare_name, arguments,
76            ),
77            zoom_target_label: zoom_target_label_for_call(bare_name, arguments),
78            ast_dry_run: ast_replace_dry_run_for_call(bare_name, arguments),
79            import_op: import_string_arg_for_call(bare_name, arguments, "op"),
80            import_remove_name: import_string_arg_for_call(bare_name, arguments, "removeName"),
81            import_file_arg: import_string_arg_for_call(bare_name, arguments, "filePath"),
82            import_module_arg: import_string_arg_for_call(bare_name, arguments, "module"),
83            refactor_op: refactor_string_arg_for_call(bare_name, arguments, "op"),
84            refactor_symbol_arg: refactor_string_arg_for_call(bare_name, arguments, "symbol"),
85            refactor_name_arg: refactor_string_arg_for_call(bare_name, arguments, "name"),
86            refactor_file_arg: refactor_string_arg_for_call(bare_name, arguments, "filePath"),
87            move_file_arg: move_string_arg_for_call(bare_name, arguments, "filePath"),
88            move_dest_arg: move_string_arg_for_call(bare_name, arguments, "destination"),
89            safety_op: safety_string_arg_for_call(bare_name, arguments, "op"),
90            safety_file_arg: safety_string_arg_for_call(bare_name, arguments, "filePath"),
91            safety_name_arg: safety_string_arg_for_call(bare_name, arguments, "name"),
92        }
93    }
94}
95
96fn agent_specified_read_range(arguments: &Value) -> bool {
97    let Some(obj) = arguments.as_object() else {
98        return false;
99    };
100    obj.contains_key("startLine")
101        || obj.contains_key("endLine")
102        || obj.contains_key("offset")
103        || obj.contains_key("limit")
104}
105
106fn outline_mode_for_call(bare_name: &str, arguments: &Value, project_root: &Path) -> OutlineMode {
107    if bare_name != "outline" {
108        return OutlineMode::Text;
109    }
110    let Some(obj) = arguments.as_object() else {
111        return OutlineMode::Text;
112    };
113    if obj.get("files").and_then(Value::as_bool) == Some(true) {
114        return OutlineMode::Files;
115    }
116    let Some(target) = obj.get("target").and_then(Value::as_str) else {
117        return OutlineMode::Text;
118    };
119    if target.starts_with("http://") || target.starts_with("https://") {
120        return OutlineMode::Text;
121    }
122    let resolved = resolve_path_from_project_root(project_root, target);
123    if std::fs::metadata(resolved)
124        .map(|m| m.is_dir())
125        .unwrap_or(false)
126    {
127        OutlineMode::DirectoryJson
128    } else {
129        OutlineMode::Text
130    }
131}
132
133fn callgraph_op_for_call(bare_name: &str, arguments: &Value) -> Option<String> {
134    if bare_name != "callgraph" {
135        return None;
136    }
137    arguments
138        .as_object()
139        .and_then(|obj| obj.get("op"))
140        .and_then(Value::as_str)
141        .filter(|op| !op.is_empty())
142        .map(str::to_string)
143}
144
145fn callgraph_include_unresolved_for_call(bare_name: &str, arguments: &Value) -> bool {
146    if bare_name != "callgraph" {
147        return false;
148    }
149    arguments
150        .as_object()
151        .and_then(|obj| obj.get("includeUnresolved"))
152        .is_some_and(coerce_boolean)
153}
154
155fn zoom_target_label_for_call(bare_name: &str, arguments: &Value) -> Option<String> {
156    if bare_name != "zoom" {
157        return None;
158    }
159    let obj = arguments.as_object()?;
160    obj.get("filePath")
161        .or_else(|| obj.get("url"))
162        .and_then(Value::as_str)
163        .filter(|label| !label.is_empty())
164        .map(str::to_string)
165}
166
167fn ast_replace_dry_run_for_call(bare_name: &str, arguments: &Value) -> bool {
168    if bare_name != "ast_replace" {
169        return false;
170    }
171    arguments
172        .as_object()
173        .and_then(|obj| obj.get("dryRun").or_else(|| obj.get("dry_run")))
174        .is_some_and(coerce_boolean)
175}
176
177fn import_string_arg_for_call(bare_name: &str, arguments: &Value, key: &str) -> Option<String> {
178    if bare_name != "import" {
179        return None;
180    }
181    arguments
182        .as_object()
183        .and_then(|obj| obj.get(key))
184        .and_then(Value::as_str)
185        .map(str::to_string)
186}
187
188fn refactor_string_arg_for_call(bare_name: &str, arguments: &Value, key: &str) -> Option<String> {
189    if bare_name != "refactor" {
190        return None;
191    }
192    arguments
193        .as_object()
194        .and_then(|obj| obj.get(key))
195        .and_then(Value::as_str)
196        .map(str::to_string)
197}
198
199fn move_string_arg_for_call(bare_name: &str, arguments: &Value, key: &str) -> Option<String> {
200    if bare_name != "move" {
201        return None;
202    }
203    arguments
204        .as_object()
205        .and_then(|obj| obj.get(key))
206        .and_then(Value::as_str)
207        .map(str::to_string)
208}
209
210fn safety_string_arg_for_call(bare_name: &str, arguments: &Value, key: &str) -> Option<String> {
211    if bare_name != "safety" {
212        return None;
213    }
214    arguments
215        .as_object()
216        .and_then(|obj| obj.get(key))
217        .and_then(Value::as_str)
218        .map(str::to_string)
219}
220
221fn coerce_boolean(value: &Value) -> bool {
222    match value {
223        Value::Bool(value) => *value,
224        Value::Number(num) => num.as_i64() == Some(1) || num.as_u64() == Some(1),
225        Value::String(raw) => {
226            let normalized = raw.trim().to_ascii_lowercase();
227            normalized == "true" || normalized == "1"
228        }
229        _ => false,
230    }
231}
232
233// Return true for agent tools whose text output is formatted on the server.
234// The capability manifest and this formatter both identify tools by their bare
235// internal routing names, without the agent-facing `aft_` prefix.
236fn is_core_agent_tool(bare_name: &str) -> bool {
237    matches!(
238        bare_name,
239        "status"
240            | "bash"
241            | "read"
242            | "write"
243            | "edit"
244            | "apply_patch"
245            | "grep"
246            | "glob"
247            | "search"
248            | "outline"
249            | "zoom"
250            | "inspect"
251            | "callgraph"
252            | "conflicts"
253            | "ast_search"
254            | "ast_replace"
255            | "delete"
256            | "move"
257            | "import"
258            | "refactor"
259            | "safety"
260    )
261}
262
263/// Render the text block for a tool `CallToolResult` from the structured AFT `Response`.
264pub fn format_response(
265    bare_name: &str,
266    response: &Response,
267    agent_specified_range: bool,
268) -> String {
269    let ctx = FormatContext {
270        agent_specified_range,
271        ..FormatContext::default()
272    };
273    format_response_with_context(bare_name, response, &ctx)
274}
275
276/// Render the text block for a tool `CallToolResult` from the structured AFT `Response`.
277pub fn format_response_with_context(
278    bare_name: &str,
279    response: &Response,
280    ctx: &FormatContext,
281) -> String {
282    if !is_core_agent_tool(bare_name) {
283        return serde_json::to_string(response).unwrap_or_else(|_| "{}".to_string());
284    }
285
286    let data = &response.data;
287    if !response.success {
288        return format_error(bare_name, data, ctx);
289    }
290
291    match bare_name {
292        "edit" => format_edit_response(data),
293        "write" => format_write_response(data),
294        "apply_patch" => format_apply_patch(data),
295        "read" => format_read(data, ctx.agent_specified_range),
296        "grep" => format_grep(data),
297        "glob" => data["text"].as_str().unwrap_or_default().to_string(),
298        "search" => format_search(data),
299        "outline" => format_outline(response, ctx.outline_mode),
300        "zoom" => format_zoom(data, ctx),
301        "inspect" => format_inspect(response),
302        "status" => format_status(data),
303        "bash" => data["output"].as_str().unwrap_or_default().to_string(),
304        "callgraph" => format_callgraph(
305            ctx.callgraph_op.as_deref().unwrap_or("callgraph"),
306            data,
307            ctx.callgraph_include_unresolved,
308        ),
309        "conflicts" => data["text"].as_str().unwrap_or_default().to_string(),
310        "ast_search" => format_ast_search(data),
311        "ast_replace" => format_ast_replace(data, ctx.ast_dry_run),
312        "delete" => format_delete(data),
313        "move" => format_move(data, ctx),
314        "import" => format_import(data, ctx),
315        "refactor" => format_refactor(data, ctx),
316        "safety" => format_safety(data, ctx),
317        _ => unreachable!("core agent tools are exhaustive"),
318    }
319}
320
321fn import_string_field(response: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
322    response
323        .get(key)
324        .and_then(Value::as_str)
325        .map(str::to_string)
326}
327
328fn import_number_field(response: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
329    response.get(key).and_then(import_number_value)
330}
331
332fn import_number_value(value: &Value) -> Option<String> {
333    let number = value.as_number()?;
334    if let Some(n) = number.as_i64() {
335        Some(n.to_string())
336    } else if let Some(n) = number.as_u64() {
337        Some(n.to_string())
338    } else {
339        number.as_f64().map(|n| n.to_string())
340    }
341}
342
343fn import_module_name(response: &serde_json::Map<String, Value>, ctx: &FormatContext) -> String {
344    import_string_field(response, "module")
345        .or_else(|| ctx.import_module_arg.clone())
346        .unwrap_or_else(|| "(module)".to_string())
347}
348
349fn import_file_name(response: &serde_json::Map<String, Value>, ctx: &FormatContext) -> String {
350    import_string_field(response, "file")
351        .or_else(|| ctx.import_file_arg.clone())
352        .unwrap_or_default()
353}
354
355fn format_apply_patch(data: &Value) -> String {
356    if let Some(output) = data
357        .get("output")
358        .and_then(Value::as_str)
359        .filter(|output| !output.is_empty())
360    {
361        return output.to_string();
362    }
363
364    data.get("metadata")
365        .and_then(|metadata| metadata.get("files"))
366        .and_then(Value::as_array)
367        .map(|files| {
368            files
369                .iter()
370                .filter_map(|file| {
371                    let kind = file.get("type").and_then(Value::as_str).unwrap_or("update");
372                    let rel = file
373                        .get("relativePath")
374                        .or_else(|| file.get("filePath"))
375                        .and_then(Value::as_str)
376                        .unwrap_or("(file)");
377                    match kind {
378                        "add" => Some(format!("Created {rel}")),
379                        "delete" => Some(format!("Deleted {rel}")),
380                        "move" => {
381                            let move_path =
382                                file.get("movePath").and_then(Value::as_str).unwrap_or(rel);
383                            Some(format!("Moved {rel} → {move_path}"))
384                        }
385                        "update" => Some(format!("Updated {rel}")),
386                        _ => None,
387                    }
388                })
389                .collect::<Vec<_>>()
390                .join("\n")
391        })
392        .unwrap_or_default()
393}
394
395fn format_delete(data: &Value) -> String {
396    let Some(response) = data.as_object() else {
397        return "Deleted 0/0 file(s)".to_string();
398    };
399    let deleted = response
400        .get("deleted")
401        .and_then(Value::as_array)
402        .map(Vec::as_slice)
403        .unwrap_or(&[]);
404    let skipped = response
405        .get("skipped_files")
406        .and_then(Value::as_array)
407        .map(Vec::as_slice)
408        .unwrap_or(&[]);
409
410    if deleted.len() == 1 && skipped.is_empty() {
411        let file = deleted[0]
412            .get("file")
413            .and_then(Value::as_str)
414            .unwrap_or_default();
415        return format!("Deleted {file}");
416    }
417
418    let total = deleted.len() + skipped.len();
419    format!("Deleted {}/{} file(s)", deleted.len(), total)
420}
421
422fn format_move(data: &Value, ctx: &FormatContext) -> String {
423    let response = data.as_object();
424    let file = ctx
425        .move_file_arg
426        .clone()
427        .or_else(|| {
428            response
429                .and_then(|r| import_string_field(r, "file"))
430                .map(|p| shorten_path(&p))
431        })
432        .unwrap_or_default();
433    let destination = ctx
434        .move_dest_arg
435        .clone()
436        .or_else(|| {
437            response
438                .and_then(|r| import_string_field(r, "destination"))
439                .map(|p| shorten_path(&p))
440        })
441        .unwrap_or_default();
442
443    format!("Moved {file} → {destination}")
444}
445
446fn format_import(data: &Value, ctx: &FormatContext) -> String {
447    let Some(response) = data.as_object() else {
448        return "No import result.".to_string();
449    };
450
451    match ctx.import_op.as_deref() {
452        Some("organize") => {
453            let group_text = response
454                .get("groups")
455                .and_then(Value::as_array)
456                .filter(|groups| !groups.is_empty())
457                .map(|groups| {
458                    groups
459                        .iter()
460                        .map(|group| {
461                            let name = group
462                                .get("name")
463                                .and_then(Value::as_str)
464                                .unwrap_or("unknown");
465                            let count = group
466                                .get("count")
467                                .and_then(import_number_value)
468                                .unwrap_or_else(|| "0".to_string());
469                            format!("{name}: {count}")
470                        })
471                        .collect::<Vec<_>>()
472                        .join(" · ")
473                })
474                .unwrap_or_else(|| "No imports found".to_string());
475            let removed_duplicates = import_number_field(response, "removed_duplicates")
476                .unwrap_or_else(|| "0".to_string());
477            [
478                format!("organized {}", import_file_name(response, ctx)),
479                format!("groups {group_text}"),
480                format!("duplicates removed {removed_duplicates}"),
481            ]
482            .join("\n")
483        }
484        Some("add") => {
485            let status = if response.get("already_present").and_then(Value::as_bool) == Some(true) {
486                "already present"
487            } else {
488                "added"
489            };
490            [
491                format!("{status} {}", import_module_name(response, ctx)),
492                format!("file {}", import_file_name(response, ctx)),
493                format!(
494                    "group {}",
495                    import_string_field(response, "group").unwrap_or_else(|| "—".to_string())
496                ),
497            ]
498            .join("\n")
499        }
500        Some("remove") => {
501            let module = import_module_name(response, ctx);
502            let status = if response.get("removed").and_then(Value::as_bool) == Some(false) {
503                format!("not present {module}")
504            } else {
505                format!("removed {module}")
506            };
507            let scope = ctx
508                .import_remove_name
509                .as_deref()
510                .filter(|name| !name.is_empty())
511                .map(|name| format!("name {name}"))
512                .unwrap_or_else(|| "scope entire import".to_string());
513            [
514                status,
515                format!("file {}", import_file_name(response, ctx)),
516                scope,
517            ]
518            .join("\n")
519        }
520        _ => "No import result.".to_string(),
521    }
522}
523
524fn format_refactor(data: &Value, ctx: &FormatContext) -> String {
525    let Some(response) = data.as_object() else {
526        return "No refactor result.".to_string();
527    };
528
529    match ctx.refactor_op.as_deref() {
530        Some("move") => {
531            let results = response
532                .get("results")
533                .and_then(Value::as_array)
534                .map(|items| {
535                    items
536                        .iter()
537                        .filter_map(Value::as_object)
538                        .collect::<Vec<_>>()
539                })
540                .unwrap_or_default();
541            let files_modified = import_number_field(response, "files_modified")
542                .unwrap_or_else(|| results.len().to_string());
543            let consumers_updated = import_number_field(response, "consumers_updated")
544                .unwrap_or_else(|| "0".to_string());
545            let files = if results.is_empty() {
546                "No files reported.".to_string()
547            } else {
548                results
549                    .iter()
550                    .map(|entry| {
551                        let file = entry
552                            .get("file")
553                            .and_then(Value::as_str)
554                            .unwrap_or("(unknown file)");
555                        format!("  ↳ {}", shorten_path(file))
556                    })
557                    .collect::<Vec<_>>()
558                    .join("\n")
559            };
560
561            [
562                format!(
563                    "moved symbol {}",
564                    ctx.refactor_symbol_arg
565                        .clone()
566                        .unwrap_or_else(|| "(symbol)".to_string())
567                ),
568                format!("files modified {files_modified}"),
569                format!("consumers updated {consumers_updated}"),
570                files,
571            ]
572            .join("\n")
573        }
574        Some("extract") => {
575            let name = import_string_field(response, "name")
576                .or_else(|| ctx.refactor_name_arg.clone())
577                .unwrap_or_else(|| "(function)".to_string());
578            let file = import_string_field(response, "file")
579                .or_else(|| ctx.refactor_file_arg.clone())
580                .unwrap_or_default();
581            let parameters = response
582                .get("parameters")
583                .and_then(Value::as_array)
584                .map(|items| {
585                    let joined = items
586                        .iter()
587                        .map(value_to_plain_string)
588                        .collect::<Vec<_>>()
589                        .join(", ");
590                    if joined.is_empty() {
591                        "none".to_string()
592                    } else {
593                        joined
594                    }
595                })
596                .unwrap_or_else(|| "none".to_string());
597            let return_type = import_string_field(response, "return_type")
598                .unwrap_or_else(|| "unknown".to_string());
599
600            [
601                format!("extracted {name}"),
602                format!("file {}", shorten_path(&file)),
603                format!("params {parameters}"),
604                format!("return type {return_type}"),
605            ]
606            .join("\n")
607        }
608        Some("inline") => {
609            let symbol = import_string_field(response, "symbol")
610                .or_else(|| ctx.refactor_symbol_arg.clone())
611                .unwrap_or_else(|| "(symbol)".to_string());
612            let file = import_string_field(response, "file")
613                .or_else(|| ctx.refactor_file_arg.clone())
614                .unwrap_or_default();
615            let context = import_string_field(response, "call_context")
616                .unwrap_or_else(|| "unknown".to_string());
617            let substitutions =
618                import_number_field(response, "substitutions").unwrap_or_else(|| "0".to_string());
619
620            [
621                format!("inlined {symbol}"),
622                format!("file {}", shorten_path(&file)),
623                format!("context {context}"),
624                format!("substitutions {substitutions}"),
625            ]
626            .join("\n")
627        }
628        _ => "No refactor result.".to_string(),
629    }
630}
631
632fn format_safety(data: &Value, ctx: &FormatContext) -> String {
633    let Some(response) = data.as_object() else {
634        return "No safety result.".to_string();
635    };
636
637    match ctx.safety_op.as_deref() {
638        Some("undo") => {
639            if response.get("operation").and_then(Value::as_bool) == Some(true) {
640                let op_id = import_string_field(response, "op_id")
641                    .unwrap_or_else(|| "(operation)".to_string());
642                let files = import_number_field(response, "restored_count").unwrap_or_else(|| {
643                    response
644                        .get("restored")
645                        .and_then(Value::as_array)
646                        .map(|items| items.len().to_string())
647                        .unwrap_or_else(|| "0".to_string())
648                });
649                [
650                    format!("restored operation {op_id}"),
651                    format!("files {files}"),
652                ]
653                .join("\n")
654            } else {
655                // Prefer the agent's own path spelling: translate resolves a
656                // relative filePath against the project root before the undo
657                // runs, so the response's `path` is absolute — echoing that
658                // back at an agent that said "src/a.ts" reads as a different
659                // file and costs a follow-up read.
660                let file = ctx
661                    .safety_file_arg
662                    .clone()
663                    .or_else(|| import_string_field(response, "path"))
664                    .unwrap_or_else(|| "(file)".to_string());
665                let backup =
666                    import_string_field(response, "backup_id").unwrap_or_else(|| "—".to_string());
667                [
668                    format!("restored {}", shorten_path(&file)),
669                    format!("backup {backup}"),
670                ]
671                .join("\n")
672            }
673        }
674        Some("history") => {
675            let file = import_string_field(response, "file")
676                .or_else(|| ctx.safety_file_arg.clone())
677                .unwrap_or_else(|| "(file)".to_string());
678            let entries = records_field(response, "entries");
679            let mut lines = vec![shorten_path(&file)];
680            if entries.is_empty() {
681                lines.push("No history entries.".to_string());
682            } else {
683                lines.push(
684                    entries
685                        .iter()
686                        .enumerate()
687                        .map(|(index, entry)| {
688                            let backup_id = entry
689                                .get("backup_id")
690                                .and_then(Value::as_str)
691                                .map(str::to_string)
692                                .unwrap_or_else(|| format!("entry-{}", index + 1));
693                            let timestamp = entry
694                                .get("timestamp")
695                                .and_then(format_timestamp)
696                                .unwrap_or_else(|| "unknown time".to_string());
697                            let description = entry
698                                .get("description")
699                                .and_then(Value::as_str)
700                                .unwrap_or_default();
701                            let mut line = format!("{}. {backup_id} {timestamp}", index + 1);
702                            if !description.is_empty() {
703                                line.push_str("\n   ");
704                                line.push_str(description);
705                            }
706                            line
707                        })
708                        .collect::<Vec<_>>()
709                        .join("\n"),
710                );
711            }
712            lines.join("\n")
713        }
714        Some("checkpoint") => {
715            let name = import_string_field(response, "name")
716                .or_else(|| ctx.safety_name_arg.clone())
717                .unwrap_or_else(|| "(checkpoint)".to_string());
718            let files =
719                import_number_field(response, "file_count").unwrap_or_else(|| "0".to_string());
720            let skipped = records_field(response, "skipped");
721            let skipped_text = if skipped.is_empty() {
722                "No skipped files.".to_string()
723            } else {
724                let details = skipped
725                    .iter()
726                    .map(|entry| {
727                        let file = entry
728                            .get("file")
729                            .and_then(Value::as_str)
730                            .unwrap_or("(file)");
731                        let error = entry
732                            .get("error")
733                            .and_then(Value::as_str)
734                            .unwrap_or("unknown error");
735                        format!("  ↳ {}: {error}", shorten_path(file))
736                    })
737                    .collect::<Vec<_>>()
738                    .join("\n");
739                format!("skipped\n{details}")
740            };
741            [
742                format!("checkpoint created {name}"),
743                format!("files {files}"),
744                skipped_text,
745            ]
746            .join("\n")
747        }
748        Some("restore") => {
749            let name = import_string_field(response, "name")
750                .or_else(|| ctx.safety_name_arg.clone())
751                .unwrap_or_else(|| "(checkpoint)".to_string());
752            let files =
753                import_number_field(response, "file_count").unwrap_or_else(|| "0".to_string());
754            [
755                format!("checkpoint restored {name}"),
756                format!("files {files}"),
757            ]
758            .join("\n")
759        }
760        Some("list") => {
761            let checkpoints = records_field(response, "checkpoints");
762            let mut lines = vec![format!("{} checkpoint(s)", checkpoints.len())];
763            if checkpoints.is_empty() {
764                lines.push("No checkpoints saved.".to_string());
765            } else {
766                lines.push(
767                    checkpoints
768                        .iter()
769                        .enumerate()
770                        .map(|(index, checkpoint)| {
771                            let name = checkpoint
772                                .get("name")
773                                .and_then(Value::as_str)
774                                .map(str::to_string)
775                                .unwrap_or_else(|| format!("checkpoint-{}", index + 1));
776                            let file_count = checkpoint
777                                .get("file_count")
778                                .and_then(import_number_value)
779                                .unwrap_or_else(|| "0".to_string());
780                            let created = checkpoint
781                                .get("created_at")
782                                .and_then(format_timestamp)
783                                .unwrap_or_else(|| "unknown time".to_string());
784                            format!("{}. {name} {file_count} file(s) · {created}", index + 1)
785                        })
786                        .collect::<Vec<_>>()
787                        .join("\n"),
788                );
789            }
790            lines.join("\n")
791        }
792        _ => "No safety result.".to_string(),
793    }
794}
795
796fn format_timestamp(value: &Value) -> Option<String> {
797    if let Some(text) = value.as_str().filter(|text| !text.is_empty()) {
798        return Some(text.to_string());
799    }
800    let number = value.as_f64()?;
801    if !number.is_finite() {
802        return None;
803    }
804    let millis = if number > 1_000_000_000_000.0 {
805        number
806    } else {
807        number * 1000.0
808    };
809    const JS_DATE_MAX_MILLIS: f64 = 8_640_000_000_000_000.0;
810    if !millis.is_finite()
811        || millis.abs() > JS_DATE_MAX_MILLIS
812        || millis < i64::MIN as f64
813        || millis > i64::MAX as f64
814    {
815        return Some(value_to_plain_string(value));
816    }
817    Some(format_unix_millis_utc(millis.trunc() as i64))
818}
819
820fn format_unix_millis_utc(millis: i64) -> String {
821    let seconds = div_floor_i64(millis, 1000);
822    let millisecond = millis.rem_euclid(1000);
823    let days = div_floor_i64(seconds, 86_400);
824    let seconds_of_day = seconds.rem_euclid(86_400);
825    let (year, month, day) = civil_from_days(days);
826    let hour = seconds_of_day / 3600;
827    let minute = (seconds_of_day % 3600) / 60;
828    let second = seconds_of_day % 60;
829    if millisecond == 0 {
830        format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}Z")
831    } else {
832        format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{millisecond:03}Z")
833    }
834}
835
836fn div_floor_i64(value: i64, divisor: i64) -> i64 {
837    let quotient = value / divisor;
838    let remainder = value % divisor;
839    if remainder != 0 && ((remainder > 0) != (divisor > 0)) {
840        quotient - 1
841    } else {
842        quotient
843    }
844}
845
846fn civil_from_days(days: i64) -> (i64, i64, i64) {
847    let z = days + 719_468;
848    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
849    let doe = z - era * 146_097;
850    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
851    let year = yoe + era * 400;
852    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
853    let mp = (5 * doy + 2) / 153;
854    let day = doy - (153 * mp + 2) / 5 + 1;
855    let month = mp + if mp < 10 { 3 } else { -9 };
856    let year = year + if month <= 2 { 1 } else { 0 };
857    (year, month, day)
858}
859
860// Mirrors per-tool OpenCode wrapper error handling in packages/opencode-plugin/src/tools/*.ts.
861fn format_error(bare_name: &str, data: &Value, ctx: &FormatContext) -> String {
862    if bare_name == "callgraph" {
863        return format_callgraph_error(ctx.callgraph_op.as_deref().unwrap_or("callgraph"), data);
864    }
865    let code = data
866        .get("code")
867        .and_then(Value::as_str)
868        .filter(|s| !s.is_empty());
869    let message = data
870        .get("message")
871        .and_then(Value::as_str)
872        .filter(|s| !s.is_empty())
873        .unwrap_or("request failed");
874    match (bare_name, code) {
875        ("search", Some(c)) => format!("semantic_search: {c} — {message}"),
876        _ => message.to_string(),
877    }
878}
879
880// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createWriteTool.
881fn format_write_response(data: &Value) -> String {
882    if data.get("rolled_back").and_then(Value::as_bool) == Some(true) {
883        return "Write rolled back: the content produced invalid syntax, so the file was left unchanged."
884            .to_string();
885    }
886
887    let mut output = if data.get("created").and_then(Value::as_bool) == Some(true) {
888        "Created new file.".to_string()
889    } else {
890        "File updated.".to_string()
891    };
892    if is_truthy_formatted(data) {
893        output.push_str(" Auto-formatted.");
894    }
895    if data.get("no_op").and_then(Value::as_bool) == Some(true) {
896        output.push_str(
897            " No net change — the written content is byte-identical to what was already on disk.",
898        );
899    }
900    append_lsp_error_lines(&mut output, data, true);
901    append_lsp_server_notes(&mut output, data);
902    output
903}
904
905// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createEditTool.
906fn format_edit_response(data: &Value) -> String {
907    let mut result = format_edit_summary(data);
908
909    if let Some(note) = format_glob_skip_reasons_note(data.get("format_skip_reasons")) {
910        result.push_str("\n\n");
911        result.push_str(&note);
912    }
913    if data.get("no_op").and_then(Value::as_bool) == Some(true) {
914        result.push_str(
915            "\n\nNote: no net file change — the match was found and applied, but the file content is byte-identical to before. Likely causes: oldString and newString are identical, or a formatter normalized the change away.",
916        );
917    }
918    append_lsp_error_lines(&mut result, data, false);
919    append_lsp_server_notes(&mut result, data);
920    result
921}
922
923fn format_glob_skip_reasons_note(reasons: Option<&Value>) -> Option<String> {
924    let actionable = reasons?
925        .as_array()?
926        .iter()
927        .filter_map(Value::as_str)
928        .filter(|reason| {
929            matches!(
930                *reason,
931                "formatter_not_installed" | "formatter_excluded_path" | "timeout" | "error"
932            )
933        })
934        .collect::<std::collections::BTreeSet<_>>();
935    if actionable.is_empty() {
936        None
937    } else {
938        Some(format!(
939            "Note: formatter skipped some glob edit result file(s): {}. See per-file format_skipped_reason values for details.",
940            actionable.into_iter().collect::<Vec<_>>().join(", ")
941        ))
942    }
943}
944
945fn append_lsp_error_lines(output: &mut String, data: &Value, trailing_newline: bool) {
946    let errors = data
947        .get("lsp_diagnostics")
948        .and_then(Value::as_array)
949        .map(|items| {
950            items
951                .iter()
952                .filter(|d| d.get("severity").and_then(Value::as_str) == Some("error"))
953                .collect::<Vec<_>>()
954        })
955        .unwrap_or_default();
956    if errors.is_empty() {
957        return;
958    }
959
960    output.push_str("\n\nLSP errors detected, please fix:\n");
961    let lines = errors
962        .iter()
963        .map(|d| {
964            let line = d
965                .get("line")
966                .and_then(Value::as_u64)
967                .map(|n| n.to_string())
968                .unwrap_or_else(|| "undefined".to_string());
969            let message = d
970                .get("message")
971                .and_then(Value::as_str)
972                .unwrap_or("undefined");
973            format!("  Line {line}: {message}")
974        })
975        .collect::<Vec<_>>();
976    output.push_str(&lines.join("\n"));
977    if trailing_newline {
978        output.push('\n');
979    }
980}
981
982fn append_lsp_server_notes(output: &mut String, data: &Value) {
983    let pending = string_array(data.get("lsp_pending_servers"));
984    if !pending.is_empty() {
985        output.push_str(&format!(
986            "\n\nNote: LSP server(s) did not respond in time: {}. Diagnostics may be incomplete; call aft_inspect for a checkpoint diagnostics snapshot.",
987            pending.join(", ")
988        ));
989    }
990    let exited = string_array(data.get("lsp_exited_servers"));
991    if !exited.is_empty() {
992        output.push_str(&format!(
993            "\n\nNote: LSP server(s) exited during this edit: {}. Their diagnostics could not be collected.",
994            exited.join(", ")
995        ));
996    }
997}
998
999// Mirrors packages/aft-bridge/src/edit-summary.ts formatEditSummary.
1000fn format_edit_summary(data: &Value) -> String {
1001    if data.get("rolled_back").and_then(Value::as_bool) == Some(true) {
1002        return "Edit rolled back: the change produced invalid syntax, so the file was left unchanged."
1003            .to_string();
1004    }
1005
1006    if let Some(n) = data.get("files_modified").and_then(Value::as_u64) {
1007        let n = n as usize;
1008        return format!(
1009            "Applied edits to {} file{}.",
1010            n,
1011            if n == 1 { "" } else { "s" }
1012        );
1013    }
1014
1015    if let Some(files) = data.get("total_files").and_then(Value::as_u64) {
1016        let files = files as usize;
1017        let reps = data
1018            .get("total_replacements")
1019            .and_then(Value::as_u64)
1020            .unwrap_or(0) as usize;
1021        return format!(
1022            "Edited {} file{} ({} replacement{}).",
1023            files,
1024            if files == 1 { "" } else { "s" },
1025            reps,
1026            if reps == 1 { "" } else { "s" }
1027        );
1028    }
1029
1030    let additions = data
1031        .get("diff")
1032        .and_then(Value::as_object)
1033        .and_then(|d| d.get("additions"))
1034        .and_then(Value::as_u64)
1035        .unwrap_or(0) as usize;
1036    let deletions = data
1037        .get("diff")
1038        .and_then(Value::as_object)
1039        .and_then(|d| d.get("deletions"))
1040        .and_then(Value::as_u64)
1041        .unwrap_or(0) as usize;
1042    let counts = format!("+{additions}/-{deletions}");
1043
1044    if data.get("created").and_then(Value::as_bool) == Some(true) {
1045        let mut s = format!("Created file ({counts}).");
1046        if is_truthy_formatted(data) {
1047            s.push_str(&format_auto_formatted_suffix(data));
1048        }
1049        return s;
1050    }
1051
1052    let mut detail = counts.clone();
1053    if let Some(n) = data.get("edits_applied").and_then(Value::as_u64) {
1054        if n > 1 {
1055            detail = format!("{counts}, {n} edits");
1056        }
1057    } else if let Some(n) = data.get("replacements").and_then(Value::as_u64) {
1058        if n > 1 {
1059            detail = format!("{counts}, {n} replacements");
1060        }
1061    }
1062
1063    let mut s = format!("Edited ({detail}).");
1064    if is_truthy_formatted(data) {
1065        s.push_str(&format_auto_formatted_suffix(data));
1066    }
1067    s
1068}
1069
1070fn is_truthy_formatted(data: &Value) -> bool {
1071    data.get("formatted")
1072        .and_then(Value::as_bool)
1073        .unwrap_or(false)
1074}
1075
1076fn format_auto_formatted_suffix(data: &Value) -> String {
1077    let reformatted = data.get("reformatted").and_then(Value::as_object);
1078    if let Some(text) = reformatted
1079        .and_then(|r| r.get("text"))
1080        .and_then(Value::as_str)
1081        .filter(|s| !s.is_empty())
1082    {
1083        return format!(
1084            "\nAuto-formatted — the formatter reflowed your edit. On disk now:\n{text}"
1085        );
1086    }
1087    if reformatted
1088        .and_then(|r| r.get("extensive"))
1089        .and_then(Value::as_bool)
1090        == Some(true)
1091    {
1092        return " Auto-formatted — extensive reflow; re-read the file before your next anchored edit."
1093            .to_string();
1094    }
1095    " Auto-formatted.".to_string()
1096}
1097
1098// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createReadTool.
1099fn format_read(data: &Value, agent_specified_range: bool) -> String {
1100    if let Some(entries) = data.get("entries").and_then(Value::as_array) {
1101        return entries
1102            .iter()
1103            .filter_map(|e| e.as_str())
1104            .collect::<Vec<_>>()
1105            .join("\n");
1106    }
1107
1108    if let Some(attachment_line) = format_read_attachments(data) {
1109        return attachment_line;
1110    }
1111
1112    if data.get("binary").and_then(Value::as_bool).unwrap_or(false) {
1113        return data
1114            .get("message")
1115            .and_then(Value::as_str)
1116            .unwrap_or("Binary file")
1117            .to_string();
1118    }
1119
1120    let mut text = data
1121        .get("content")
1122        .and_then(Value::as_str)
1123        .unwrap_or("")
1124        .to_string();
1125    text.push_str(&format_read_footer(agent_specified_range, data));
1126    text
1127}
1128
1129fn format_read_attachments(data: &Value) -> Option<String> {
1130    let attachments = data.get("attachments")?.as_array()?;
1131    let has_host_attachment = attachments.iter().any(|attachment| {
1132        attachment.get("mime").and_then(Value::as_str).is_some()
1133            && attachment.get("data").and_then(Value::as_str).is_some()
1134    });
1135    if !has_host_attachment {
1136        return None;
1137    }
1138
1139    if let Some(content) = data
1140        .get("content")
1141        .and_then(Value::as_str)
1142        .filter(|content| !content.is_empty())
1143    {
1144        return Some(content.to_string());
1145    }
1146
1147    let first = attachments.first()?.as_object()?;
1148    let kind = first.get("kind").and_then(Value::as_str).unwrap_or("file");
1149    let mime = first
1150        .get("mime")
1151        .and_then(Value::as_str)
1152        .unwrap_or("application/octet-stream");
1153    let size = first
1154        .get("bytes")
1155        .and_then(Value::as_u64)
1156        .map(format_attachment_size);
1157
1158    if kind == "image" || mime.starts_with("image/") {
1159        let dimensions = match (
1160            first.get("width").and_then(Value::as_u64),
1161            first.get("height").and_then(Value::as_u64),
1162        ) {
1163            (Some(width), Some(height)) => format!(", {width}×{height}"),
1164            _ => String::new(),
1165        };
1166        let resized = if first.get("resized").and_then(Value::as_bool) == Some(true) {
1167            ", resized"
1168        } else {
1169            ""
1170        };
1171        let size = size.map(|size| format!(", {size}")).unwrap_or_default();
1172        return Some(format!("Read image ({mime}{dimensions}{resized}{size})."));
1173    }
1174
1175    if kind == "pdf" || mime == "application/pdf" {
1176        let size = size.map(|size| format!(" ({size})")).unwrap_or_default();
1177        return Some(format!("Read PDF{size}."));
1178    }
1179
1180    let size = size.map(|size| format!(", {size}")).unwrap_or_default();
1181    Some(format!("Read attachment ({mime}{size})."))
1182}
1183
1184fn format_attachment_size(bytes: u64) -> String {
1185    if bytes >= 1024 * 1024 {
1186        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
1187    } else if bytes >= 1024 {
1188        format!("{} KB", bytes.div_ceil(1024))
1189    } else {
1190        format!("{bytes} bytes")
1191    }
1192}
1193
1194fn format_read_footer(agent_specified_range: bool, data: &Value) -> String {
1195    if agent_specified_range {
1196        return String::new();
1197    }
1198    if !data
1199        .get("truncated")
1200        .and_then(Value::as_bool)
1201        .unwrap_or(false)
1202    {
1203        return String::new();
1204    }
1205    let start = data.get("start_line").and_then(Value::as_u64);
1206    let end = data.get("end_line").and_then(Value::as_u64);
1207    let total = data.get("total_lines").and_then(Value::as_u64);
1208    match (start, end, total) {
1209        (Some(start), Some(end), Some(total)) => format!(
1210            "\n(Showing lines {start}-{end} of {total}. Use startLine/endLine to read other sections.)"
1211        ),
1212        _ => String::new(),
1213    }
1214}
1215
1216// Mirrors packages/opencode-plugin/src/tools/search.ts formatGrepOutput.
1217fn format_grep(data: &Value) -> String {
1218    if let Some(text) = data.get("text").and_then(Value::as_str) {
1219        return text.to_string();
1220    }
1221
1222    let matches = data
1223        .get("matches")
1224        .and_then(Value::as_array)
1225        .cloned()
1226        .unwrap_or_default();
1227    let total_matches = data
1228        .get("total_matches")
1229        .and_then(Value::as_u64)
1230        .unwrap_or(matches.len() as u64);
1231    let files_with_matches = data
1232        .get("files_with_matches")
1233        .and_then(Value::as_u64)
1234        .unwrap_or_else(|| {
1235            matches
1236                .iter()
1237                .filter_map(|m| m.get("file").and_then(Value::as_str))
1238                .collect::<std::collections::BTreeSet<_>>()
1239                .len() as u64
1240        });
1241
1242    if matches.is_empty() {
1243        return format!("Found {total_matches} match across {files_with_matches} file");
1244    }
1245
1246    let body = matches
1247        .iter()
1248        .map(|m| {
1249            let file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
1250            let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
1251            let text = m
1252                .get("line_text")
1253                .or_else(|| m.get("text"))
1254                .and_then(Value::as_str)
1255                .unwrap_or("");
1256            format!("{file}:{line}: {text}")
1257        })
1258        .collect::<Vec<_>>()
1259        .join("\n");
1260    format!("{body}\n\nFound {total_matches} match across {files_with_matches} file")
1261}
1262
1263fn format_ast_search(data: &Value) -> String {
1264    let matches = data.get("matches").and_then(Value::as_array);
1265    let match_count = data
1266        .get("total_matches")
1267        .and_then(Value::as_u64)
1268        .unwrap_or_else(|| matches.map(|m| m.len() as u64).unwrap_or(0));
1269    let files_searched = data
1270        .get("files_searched")
1271        .and_then(Value::as_u64)
1272        .unwrap_or(0);
1273    let files_with_matches = data
1274        .get("files_with_matches")
1275        .and_then(Value::as_u64)
1276        .unwrap_or(files_searched);
1277
1278    let mut output = if data.get("no_files_matched_scope").and_then(Value::as_bool) == Some(true) {
1279        let mut output =
1280            "No files matched the scope (paths/globs resolved to zero files)".to_string();
1281        append_scope_warnings(&mut output, data);
1282        output
1283    } else if match_count == 0 {
1284        let mut output = format!("No matches found (searched {files_searched} files)");
1285        append_scope_warnings(&mut output, data);
1286        append_hint(&mut output, data);
1287        output
1288    } else {
1289        let mut output = format!(
1290            "Found {match_count} match(es) in {files_with_matches} file(s) ({files_searched} searched)\n\n"
1291        );
1292        if let Some(matches) = matches {
1293            for m in matches {
1294                let rel_file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
1295                let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
1296                output.push_str(&format!("{rel_file}:{line}\n"));
1297                if let Some(text) = m.get("text").and_then(Value::as_str) {
1298                    output.push_str(&format!("  {}\n", text.trim()));
1299                }
1300                if let Some(meta_vars) = m.get("meta_variables").and_then(Value::as_object) {
1301                    if !meta_vars.is_empty() {
1302                        for (key, value) in meta_vars {
1303                            output.push_str(&format!("  {key}: {}\n", js_template_string(value)));
1304                        }
1305                    }
1306                }
1307                output.push('\n');
1308            }
1309        }
1310        output
1311    };
1312
1313    if data.get("complete").and_then(Value::as_bool) == Some(false)
1314        || data
1315            .get("skipped_files")
1316            .and_then(Value::as_array)
1317            .is_some_and(|skipped| !skipped.is_empty())
1318    {
1319        output = append_ast_skipped_files(output, data.get("skipped_files"));
1320    }
1321    output
1322}
1323
1324fn format_ast_replace(data: &Value, dry_run: bool) -> String {
1325    let matches = data.get("matches").and_then(Value::as_array);
1326    let match_count = data
1327        .get("total_replacements")
1328        .or_else(|| data.get("total_matches"))
1329        .and_then(Value::as_u64)
1330        .unwrap_or_else(|| matches.map(|m| m.len() as u64).unwrap_or(0));
1331    let files_searched = data
1332        .get("files_searched")
1333        .or_else(|| data.get("total_files"))
1334        .and_then(Value::as_u64)
1335        .unwrap_or(0);
1336    let files_with_matches = data
1337        .get("files_with_matches")
1338        .or_else(|| data.get("total_files"))
1339        .and_then(Value::as_u64)
1340        .unwrap_or(files_searched);
1341
1342    if data.get("no_files_matched_scope").and_then(Value::as_bool) == Some(true) {
1343        let mut output =
1344            "No files matched the scope (paths/globs resolved to zero files)".to_string();
1345        append_scope_warnings(&mut output, data);
1346        return output;
1347    }
1348
1349    if match_count == 0 {
1350        let mut output = format!("No matches found (searched {files_searched} files)");
1351        append_scope_warnings(&mut output, data);
1352        append_hint(&mut output, data);
1353        return output;
1354    }
1355
1356    let mut output = if dry_run {
1357        format!(
1358            "[DRY RUN] Would replace {match_count} match(es) in {files_with_matches} file(s) ({files_searched} searched)\n\n"
1359        )
1360    } else {
1361        format!(
1362            "Replaced {match_count} match(es) in {files_with_matches} file(s) ({files_searched} searched)\n\n"
1363        )
1364    };
1365
1366    if dry_run {
1367        if let Some(files) = data.get("files").and_then(Value::as_array) {
1368            if !files.is_empty() {
1369                append_ast_replace_dry_run_files(
1370                    &mut output,
1371                    files,
1372                    match_count,
1373                    files_with_matches,
1374                );
1375            }
1376        }
1377    } else if let Some(matches) = matches {
1378        for m in matches {
1379            let rel_file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
1380            let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
1381            output.push_str(&format!("{rel_file}:{line}\n"));
1382            if let (Some(text), Some(replacement)) = (
1383                m.get("text").and_then(Value::as_str),
1384                m.get("replacement").and_then(Value::as_str),
1385            ) {
1386                output.push_str(&format!("  - {}\n", text.trim()));
1387                output.push_str(&format!("  + {}\n", replacement.trim()));
1388            }
1389            output.push('\n');
1390        }
1391    } else if let Some(files) = data.get("files").and_then(Value::as_array) {
1392        if !files.is_empty() {
1393            for f in files {
1394                let rel_file = f.get("file").and_then(Value::as_str).unwrap_or("unknown");
1395                let replacements = f.get("replacements").and_then(Value::as_u64).unwrap_or(0);
1396                let suffix = if replacements == 1 { "" } else { "s" };
1397                output.push_str(&format!(
1398                    "  {rel_file}: {replacements} replacement{suffix}\n"
1399                ));
1400            }
1401        }
1402    }
1403
1404    output
1405}
1406
1407fn append_ast_replace_dry_run_files(
1408    output: &mut String,
1409    files: &[Value],
1410    match_count: u64,
1411    files_with_matches: u64,
1412) {
1413    const MAX_DIFF_BYTES: usize = 8 * 1024;
1414    let mut used = 0usize;
1415    for (index, f) in files.iter().enumerate() {
1416        let rel_file = f.get("file").and_then(Value::as_str).unwrap_or("unknown");
1417        let replacements = f.get("replacements").and_then(Value::as_u64).unwrap_or(0);
1418        let diff = f.get("diff").and_then(Value::as_str).unwrap_or("");
1419        if used + diff.len() > MAX_DIFF_BYTES {
1420            let remaining = files.len().saturating_sub(index);
1421            if remaining > 0 {
1422                output.push_str(&format!(
1423                    "\n... ({remaining} more file(s) omitted from preview to stay under {}KB; total {match_count} replacements across {files_with_matches} files)\n",
1424                    MAX_DIFF_BYTES / 1024
1425                ));
1426            }
1427            break;
1428        }
1429        let suffix = if replacements == 1 { "" } else { "s" };
1430        output.push_str(&format!(
1431            "{rel_file} ({replacements} replacement{suffix}):\n"
1432        ));
1433        output.push_str(diff);
1434        if !diff.ends_with('\n') {
1435            output.push('\n');
1436        }
1437        output.push('\n');
1438        used += diff.len();
1439    }
1440}
1441
1442fn append_scope_warnings(output: &mut String, data: &Value) {
1443    let warnings = string_array(data.get("scope_warnings"));
1444    if !warnings.is_empty() {
1445        output.push_str("\n\nScope warnings:\n");
1446        output.push_str(
1447            &warnings
1448                .iter()
1449                .map(|warning| format!("  {warning}"))
1450                .collect::<Vec<_>>()
1451                .join("\n"),
1452        );
1453    }
1454}
1455
1456fn append_hint(output: &mut String, data: &Value) {
1457    if let Some(hint) = data
1458        .get("hint")
1459        .and_then(Value::as_str)
1460        .filter(|hint| !hint.is_empty())
1461    {
1462        output.push_str("\n\n");
1463        output.push_str(hint);
1464    }
1465}
1466
1467fn append_ast_skipped_files(output: String, skipped_files: Option<&Value>) -> String {
1468    let Some(skipped_files) = skipped_files.and_then(Value::as_array) else {
1469        return output;
1470    };
1471    if skipped_files.is_empty() {
1472        return output;
1473    }
1474    let lines = skipped_files
1475        .iter()
1476        .map(|skipped| {
1477            let file = skipped
1478                .get("file")
1479                .and_then(Value::as_str)
1480                .unwrap_or("unknown");
1481            let reason = skipped
1482                .get("reason")
1483                .and_then(Value::as_str)
1484                .unwrap_or("unknown reason");
1485            format!("  {file}: {reason}")
1486        })
1487        .collect::<Vec<_>>();
1488    format!(
1489        "{output}\n\nIncomplete: skipped {} file(s)\n{}",
1490        skipped_files.len(),
1491        lines.join("\n")
1492    )
1493}
1494
1495fn js_template_string(value: &Value) -> String {
1496    match value {
1497        Value::Null => "null".to_string(),
1498        Value::Bool(value) => value.to_string(),
1499        Value::Number(value) => value.to_string(),
1500        Value::String(value) => value.clone(),
1501        Value::Array(items) => items
1502            .iter()
1503            .map(|item| match item {
1504                Value::Null => String::new(),
1505                other => js_template_string(other),
1506            })
1507            .collect::<Vec<_>>()
1508            .join(","),
1509        Value::Object(_) => "[object Object]".to_string(),
1510    }
1511}
1512
1513// Mirrors packages/opencode-plugin/src/tools/semantic.ts semanticTools.
1514fn format_search(data: &Value) -> String {
1515    let note = extra_honesty_note(data);
1516    if let Some(text) = data
1517        .get("text")
1518        .and_then(Value::as_str)
1519        .filter(|s| !s.is_empty())
1520    {
1521        return match note {
1522            Some(n) => format!("{text}\n{n}"),
1523            None => text.to_string(),
1524        };
1525    }
1526    semantic_honesty_note(data).unwrap_or_else(|| "No results.".to_string())
1527}
1528
1529fn semantic_honesty_note(data: &Value) -> Option<String> {
1530    let mut notes = Vec::new();
1531    if data.get("more_available").and_then(Value::as_bool) == Some(true) {
1532        notes.push("more results available");
1533    }
1534    if data.get("engine_capped").and_then(Value::as_bool) == Some(true) {
1535        notes.push("enumeration capped");
1536    }
1537    if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
1538        notes.push("fully degraded");
1539    }
1540    if data.get("complete").and_then(Value::as_bool) == Some(false) {
1541        notes.push("partial/incomplete");
1542    }
1543    if notes.is_empty() {
1544        None
1545    } else {
1546        Some(format!("Search status: {}.", notes.join("; ")))
1547    }
1548}
1549
1550fn extra_honesty_note(data: &Value) -> Option<String> {
1551    let mut notes = Vec::new();
1552    if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
1553        notes.push("fully degraded");
1554    }
1555    if data.get("complete").and_then(Value::as_bool) == Some(false) {
1556        notes.push("partial/incomplete");
1557    }
1558    if notes.is_empty() {
1559        None
1560    } else {
1561        Some(format!("Search status: {}.", notes.join("; ")))
1562    }
1563}
1564
1565// Mirrors packages/opencode-plugin/src/tools/reading.ts aft_outline dispatch.
1566fn format_outline(response: &Response, mode: OutlineMode) -> String {
1567    match mode {
1568        OutlineMode::Text => format_outline_text(&response.data),
1569        OutlineMode::Files => format_outline_files_text(&response.data),
1570        OutlineMode::DirectoryJson => {
1571            serde_json::to_string_pretty(response).unwrap_or_else(|_| "{}".to_string())
1572        }
1573    }
1574}
1575
1576// Mirrors packages/opencode-plugin/src/tools/reading.ts formatOutlineFilesText.
1577fn format_outline_files_text(data: &Value) -> String {
1578    let text = format_outline_text(data);
1579    let unchecked: Vec<String> = data
1580        .get("unchecked_files")
1581        .and_then(Value::as_array)
1582        .map(|arr| {
1583            arr.iter()
1584                .filter_map(|v| v.as_str())
1585                .filter(|s| !s.is_empty())
1586                .map(str::to_string)
1587                .collect()
1588        })
1589        .unwrap_or_default();
1590
1591    let is_partial = data.get("complete").and_then(Value::as_bool) == Some(false)
1592        || data.get("walk_truncated").and_then(Value::as_bool) == Some(true)
1593        || !unchecked.is_empty();
1594
1595    if !is_partial {
1596        return text;
1597    }
1598
1599    let mut footer = Vec::new();
1600    if data.get("walk_truncated").and_then(Value::as_bool) == Some(true) {
1601        let suffix = if !unchecked.is_empty() {
1602            format!(
1603                " {} additional files in this directory were not indexed.",
1604                unchecked.len()
1605            )
1606        } else {
1607            " Some files in this directory were not indexed.".to_string()
1608        };
1609        footer.push(format!(
1610            "⚠ Partial result: walk truncated at 200 files.{suffix}"
1611        ));
1612    } else {
1613        let suffix = if !unchecked.is_empty() {
1614            format!(
1615                " {} files in this directory were not indexed.",
1616                unchecked.len()
1617            )
1618        } else {
1619            " Some files in this directory were not indexed.".to_string()
1620        };
1621        footer.push(format!("⚠ Partial result:{suffix}"));
1622    }
1623
1624    if !unchecked.is_empty() {
1625        footer.push("Unchecked files:".to_string());
1626        for file in unchecked.iter().take(MAX_UNCHECKED_FILES_IN_FOOTER) {
1627            footer.push(format!("  {file}"));
1628        }
1629        let remaining = unchecked
1630            .len()
1631            .saturating_sub(MAX_UNCHECKED_FILES_IN_FOOTER);
1632        if remaining > 0 {
1633            footer.push(format!("  ... +{remaining} more"));
1634        }
1635    }
1636
1637    if text.is_empty() {
1638        footer.join("\n")
1639    } else {
1640        format!("{text}\n\n{}", footer.join("\n"))
1641    }
1642}
1643
1644fn format_outline_text(data: &Value) -> String {
1645    let text = data.get("text").and_then(Value::as_str).unwrap_or("");
1646    let skipped = data.get("skipped_files").and_then(Value::as_array);
1647    let Some(skipped) = skipped.filter(|s| !s.is_empty()) else {
1648        return text.to_string();
1649    };
1650
1651    let lines: Vec<String> = skipped
1652        .iter()
1653        .filter_map(|item| {
1654            let obj = item.as_object()?;
1655            let file = obj.get("file").and_then(Value::as_str)?;
1656            let reason = obj
1657                .get("reason")
1658                .and_then(Value::as_str)
1659                .unwrap_or("skipped");
1660            Some(format!("  {file} — {reason}"))
1661        })
1662        .collect();
1663    if lines.is_empty() {
1664        return text.to_string();
1665    }
1666    let header = if text.is_empty() { "" } else { "\n\n" };
1667    format!(
1668        "{text}{header}Skipped {} file(s):\n{}",
1669        lines.len(),
1670        lines.join("\n")
1671    )
1672}
1673
1674// Format zoom responses as plain text so direct calls and server-side calls
1675// produce identical output.
1676fn format_zoom(data: &Value, ctx: &FormatContext) -> String {
1677    if let Some(entries) = data.get("targets").and_then(Value::as_array) {
1678        return format_zoom_multi_target_result(entries);
1679    }
1680
1681    let target_label = ctx.zoom_target_label.as_deref().unwrap_or("(no target)");
1682    if let Some((names, responses)) = unwrap_rust_zoom_batch_envelope(data) {
1683        return format_zoom_batch_result(target_label, &names, &responses);
1684    }
1685    format_zoom_text(target_label, data)
1686}
1687
1688fn format_zoom_multi_target_result(entries: &[Value]) -> String {
1689    let rendered = entries
1690        .iter()
1691        .map(|entry| {
1692            let target_label = entry
1693                .get("targetLabel")
1694                .and_then(Value::as_str)
1695                .filter(|label| !label.is_empty())
1696                .unwrap_or("(no target)");
1697            let name = entry.get("name").and_then(Value::as_str).unwrap_or("");
1698            let response = entry.get("response");
1699            if response
1700                .and_then(|response| response.get("success"))
1701                .and_then(Value::as_bool)
1702                == Some(false)
1703            {
1704                let message = response
1705                    .and_then(|response| response.get("message"))
1706                    .and_then(Value::as_str)
1707                    .filter(|message| !message.is_empty())
1708                    .unwrap_or("zoom failed");
1709                return (
1710                    false,
1711                    format!("Symbol \"{name}\" not found in {target_label}: {message}"),
1712                );
1713            }
1714            match response {
1715                Some(response) => (true, format_zoom_text(target_label, response)),
1716                None => (
1717                    false,
1718                    format!("Symbol \"{name}\" not found in {target_label}: missing zoom response"),
1719                ),
1720            }
1721        })
1722        .collect::<Vec<_>>();
1723
1724    let complete = rendered.iter().all(|(success, _)| *success);
1725    let mut sections = Vec::new();
1726    if !complete {
1727        sections.push("Incomplete zoom results: one or more symbols failed.".to_string());
1728    }
1729    sections.extend(rendered.into_iter().map(|(_, content)| content));
1730    sections.join("\n\n")
1731}
1732
1733fn unwrap_rust_zoom_batch_envelope(data: &Value) -> Option<(Vec<String>, Vec<Value>)> {
1734    let symbols = data.get("symbols")?.as_array()?;
1735    if symbols.is_empty() {
1736        return None;
1737    }
1738
1739    let mut names = Vec::with_capacity(symbols.len());
1740    let mut responses = Vec::with_capacity(symbols.len());
1741    for entry in symbols {
1742        let row = entry.as_object()?;
1743        let name = row.get("name")?.as_str()?;
1744        let response = row.get("response")?;
1745        if response.is_null() {
1746            return None;
1747        }
1748        names.push(name.to_string());
1749        responses.push(response.clone());
1750    }
1751    Some((names, responses))
1752}
1753
1754fn format_zoom_batch_result(target_label: &str, symbols: &[String], responses: &[Value]) -> String {
1755    let entries = symbols
1756        .iter()
1757        .enumerate()
1758        .map(|(index, name)| {
1759            let response = responses.get(index);
1760            if response
1761                .and_then(|r| r.get("success"))
1762                .and_then(Value::as_bool)
1763                == Some(false)
1764            {
1765                let message = response
1766                    .and_then(|r| r.get("message"))
1767                    .and_then(Value::as_str)
1768                    .filter(|message| !message.is_empty())
1769                    .unwrap_or("zoom failed");
1770                return (false, format!("Symbol \"{name}\" not found: {message}"));
1771            }
1772            match response {
1773                Some(response) => (true, format_zoom_text(target_label, response)),
1774                None => (
1775                    false,
1776                    format!("Symbol \"{name}\" not found: missing zoom response"),
1777                ),
1778            }
1779        })
1780        .collect::<Vec<_>>();
1781
1782    let complete = entries.iter().all(|(success, _)| *success);
1783    let mut sections = Vec::new();
1784    if !complete {
1785        sections.push("Incomplete zoom results: one or more symbols failed.".to_string());
1786    }
1787    sections.extend(entries.into_iter().map(|(_, content)| content));
1788    sections.join("\n\n")
1789}
1790
1791fn format_zoom_text(target_label: &str, response: &Value) -> String {
1792    let range = response.get("range");
1793    let start_line = range
1794        .and_then(|range| range.get("start_line"))
1795        .and_then(Value::as_i64)
1796        .unwrap_or(1);
1797    let end_line = range
1798        .and_then(|range| range.get("end_line"))
1799        .and_then(Value::as_i64)
1800        .unwrap_or(start_line);
1801    let kind = response
1802        .get("kind")
1803        .and_then(Value::as_str)
1804        .unwrap_or("symbol");
1805    let name = response.get("name").and_then(Value::as_str).unwrap_or("");
1806    let content_text = response
1807        .get("content")
1808        .and_then(Value::as_str)
1809        .unwrap_or("");
1810    let context_before = string_array(response.get("context_before"));
1811    let context_after = string_array(response.get("context_after"));
1812
1813    let header = if kind == "lines" {
1814        format!("{target_label}:{start_line}-{end_line}")
1815    } else {
1816        format!("{target_label}:{start_line}-{end_line} [{kind} {name}]")
1817            .trim_end()
1818            .to_string()
1819    };
1820
1821    let mut content_lines = content_text.split('\n').collect::<Vec<_>>();
1822    if content_lines.last() == Some(&"") {
1823        content_lines.pop();
1824    }
1825
1826    let last_displayed_line = end_line + context_after.len() as i64;
1827    let gutter_width = last_displayed_line.max(1).to_string().len();
1828    let mut out = vec![header, String::new()];
1829
1830    let mut line_no = start_line - context_before.len() as i64;
1831    for text in &context_before {
1832        out.push(format_zoom_line(line_no, gutter_width, text));
1833        line_no += 1;
1834    }
1835    for text in content_lines {
1836        out.push(format_zoom_line(line_no, gutter_width, text));
1837        line_no += 1;
1838    }
1839    for text in &context_after {
1840        out.push(format_zoom_line(line_no, gutter_width, text));
1841        line_no += 1;
1842    }
1843
1844    let annotations = response.get("annotations");
1845    let calls_out = annotations
1846        .and_then(|annotations| annotations.get("calls_out"))
1847        .and_then(Value::as_array);
1848    if let Some(calls_out) = calls_out.filter(|calls| !calls.is_empty()) {
1849        out.push(String::new());
1850        out.push("──── calls_out".to_string());
1851        for call in calls_out {
1852            out.push(format_zoom_call_ref(call));
1853        }
1854    }
1855
1856    let called_by = annotations
1857        .and_then(|annotations| annotations.get("called_by"))
1858        .and_then(Value::as_array);
1859    if let Some(called_by) = called_by.filter(|calls| !calls.is_empty()) {
1860        out.push(String::new());
1861        out.push("──── called_by".to_string());
1862        for call in called_by {
1863            out.push(format_zoom_call_ref(call));
1864        }
1865    }
1866
1867    out.join("\n")
1868}
1869
1870fn format_zoom_line(line_no: i64, gutter_width: usize, text: &str) -> String {
1871    format!("{line_no:>gutter_width$}: {text}")
1872}
1873
1874fn format_zoom_call_ref(call: &Value) -> String {
1875    let name = call.get("name").and_then(Value::as_str).unwrap_or("");
1876    let line = call.get("line").and_then(Value::as_i64).unwrap_or(0);
1877    let extra = call
1878        .get("extra_count")
1879        .and_then(Value::as_i64)
1880        .filter(|count| *count > 0)
1881        .map(|count| format!(" +{count}"))
1882        .unwrap_or_default();
1883    format!("  {name} (line {line}){extra}")
1884}
1885
1886// Mirrors packages/opencode-plugin/src/tools/inspect.ts inspectTools.
1887fn format_inspect(response: &Response) -> String {
1888    if let Some(text) = response.data.get("text").and_then(Value::as_str) {
1889        return append_rendered_diagnostics(text, &response.data);
1890    }
1891    let json = serde_json::to_string_pretty(response).unwrap_or_else(|_| "{}".to_string());
1892    append_rendered_diagnostics(&json, &response.data)
1893}
1894
1895// Mirrors packages/opencode-plugin/src/tools/inspect.ts appendRenderedDiagnostics.
1896fn append_rendered_diagnostics(text: &str, data: &Value) -> String {
1897    if text.lines().any(|line| {
1898        let lower = line.to_lowercase();
1899        lower.starts_with("diagnostics:") || lower.starts_with("diagnostics ")
1900    }) {
1901        return text.to_string();
1902    }
1903    let diagnostics = render_inspect_diagnostics(data);
1904    if diagnostics.is_empty() {
1905        return text.to_string();
1906    }
1907    if text.is_empty() {
1908        diagnostics
1909    } else {
1910        format!("{text}\n\n{diagnostics}")
1911    }
1912}
1913
1914fn render_inspect_diagnostics(data: &Value) -> String {
1915    let mut lines = Vec::new();
1916    if let Some(summary_line) = format_diagnostics_summary(data.get("summary")) {
1917        lines.push(summary_line);
1918    }
1919
1920    let detail_lines = format_diagnostics_details(data.get("details"));
1921    if !detail_lines.is_empty() {
1922        lines.push("diagnostics details:".to_string());
1923        for line in detail_lines {
1924            lines.push(format!("- {line}"));
1925        }
1926    }
1927
1928    lines.join("\n")
1929}
1930
1931fn format_diagnostics_summary(summary: Option<&Value>) -> Option<String> {
1932    let section = summary?.get("diagnostics")?.as_object()?;
1933    let errors = section.get("errors").and_then(Value::as_u64);
1934    let warnings = section.get("warnings").and_then(Value::as_u64);
1935    let info = section.get("info").and_then(Value::as_u64);
1936    let hints = section.get("hints").and_then(Value::as_u64);
1937    let has_counts = [errors, warnings, info, hints].iter().any(|v| v.is_some());
1938    let counts = format!(
1939        "{} errors, {} warnings, {} info, {} hints",
1940        errors.unwrap_or(0),
1941        warnings.unwrap_or(0),
1942        info.unwrap_or(0),
1943        hints.unwrap_or(0)
1944    );
1945    let status = section.get("status").and_then(Value::as_str);
1946
1947    match status {
1948        Some("pending") => {
1949            if has_counts {
1950                Some(format!(
1951                    "diagnostics: {counts} so far — still pending (servers: {})",
1952                    diagnostics_server_summary(section)
1953                ))
1954            } else {
1955                Some(format!(
1956                    "diagnostics: pending (servers: {})",
1957                    diagnostics_server_summary(section)
1958                ))
1959            }
1960        }
1961        Some("incomplete") => {
1962            if has_counts {
1963                Some(format!(
1964                    "diagnostics: {counts} (incomplete — servers: {})",
1965                    diagnostics_server_summary(section)
1966                ))
1967            } else {
1968                Some(format!(
1969                    "diagnostics: unavailable (status incomplete; servers: {})",
1970                    diagnostics_server_summary(section)
1971                ))
1972            }
1973        }
1974        _ => {
1975            if has_counts {
1976                Some(format!("diagnostics: {counts}"))
1977            } else {
1978                None
1979            }
1980        }
1981    }
1982}
1983
1984fn diagnostics_server_summary(section: &serde_json::Map<String, Value>) -> String {
1985    let pending = string_array(section.get("servers_pending"));
1986    let not_installed = string_array(section.get("servers_not_installed"));
1987    let mut parts = Vec::new();
1988    if !pending.is_empty() {
1989        parts.push(format!("pending: {}", pending.join(", ")));
1990    }
1991    if !not_installed.is_empty() {
1992        parts.push(format!("not installed: {}", not_installed.join(", ")));
1993    }
1994    if parts.is_empty() {
1995        "none reported".to_string()
1996    } else {
1997        parts.join("; ")
1998    }
1999}
2000
2001fn string_array(value: Option<&Value>) -> Vec<String> {
2002    value
2003        .and_then(Value::as_array)
2004        .map(|arr| {
2005            arr.iter()
2006                .filter_map(|v| v.as_str().map(str::to_string))
2007                .collect()
2008        })
2009        .unwrap_or_default()
2010}
2011
2012fn format_diagnostics_details(details: Option<&Value>) -> Vec<String> {
2013    let Some(details) = details.and_then(Value::as_object) else {
2014        return Vec::new();
2015    };
2016    let Some(diagnostics) = details.get("diagnostics").and_then(Value::as_array) else {
2017        return Vec::new();
2018    };
2019    diagnostics
2020        .iter()
2021        .filter_map(|item| {
2022            let d = item.as_object()?;
2023            let severity = d
2024                .get("severity")
2025                .and_then(Value::as_str)
2026                .unwrap_or("information");
2027            let message = d
2028                .get("message")
2029                .and_then(Value::as_str)
2030                .unwrap_or("(no message)");
2031            let source = d.get("source").and_then(Value::as_str);
2032            let suffix = source.map(|s| format!(" [{s}]")).unwrap_or_default();
2033            Some(format!(
2034                "{} {} {}{}",
2035                format_diagnostic_location(d),
2036                severity,
2037                message,
2038                suffix
2039            ))
2040        })
2041        .collect()
2042}
2043
2044fn format_diagnostic_location(d: &serde_json::Map<String, Value>) -> String {
2045    let file = d
2046        .get("file")
2047        .and_then(Value::as_str)
2048        .unwrap_or("(unknown file)");
2049    let line = d.get("line").and_then(Value::as_u64);
2050    let column = d.get("column").and_then(Value::as_u64);
2051    match (line, column) {
2052        (None, _) => file.to_string(),
2053        (Some(line), None) => format!("{file}:{line}"),
2054        (Some(line), Some(col)) => format!("{file}:{line}:{col}"),
2055    }
2056}
2057
2058const UNRESOLVED_SUMMARY_NAME_LIMIT: usize = 10;
2059
2060pub fn format_callgraph(op: &str, response_data: &Value, include_unresolved: bool) -> String {
2061    let Some(record) = response_data.as_object() else {
2062        return "No navigation result.".to_string();
2063    };
2064
2065    let sections = match op {
2066        "call_tree" => format_call_tree_sections(record, include_unresolved),
2067        "callers" => format_callers_sections(record),
2068        "trace_to_symbol" => format_trace_to_symbol_sections(record),
2069        "trace_to" => format_trace_to_sections(record),
2070        "impact" => format_impact_sections(record),
2071        _ => format_trace_data_sections(record),
2072    };
2073    sections.join("\n")
2074}
2075
2076fn format_callgraph_error(command: &str, data: &Value) -> String {
2077    let code = data
2078        .get("code")
2079        .and_then(Value::as_str)
2080        .filter(|s| !s.is_empty());
2081    let message = data
2082        .get("message")
2083        .and_then(Value::as_str)
2084        .filter(|s| !s.is_empty())
2085        .unwrap_or("callgraph failed");
2086
2087    if matches!(
2088        code,
2089        Some("ambiguous_target") | Some("target_symbol_not_in_file")
2090    ) {
2091        let candidates = callgraph_candidates(data);
2092        if !candidates.is_empty() {
2093            let symbol =
2094                callgraph_error_symbol(data).or_else(|| symbol_from_callgraph_message(message));
2095            let target = symbol
2096                .map(|symbol| format!("multiple symbols named \"{symbol}\""))
2097                .unwrap_or_else(|| strip_terminal_punctuation(message));
2098            let action = if code == Some("ambiguous_target") {
2099                "Pass toFile to disambiguate"
2100            } else {
2101                "Try one of these files for toFile"
2102            };
2103            let mut lines = vec![format!(
2104                "{command}: {} — {target}. {action}:",
2105                code.unwrap_or_default()
2106            )];
2107            lines.extend(
2108                candidates
2109                    .into_iter()
2110                    .map(|candidate| format!("  - {candidate}")),
2111            );
2112            return lines.join("\n");
2113        }
2114    }
2115
2116    let Some(code) = code else {
2117        return message.to_string();
2118    };
2119    let mut lines = vec![format!("{command}: {code} — {message}")];
2120    if let Some(extras) = collect_callgraph_error_extras(data) {
2121        lines.push(format!("data: {extras}"));
2122    }
2123    lines.join("\n")
2124}
2125
2126fn callgraph_candidates(data: &Value) -> Vec<String> {
2127    data.get("candidates")
2128        .and_then(Value::as_array)
2129        .or_else(|| {
2130            data.get("data")
2131                .and_then(Value::as_object)
2132                .and_then(|nested| nested.get("candidates"))
2133                .and_then(Value::as_array)
2134        })
2135        .map(|items| {
2136            items
2137                .iter()
2138                .filter_map(|candidate| {
2139                    let candidate = candidate.as_object()?;
2140                    let file = string_field(candidate, "file")?;
2141                    let line = number_field(candidate, "line");
2142                    Some(match line {
2143                        Some(line) => format!("{file}:{line}"),
2144                        None => file.to_string(),
2145                    })
2146                })
2147                .collect()
2148        })
2149        .unwrap_or_default()
2150}
2151
2152fn callgraph_error_symbol(data: &Value) -> Option<String> {
2153    data.get("symbol")
2154        .and_then(Value::as_str)
2155        .filter(|s| !s.is_empty())
2156        .or_else(|| {
2157            data.get("data")
2158                .and_then(Value::as_object)
2159                .and_then(|nested| nested.get("symbol"))
2160                .and_then(Value::as_str)
2161                .filter(|s| !s.is_empty())
2162        })
2163        .map(str::to_string)
2164}
2165
2166fn symbol_from_callgraph_message(message: &str) -> Option<String> {
2167    extract_between(message, "target symbol '", "'")
2168        .or_else(|| extract_between(message, "multiple symbols named \"", "\""))
2169}
2170
2171fn extract_between(message: &str, prefix: &str, suffix: &str) -> Option<String> {
2172    let start = message.find(prefix)? + prefix.len();
2173    let rest = &message[start..];
2174    let end = rest.find(suffix)?;
2175    let value = &rest[..end];
2176    (!value.is_empty()).then(|| value.to_string())
2177}
2178
2179fn strip_terminal_punctuation(message: &str) -> String {
2180    message.trim_end_matches(['.', '!', '?']).to_string()
2181}
2182
2183fn collect_callgraph_error_extras(data: &Value) -> Option<String> {
2184    let obj = data.as_object()?;
2185    let mut extras = serde_json::Map::new();
2186    for (key, value) in obj {
2187        if matches!(
2188            key.as_str(),
2189            "id" | "success" | "code" | "message" | "data" | "status_bar" | "bg_completions"
2190        ) {
2191            continue;
2192        }
2193        extras.insert(key.clone(), value.clone());
2194    }
2195    if extras.is_empty() {
2196        data.get("data").map(stringify_json_pretty)
2197    } else {
2198        if let Some(nested) = data.get("data") {
2199            extras.insert("data".to_string(), nested.clone());
2200        }
2201        Some(stringify_json_pretty(&Value::Object(extras)))
2202    }
2203}
2204
2205fn stringify_json_pretty(value: &Value) -> String {
2206    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
2207}
2208
2209fn format_call_tree_sections(
2210    record: &serde_json::Map<String, Value>,
2211    include_unresolved: bool,
2212) -> Vec<String> {
2213    let mut lines = Vec::new();
2214    render_call_tree_node(record, 0, &mut lines, include_unresolved);
2215    let warning = depth_warning(record, "depth_limited", "truncated");
2216    if !warning.is_empty() {
2217        lines.push(warning);
2218    }
2219    if lines.is_empty() {
2220        vec!["No call tree available.".to_string()]
2221    } else {
2222        lines
2223    }
2224}
2225
2226fn render_call_tree_node(
2227    node: &serde_json::Map<String, Value>,
2228    depth: usize,
2229    lines: &mut Vec<String>,
2230    include_unresolved: bool,
2231) {
2232    let name = string_field(node, "name").unwrap_or("(unknown)");
2233    let file = shorten_path(string_field(node, "file").unwrap_or("(unknown file)"));
2234    let line = number_field(node, "line");
2235    let unresolved = if node.get("resolved").and_then(Value::as_bool) == Some(false) {
2236        " [unresolved]"
2237    } else {
2238        ""
2239    };
2240    let name_match = name_match_edge_marker(node);
2241    let location = match line {
2242        Some(line) => format!("[{file}:{line}]"),
2243        None => format!("[{file}]"),
2244    };
2245    lines.push(tree_line(
2246        depth,
2247        &format!("{name} {location}{unresolved}{name_match}"),
2248    ));
2249
2250    let children = records_field(node, "children");
2251    if include_unresolved {
2252        for child in children {
2253            render_call_tree_node(child, depth + 1, lines, include_unresolved);
2254        }
2255        return;
2256    }
2257
2258    let unresolved_indices = children
2259        .iter()
2260        .enumerate()
2261        .filter_map(|(index, child)| is_unresolved_leaf(child).then_some(index))
2262        .collect::<Vec<_>>();
2263    if unresolved_indices.is_empty() {
2264        for child in children {
2265            render_call_tree_node(child, depth + 1, lines, include_unresolved);
2266        }
2267        return;
2268    }
2269
2270    let mut summary_inserted = false;
2271    for (index, child) in children.iter().enumerate() {
2272        if unresolved_indices.contains(&index) {
2273            if !summary_inserted {
2274                let unresolved_leaves = unresolved_indices
2275                    .iter()
2276                    .filter_map(|idx| children.get(*idx).copied())
2277                    .collect::<Vec<_>>();
2278                lines.push(tree_line(
2279                    depth + 1,
2280                    &unresolved_summary_text(&unresolved_leaves),
2281                ));
2282                summary_inserted = true;
2283            }
2284            continue;
2285        }
2286        render_call_tree_node(child, depth + 1, lines, include_unresolved);
2287    }
2288}
2289
2290fn is_unresolved_leaf(node: &serde_json::Map<String, Value>) -> bool {
2291    node.get("resolved").and_then(Value::as_bool) == Some(false)
2292        && records_field(node, "children").is_empty()
2293}
2294
2295fn unresolved_summary_text(nodes: &[&serde_json::Map<String, Value>]) -> String {
2296    let mut distinct_names = Vec::new();
2297    for node in nodes {
2298        let name = string_field(node, "name").unwrap_or("(unknown)");
2299        if !distinct_names.iter().any(|seen| seen == name) {
2300            distinct_names.push(name.to_string());
2301        }
2302    }
2303
2304    let displayed = distinct_names
2305        .iter()
2306        .take(UNRESOLVED_SUMMARY_NAME_LIMIT)
2307        .cloned()
2308        .collect::<Vec<_>>();
2309    let hidden = distinct_names.len().saturating_sub(displayed.len());
2310    let names = if hidden > 0 {
2311        format!("{}, … (+{hidden} more)", displayed.join(", "))
2312    } else {
2313        displayed.join(", ")
2314    };
2315    let noun = if nodes.len() == 1 { "call" } else { "calls" };
2316    format!("+ {} unresolved external {noun}: {names}", nodes.len())
2317}
2318
2319fn format_callers_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2320    let groups = records_field(record, "callers");
2321    let warning = depth_warning(record, "depth_limited", "truncated");
2322    let hub_summary = hub_summary_line(record);
2323    let total = number_field(record, "total_callers").unwrap_or(0);
2324    let mut sections = vec![join_non_empty(&[
2325        Some(format!(
2326            "{total} caller{}",
2327            if total == 1 { "" } else { "s" }
2328        )),
2329        Some(format!(
2330            "{} file group{}",
2331            groups.len(),
2332            if groups.len() == 1 { "" } else { "s" }
2333        )),
2334        (!warning.is_empty()).then_some(warning),
2335    ])];
2336    if let Some(summary) = hub_summary {
2337        sections.push(summary);
2338    }
2339    for group in groups {
2340        sections.push(render_callers_group_lines(group).join("\n"));
2341    }
2342    sections
2343}
2344
2345fn render_callers_group_lines(group: &serde_json::Map<String, Value>) -> Vec<String> {
2346    let file = shorten_path(string_field(group, "file").unwrap_or("(unknown file)"));
2347    let mut lines = vec![file];
2348    let callers = records_field(group, "callers");
2349    let mut by_symbol_provenance: BTreeMap<String, Vec<i64>> = BTreeMap::new();
2350    for caller in callers {
2351        let symbol = string_field(caller, "symbol").unwrap_or("(unknown)");
2352        let provenance = if string_field(caller, "resolved_by") == Some("name_match") {
2353            "name_match"
2354        } else {
2355            "exact"
2356        };
2357        let key = format!("{symbol}\0{provenance}");
2358        let bucket = by_symbol_provenance.entry(key).or_default();
2359        if let Some(line) = number_field(caller, "line") {
2360            bucket.push(line);
2361        }
2362    }
2363    for (key, mut line_nums) in by_symbol_provenance {
2364        let symbol = key.split('\0').next().unwrap_or("(unknown)");
2365        let is_name_match = key.ends_with("\0name_match");
2366        line_nums.sort_unstable();
2367        let line_part = if line_nums.is_empty() {
2368            "?".to_string()
2369        } else {
2370            line_nums
2371                .iter()
2372                .map(ToString::to_string)
2373                .collect::<Vec<_>>()
2374                .join(", ")
2375        };
2376        let marker = if is_name_match { " ~" } else { "" };
2377        lines.push(format!("  ↳ {symbol}:{line_part}{marker}"));
2378    }
2379    lines
2380}
2381
2382fn format_trace_to_symbol_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2383    let path = records_field(record, "path");
2384    let complete = record.get("complete").and_then(Value::as_bool);
2385    let reason = string_field(record, "reason");
2386    if path.is_empty() {
2387        let prefix = if complete == Some(false) {
2388            "No complete path"
2389        } else {
2390            "No path"
2391        };
2392        return vec![match reason {
2393            Some(reason) => format!("{prefix} ({reason})"),
2394            None => prefix.to_string(),
2395        }];
2396    }
2397
2398    let mut lines = vec![format!(
2399        "{} hop{}",
2400        path.len(),
2401        if path.len() == 1 { "" } else { "s" }
2402    )];
2403    for (index, hop) in path.iter().enumerate() {
2404        let symbol = string_field(hop, "symbol").unwrap_or("(unknown)");
2405        let file = shorten_path(string_field(hop, "file").unwrap_or("(unknown file)"));
2406        let line = number_field(hop, "line");
2407        let name_match = name_match_edge_marker(hop);
2408        let location = match line {
2409            Some(line) => format!("[{file}:{line}]"),
2410            None => format!("[{file}]"),
2411        };
2412        lines.push(tree_line(
2413            index + 1,
2414            &format!("{symbol} {location}{name_match}"),
2415        ));
2416    }
2417    lines
2418}
2419
2420fn format_trace_to_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2421    let paths = records_field(record, "paths");
2422    let warning = depth_warning(record, "max_depth_reached", "truncated_paths");
2423    let hub_summary = hub_summary_line(record);
2424    let total_paths = number_field(record, "total_paths").unwrap_or(paths.len() as i64);
2425    let entry_points = number_field(record, "entry_points_found").unwrap_or(0);
2426    let mut sections = vec![join_non_empty(&[
2427        Some(format!(
2428            "{total_paths} path{}",
2429            if total_paths == 1 { "" } else { "s" }
2430        )),
2431        Some(format!(
2432            "{entry_points} entry point{}",
2433            if entry_points == 1 { "" } else { "s" }
2434        )),
2435        (!warning.is_empty()).then_some(warning),
2436    ])];
2437    if let Some(summary) = hub_summary {
2438        sections.push(summary);
2439    }
2440    if paths.is_empty() {
2441        sections.push("No entry paths found.".to_string());
2442    }
2443    for (index, path) in paths.iter().enumerate() {
2444        let mut lines = Vec::new();
2445        render_trace_path(path, index, &mut lines);
2446        sections.push(lines.join("\n"));
2447    }
2448    sections
2449}
2450
2451fn render_trace_path(path: &serde_json::Map<String, Value>, index: usize, lines: &mut Vec<String>) {
2452    lines.push(format!("Path {}", index + 1));
2453    for (hop_index, hop) in records_field(path, "hops").iter().enumerate() {
2454        let symbol = string_field(hop, "symbol").unwrap_or("(unknown)");
2455        let file = shorten_path(string_field(hop, "file").unwrap_or("(unknown file)"));
2456        let line = number_field(hop, "line");
2457        let entry = if hop.get("is_entry_point").and_then(Value::as_bool) == Some(true) {
2458            " [entry]"
2459        } else {
2460            ""
2461        };
2462        let name_match = name_match_edge_marker(hop);
2463        let location = match line {
2464            Some(line) => format!("[{file}:{line}]"),
2465            None => format!("[{file}]"),
2466        };
2467        lines.push(tree_line(
2468            hop_index + 1,
2469            &format!("{symbol}{entry} {location}{name_match}"),
2470        ));
2471    }
2472}
2473
2474fn format_impact_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2475    let callers = records_field(record, "callers");
2476    let warning = depth_warning(record, "depth_limited", "truncated");
2477    let hub_summary = hub_summary_line(record);
2478    let total_affected = number_field(record, "total_affected").unwrap_or(callers.len() as i64);
2479    let affected_files = number_field(record, "affected_files").unwrap_or(0);
2480    let mut sections = vec![join_non_empty(&[
2481        Some(format!(
2482            "{total_affected} affected call site{}",
2483            if total_affected == 1 { "" } else { "s" }
2484        )),
2485        Some(format!(
2486            "{affected_files} file{}",
2487            if affected_files == 1 { "" } else { "s" }
2488        )),
2489        (!warning.is_empty()).then_some(warning),
2490    ])];
2491    if let Some(summary) = hub_summary {
2492        sections.push(summary);
2493    }
2494    if callers.is_empty() {
2495        sections.push("No impacted callers found.".to_string());
2496    }
2497    for caller in callers {
2498        let file = shorten_path(string_field(caller, "caller_file").unwrap_or("(unknown file)"));
2499        let symbol = string_field(caller, "caller_symbol").unwrap_or("(unknown)");
2500        let line = number_field(caller, "line").unwrap_or(0);
2501        let entry = if caller.get("is_entry_point").and_then(Value::as_bool) == Some(true) {
2502            " [entry]"
2503        } else {
2504            ""
2505        };
2506        let name_match = name_match_edge_marker(caller);
2507        let expression = string_field(caller, "call_expression");
2508        let params = caller
2509            .get("parameters")
2510            .and_then(Value::as_array)
2511            .map(|items| {
2512                items
2513                    .iter()
2514                    .map(value_to_plain_string)
2515                    .collect::<Vec<_>>()
2516                    .join(", ")
2517            })
2518            .unwrap_or_default();
2519        let mut lines = vec![
2520            format!("{file}:{line}"),
2521            format!("  ↳ {symbol}{entry}{name_match}"),
2522        ];
2523        if let Some(expression) = expression {
2524            lines.push(format!("  {expression}"));
2525        }
2526        if !params.is_empty() {
2527            lines.push(format!("  params: {params}"));
2528        }
2529        sections.push(lines.join("\n"));
2530    }
2531    sections
2532}
2533
2534fn format_trace_data_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2535    let hops = records_field(record, "hops");
2536    let mut sections = vec![join_non_empty(&[
2537        Some(format!(
2538            "{} hop{}",
2539            hops.len(),
2540            if hops.len() == 1 { "" } else { "s" }
2541        )),
2542        (record.get("depth_limited").and_then(Value::as_bool) == Some(true))
2543            .then_some("(depth limited)".to_string()),
2544    ])];
2545    if hops.is_empty() {
2546        sections.push("No data-flow hops found.".to_string());
2547    }
2548    for (index, hop) in hops.iter().enumerate() {
2549        let file = shorten_path(string_field(hop, "file").unwrap_or("(unknown file)"));
2550        let symbol = string_field(hop, "symbol").unwrap_or("(unknown)");
2551        let variable = string_field(hop, "variable").unwrap_or("(unknown)");
2552        let line = number_field(hop, "line").unwrap_or(0);
2553        let approximate = if hop.get("approximate").and_then(Value::as_bool) == Some(true) {
2554            " [approx]"
2555        } else {
2556            ""
2557        };
2558        let name_match = name_match_edge_marker(hop);
2559        let flow_type = string_field(hop, "flow_type").unwrap_or("flow");
2560        sections.push(tree_line(
2561            index,
2562            &format!("{variable} {flow_type} {symbol} [{file}:{line}]{approximate}{name_match}"),
2563        ));
2564    }
2565    sections
2566}
2567
2568fn records_field<'a>(
2569    record: &'a serde_json::Map<String, Value>,
2570    key: &str,
2571) -> Vec<&'a serde_json::Map<String, Value>> {
2572    record
2573        .get(key)
2574        .and_then(Value::as_array)
2575        .map(|items| items.iter().filter_map(Value::as_object).collect())
2576        .unwrap_or_default()
2577}
2578
2579fn string_field<'a>(record: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a str> {
2580    record.get(key).and_then(Value::as_str)
2581}
2582
2583fn number_field(record: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
2584    let value = record.get(key)?;
2585    value
2586        .as_i64()
2587        .or_else(|| value.as_u64().and_then(|n| i64::try_from(n).ok()))
2588}
2589
2590fn shorten_path(path: &str) -> String {
2591    let Some(home) = home_dir() else {
2592        return path.to_string();
2593    };
2594    let home = home.to_string_lossy().to_string();
2595    if path.starts_with(&home) {
2596        format!("~{}", &path[home.len()..])
2597    } else {
2598        path.to_string()
2599    }
2600}
2601
2602fn home_dir() -> Option<PathBuf> {
2603    std::env::var_os("HOME")
2604        .or_else(|| std::env::var_os("USERPROFILE"))
2605        .map(PathBuf::from)
2606}
2607
2608fn tree_line(depth: usize, text: &str) -> String {
2609    format!(
2610        "{}{}{}",
2611        "  ".repeat(depth),
2612        if depth == 0 { "" } else { "↳ " },
2613        text
2614    )
2615}
2616
2617fn name_match_edge_marker(record: &serde_json::Map<String, Value>) -> &'static str {
2618    if string_field(record, "resolved_by") == Some("name_match") {
2619        " ~"
2620    } else {
2621        ""
2622    }
2623}
2624
2625fn depth_warning(
2626    response: &serde_json::Map<String, Value>,
2627    depth_field: &str,
2628    truncated_field: &str,
2629) -> String {
2630    let limited = response.get(depth_field).and_then(Value::as_bool);
2631    let truncated = number_field(response, truncated_field).unwrap_or(0);
2632    if limited != Some(true) && truncated == 0 {
2633        return String::new();
2634    }
2635    let detail = if truncated > 0 {
2636        format!(", {truncated} truncated")
2637    } else {
2638        String::new()
2639    };
2640    format!("(depth limited{detail})")
2641}
2642
2643fn hub_summary_line(response: &serde_json::Map<String, Value>) -> Option<String> {
2644    response
2645        .get("hub_summary")
2646        .and_then(Value::as_object)
2647        .and_then(|summary| string_field(summary, "message"))
2648        .map(str::to_string)
2649}
2650
2651fn join_non_empty(parts: &[Option<String>]) -> String {
2652    parts
2653        .iter()
2654        .filter_map(|part| part.as_deref())
2655        .filter(|part| !part.is_empty())
2656        .collect::<Vec<_>>()
2657        .join(" · ")
2658}
2659
2660fn value_to_plain_string(value: &Value) -> String {
2661    value
2662        .as_str()
2663        .map(str::to_string)
2664        .unwrap_or_else(|| value.to_string())
2665}
2666
2667// Status has no TypeScript wrapper; preserve the pretty JSON summary and add
2668// one deliberately thin human-readable view of the raw memory section.
2669fn format_status(data: &Value) -> String {
2670    if let Some(text) = data
2671        .get("text")
2672        .and_then(Value::as_str)
2673        .filter(|s| !s.is_empty())
2674    {
2675        return text.to_string();
2676    }
2677    let mut summary = data.clone();
2678    let memory = summary
2679        .as_object_mut()
2680        .and_then(|summary| summary.remove("memory"));
2681    let pretty = serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".to_string());
2682    match memory.as_ref() {
2683        Some(memory) => format!("{pretty}\n\n{}", format_memory_block(memory)),
2684        None => pretty,
2685    }
2686}
2687
2688fn format_memory_block(memory: &Value) -> String {
2689    let process = memory.get("process").unwrap_or(&Value::Null);
2690    let rss = format_optional_memory_bytes(process.get("rss_bytes"));
2691    let attributed = format_optional_memory_bytes(process.get("total_attributed_bytes"));
2692    let unattributed = format_optional_memory_bytes(process.get("unattributed_bytes"));
2693    let mut lines = vec![format!(
2694        "Memory: RSS {rss} | attributed {attributed} | unattributed {unattributed}"
2695    )];
2696    if let Some(roots) = memory.get("roots").and_then(Value::as_object) {
2697        for (root, estimate) in roots {
2698            let total = format_optional_memory_bytes(estimate.get("attributed_bytes"));
2699            let subsystems = [
2700                ("semantic", "semantic"),
2701                ("trigram", "trigram"),
2702                ("symbols", "symbols"),
2703                ("callgraph", "callgraph"),
2704                ("inspect", "inspect"),
2705                ("bash", "bash"),
2706                ("lsp", "lsp"),
2707                ("parser_pool", "parsers"),
2708            ]
2709            .iter()
2710            .map(|(key, label)| {
2711                let subsystem = estimate.get(*key).unwrap_or(&Value::Null);
2712                let value = if subsystem.get("status").and_then(Value::as_str) == Some("busy") {
2713                    "busy".to_string()
2714                } else {
2715                    format_optional_memory_bytes(subsystem.get("estimated_bytes"))
2716                };
2717                format!("{label} {value}")
2718            })
2719            .collect::<Vec<_>>()
2720            .join(", ");
2721            lines.push(format!("  {root}: {total} ({subsystems})"));
2722        }
2723    }
2724    lines.join("\n")
2725}
2726
2727fn format_optional_memory_bytes(value: Option<&Value>) -> String {
2728    let Some(value) = value else {
2729        return "not estimated".to_string();
2730    };
2731    let (sign, magnitude) = if let Some(bytes) = value.as_i64() {
2732        (if bytes < 0 { "-" } else { "" }, bytes.unsigned_abs())
2733    } else if let Some(bytes) = value.as_u64() {
2734        ("", bytes)
2735    } else {
2736        return "not estimated".to_string();
2737    };
2738    let magnitude = magnitude as f64;
2739    if magnitude >= 1024.0 * 1024.0 {
2740        format!("{sign}{:.1} MiB", magnitude / (1024.0 * 1024.0))
2741    } else if magnitude >= 1024.0 {
2742        format!("{sign}{:.1} KiB", magnitude / 1024.0)
2743    } else {
2744        format!("{sign}{} B", magnitude as u64)
2745    }
2746}
2747
2748#[cfg(test)]
2749mod status_memory_tests {
2750    use super::*;
2751    use serde_json::json;
2752
2753    #[test]
2754    fn status_text_renders_compact_memory_block() {
2755        let data = json!({
2756            "version": "test",
2757            "memory": {
2758                "process": {
2759                    "rss_bytes": 8 * 1024 * 1024,
2760                    "total_attributed_bytes": 3 * 1024 * 1024,
2761                    "unattributed_bytes": 5 * 1024 * 1024
2762                },
2763                "roots": {
2764                    "/repo": {
2765                        "attributed_bytes": 3 * 1024 * 1024,
2766                        "semantic": {"status": "ready", "estimated_bytes": 2 * 1024 * 1024},
2767                        "trigram": {"status": "ready", "estimated_bytes": 1024 * 1024},
2768                        "symbols": {"status": "ready", "estimated_bytes": 0},
2769                        "callgraph": {"status": "ready", "estimated_bytes": null},
2770                        "inspect": {"status": "ready", "estimated_bytes": 0},
2771                        "bash": {"status": "ready", "estimated_bytes": 0},
2772                        "lsp": {"status": "ready", "estimated_bytes": 0},
2773                        "parser_pool": {"status": "ready", "estimated_bytes": null}
2774                    }
2775                }
2776            }
2777        });
2778        let rendered = format_status(&data);
2779        assert!(
2780            rendered.contains("Memory: RSS 8.0 MiB | attributed 3.0 MiB | unattributed 5.0 MiB")
2781        );
2782        assert!(rendered.contains("/repo: 3.0 MiB (semantic 2.0 MiB, trigram 1.0 MiB"));
2783        assert!(!rendered.contains("\"memory\""));
2784    }
2785}