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    // Producer may mark a copy+delete-failed path as incomplete while both
444    // paths still exist; never render that as a finished "Moved".
445    let source_delete_failed = response
446        .and_then(|r| r.get("source_delete_failed"))
447        .and_then(Value::as_bool)
448        .unwrap_or(false);
449    let incomplete = response
450        .and_then(|r| r.get("complete"))
451        .and_then(Value::as_bool)
452        == Some(false);
453    if source_delete_failed || incomplete {
454        let message = response
455            .and_then(|r| r.get("warning"))
456            .and_then(Value::as_str)
457            .and_then(extract_move_source_delete_message)
458            .unwrap_or("unknown error");
459        return format!(
460            "Partially moved {file} → {destination}; destination was written, but source deletion failed: {message}. Both paths exist. Verify the source and destination before retrying or accepting the duplicate."
461        );
462    }
463
464    format!("Moved {file} → {destination}")
465}
466
467/// Pull the OS/error fragment out of the move producer's warning string.
468fn extract_move_source_delete_message(warning: &str) -> Option<&str> {
469    const PREFIX: &str =
470        "destination was written, but source file could not be deleted after copy: ";
471    let rest = warning.strip_prefix(PREFIX)?;
472    rest.split(". Both paths")
473        .next()
474        .map(str::trim)
475        .filter(|s| !s.is_empty())
476}
477
478fn format_import(data: &Value, ctx: &FormatContext) -> String {
479    let Some(response) = data.as_object() else {
480        return "No import result.".to_string();
481    };
482
483    match ctx.import_op.as_deref() {
484        Some("organize") => {
485            let group_text = response
486                .get("groups")
487                .and_then(Value::as_array)
488                .filter(|groups| !groups.is_empty())
489                .map(|groups| {
490                    groups
491                        .iter()
492                        .map(|group| {
493                            let name = group
494                                .get("name")
495                                .and_then(Value::as_str)
496                                .unwrap_or("unknown");
497                            let count = group
498                                .get("count")
499                                .and_then(import_number_value)
500                                .unwrap_or_else(|| "0".to_string());
501                            format!("{name}: {count}")
502                        })
503                        .collect::<Vec<_>>()
504                        .join(" · ")
505                })
506                .unwrap_or_else(|| "No imports found".to_string());
507            let removed_duplicates = import_number_field(response, "removed_duplicates")
508                .unwrap_or_else(|| "0".to_string());
509            [
510                format!("organized {}", import_file_name(response, ctx)),
511                format!("groups {group_text}"),
512                format!("duplicates removed {removed_duplicates}"),
513            ]
514            .join("\n")
515        }
516        Some("add") => {
517            let status = if response.get("already_present").and_then(Value::as_bool) == Some(true) {
518                "already present"
519            } else {
520                "added"
521            };
522            [
523                format!("{status} {}", import_module_name(response, ctx)),
524                format!("file {}", import_file_name(response, ctx)),
525                format!(
526                    "group {}",
527                    import_string_field(response, "group").unwrap_or_else(|| "—".to_string())
528                ),
529            ]
530            .join("\n")
531        }
532        Some("remove") => {
533            let module = import_module_name(response, ctx);
534            let status = if response.get("removed").and_then(Value::as_bool) == Some(false) {
535                format!("not present {module}")
536            } else {
537                format!("removed {module}")
538            };
539            let scope = ctx
540                .import_remove_name
541                .as_deref()
542                .filter(|name| !name.is_empty())
543                .map(|name| format!("name {name}"))
544                .unwrap_or_else(|| "scope entire import".to_string());
545            [
546                status,
547                format!("file {}", import_file_name(response, ctx)),
548                scope,
549            ]
550            .join("\n")
551        }
552        _ => "No import result.".to_string(),
553    }
554}
555
556fn format_refactor(data: &Value, ctx: &FormatContext) -> String {
557    let Some(response) = data.as_object() else {
558        return "No refactor result.".to_string();
559    };
560
561    match ctx.refactor_op.as_deref() {
562        Some("move") => {
563            let results = response
564                .get("results")
565                .and_then(Value::as_array)
566                .map(|items| {
567                    items
568                        .iter()
569                        .filter_map(Value::as_object)
570                        .collect::<Vec<_>>()
571                })
572                .unwrap_or_default();
573            let files_modified = import_number_field(response, "files_modified")
574                .unwrap_or_else(|| results.len().to_string());
575            let consumers_updated = import_number_field(response, "consumers_updated")
576                .unwrap_or_else(|| "0".to_string());
577            let files = if results.is_empty() {
578                "No files reported.".to_string()
579            } else {
580                results
581                    .iter()
582                    .map(|entry| {
583                        let file = entry
584                            .get("file")
585                            .and_then(Value::as_str)
586                            .unwrap_or("(unknown file)");
587                        format!("  ↳ {}", shorten_path(file))
588                    })
589                    .collect::<Vec<_>>()
590                    .join("\n")
591            };
592
593            [
594                format!(
595                    "moved symbol {}",
596                    ctx.refactor_symbol_arg
597                        .clone()
598                        .unwrap_or_else(|| "(symbol)".to_string())
599                ),
600                format!("files modified {files_modified}"),
601                format!("consumers updated {consumers_updated}"),
602                files,
603            ]
604            .join("\n")
605        }
606        Some("extract") => {
607            let name = import_string_field(response, "name")
608                .or_else(|| ctx.refactor_name_arg.clone())
609                .unwrap_or_else(|| "(function)".to_string());
610            let file = import_string_field(response, "file")
611                .or_else(|| ctx.refactor_file_arg.clone())
612                .unwrap_or_default();
613            let parameters = response
614                .get("parameters")
615                .and_then(Value::as_array)
616                .map(|items| {
617                    let joined = items
618                        .iter()
619                        .map(value_to_plain_string)
620                        .collect::<Vec<_>>()
621                        .join(", ");
622                    if joined.is_empty() {
623                        "none".to_string()
624                    } else {
625                        joined
626                    }
627                })
628                .unwrap_or_else(|| "none".to_string());
629            let return_type = import_string_field(response, "return_type")
630                .unwrap_or_else(|| "unknown".to_string());
631
632            [
633                format!("extracted {name}"),
634                format!("file {}", shorten_path(&file)),
635                format!("params {parameters}"),
636                format!("return type {return_type}"),
637            ]
638            .join("\n")
639        }
640        Some("inline") => {
641            let symbol = import_string_field(response, "symbol")
642                .or_else(|| ctx.refactor_symbol_arg.clone())
643                .unwrap_or_else(|| "(symbol)".to_string());
644            let file = import_string_field(response, "file")
645                .or_else(|| ctx.refactor_file_arg.clone())
646                .unwrap_or_default();
647            let context = import_string_field(response, "call_context")
648                .unwrap_or_else(|| "unknown".to_string());
649            let substitutions =
650                import_number_field(response, "substitutions").unwrap_or_else(|| "0".to_string());
651
652            [
653                format!("inlined {symbol}"),
654                format!("file {}", shorten_path(&file)),
655                format!("context {context}"),
656                format!("substitutions {substitutions}"),
657            ]
658            .join("\n")
659        }
660        _ => "No refactor result.".to_string(),
661    }
662}
663
664fn format_safety(data: &Value, ctx: &FormatContext) -> String {
665    let Some(response) = data.as_object() else {
666        return "No safety result.".to_string();
667    };
668
669    match ctx.safety_op.as_deref() {
670        Some("undo") => {
671            if response.get("operation").and_then(Value::as_bool) == Some(true) {
672                let op_id = import_string_field(response, "op_id")
673                    .unwrap_or_else(|| "(operation)".to_string());
674                let files = import_number_field(response, "restored_count").unwrap_or_else(|| {
675                    response
676                        .get("restored")
677                        .and_then(Value::as_array)
678                        .map(|items| items.len().to_string())
679                        .unwrap_or_else(|| "0".to_string())
680                });
681                [
682                    format!("restored operation {op_id}"),
683                    format!("files {files}"),
684                ]
685                .join("\n")
686            } else {
687                // Prefer the agent's own path spelling: translate resolves a
688                // relative filePath against the project root before the undo
689                // runs, so the response's `path` is absolute — echoing that
690                // back at an agent that said "src/a.ts" reads as a different
691                // file and costs a follow-up read.
692                let file = ctx
693                    .safety_file_arg
694                    .clone()
695                    .or_else(|| import_string_field(response, "path"))
696                    .unwrap_or_else(|| "(file)".to_string());
697                let backup =
698                    import_string_field(response, "backup_id").unwrap_or_else(|| "—".to_string());
699                [
700                    format!("restored {}", shorten_path(&file)),
701                    format!("backup {backup}"),
702                ]
703                .join("\n")
704            }
705        }
706        Some("history") => {
707            let file = import_string_field(response, "file")
708                .or_else(|| ctx.safety_file_arg.clone())
709                .unwrap_or_else(|| "(file)".to_string());
710            let entries = records_field(response, "entries");
711            let mut lines = vec![shorten_path(&file)];
712            if entries.is_empty() {
713                lines.push("No history entries.".to_string());
714            } else {
715                lines.push(
716                    entries
717                        .iter()
718                        .enumerate()
719                        .map(|(index, entry)| {
720                            let backup_id = entry
721                                .get("backup_id")
722                                .and_then(Value::as_str)
723                                .map(str::to_string)
724                                .unwrap_or_else(|| format!("entry-{}", index + 1));
725                            let timestamp = entry
726                                .get("timestamp")
727                                .and_then(format_timestamp)
728                                .unwrap_or_else(|| "unknown time".to_string());
729                            let description = entry
730                                .get("description")
731                                .and_then(Value::as_str)
732                                .unwrap_or_default();
733                            let mut line = format!("{}. {backup_id} {timestamp}", index + 1);
734                            if !description.is_empty() {
735                                line.push_str("\n   ");
736                                line.push_str(description);
737                            }
738                            line
739                        })
740                        .collect::<Vec<_>>()
741                        .join("\n"),
742                );
743            }
744            lines.join("\n")
745        }
746        Some("checkpoint") => {
747            let name = import_string_field(response, "name")
748                .or_else(|| ctx.safety_name_arg.clone())
749                .unwrap_or_else(|| "(checkpoint)".to_string());
750            let files =
751                import_number_field(response, "file_count").unwrap_or_else(|| "0".to_string());
752            let skipped = records_field(response, "skipped");
753            let skipped_text = if skipped.is_empty() {
754                "No skipped files.".to_string()
755            } else {
756                let details = skipped
757                    .iter()
758                    .map(|entry| {
759                        let file = entry
760                            .get("file")
761                            .and_then(Value::as_str)
762                            .unwrap_or("(file)");
763                        let error = entry
764                            .get("error")
765                            .and_then(Value::as_str)
766                            .unwrap_or("unknown error");
767                        format!("  ↳ {}: {error}", shorten_path(file))
768                    })
769                    .collect::<Vec<_>>()
770                    .join("\n");
771                format!("skipped\n{details}")
772            };
773            [
774                format!("checkpoint created {name}"),
775                format!("files {files}"),
776                skipped_text,
777            ]
778            .join("\n")
779        }
780        Some("restore") => {
781            let name = import_string_field(response, "name")
782                .or_else(|| ctx.safety_name_arg.clone())
783                .unwrap_or_else(|| "(checkpoint)".to_string());
784            let files =
785                import_number_field(response, "file_count").unwrap_or_else(|| "0".to_string());
786            [
787                format!("checkpoint restored {name}"),
788                format!("files {files}"),
789            ]
790            .join("\n")
791        }
792        Some("list") => {
793            let checkpoints = records_field(response, "checkpoints");
794            let mut lines = vec![format!("{} checkpoint(s)", checkpoints.len())];
795            if checkpoints.is_empty() {
796                lines.push("No checkpoints saved.".to_string());
797            } else {
798                lines.push(
799                    checkpoints
800                        .iter()
801                        .enumerate()
802                        .map(|(index, checkpoint)| {
803                            let name = checkpoint
804                                .get("name")
805                                .and_then(Value::as_str)
806                                .map(str::to_string)
807                                .unwrap_or_else(|| format!("checkpoint-{}", index + 1));
808                            let file_count = checkpoint
809                                .get("file_count")
810                                .and_then(import_number_value)
811                                .unwrap_or_else(|| "0".to_string());
812                            let created = checkpoint
813                                .get("created_at")
814                                .and_then(format_timestamp)
815                                .unwrap_or_else(|| "unknown time".to_string());
816                            format!("{}. {name} {file_count} file(s) · {created}", index + 1)
817                        })
818                        .collect::<Vec<_>>()
819                        .join("\n"),
820                );
821            }
822            lines.join("\n")
823        }
824        _ => "No safety result.".to_string(),
825    }
826}
827
828fn format_timestamp(value: &Value) -> Option<String> {
829    if let Some(text) = value.as_str().filter(|text| !text.is_empty()) {
830        return Some(text.to_string());
831    }
832    let number = value.as_f64()?;
833    if !number.is_finite() {
834        return None;
835    }
836    let millis = if number > 1_000_000_000_000.0 {
837        number
838    } else {
839        number * 1000.0
840    };
841    const JS_DATE_MAX_MILLIS: f64 = 8_640_000_000_000_000.0;
842    if !millis.is_finite()
843        || millis.abs() > JS_DATE_MAX_MILLIS
844        || millis < i64::MIN as f64
845        || millis > i64::MAX as f64
846    {
847        return Some(value_to_plain_string(value));
848    }
849    Some(format_unix_millis_utc(millis.trunc() as i64))
850}
851
852fn format_unix_millis_utc(millis: i64) -> String {
853    let seconds = div_floor_i64(millis, 1000);
854    let millisecond = millis.rem_euclid(1000);
855    let days = div_floor_i64(seconds, 86_400);
856    let seconds_of_day = seconds.rem_euclid(86_400);
857    let (year, month, day) = civil_from_days(days);
858    let hour = seconds_of_day / 3600;
859    let minute = (seconds_of_day % 3600) / 60;
860    let second = seconds_of_day % 60;
861    if millisecond == 0 {
862        format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}Z")
863    } else {
864        format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{millisecond:03}Z")
865    }
866}
867
868fn div_floor_i64(value: i64, divisor: i64) -> i64 {
869    let quotient = value / divisor;
870    let remainder = value % divisor;
871    if remainder != 0 && ((remainder > 0) != (divisor > 0)) {
872        quotient - 1
873    } else {
874        quotient
875    }
876}
877
878fn civil_from_days(days: i64) -> (i64, i64, i64) {
879    let z = days + 719_468;
880    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
881    let doe = z - era * 146_097;
882    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
883    let year = yoe + era * 400;
884    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
885    let mp = (5 * doy + 2) / 153;
886    let day = doy - (153 * mp + 2) / 5 + 1;
887    let month = mp + if mp < 10 { 3 } else { -9 };
888    let year = year + if month <= 2 { 1 } else { 0 };
889    (year, month, day)
890}
891
892// Mirrors per-tool OpenCode wrapper error handling in packages/opencode-plugin/src/tools/*.ts.
893fn format_error(bare_name: &str, data: &Value, ctx: &FormatContext) -> String {
894    if bare_name == "callgraph" {
895        return format_callgraph_error(ctx.callgraph_op.as_deref().unwrap_or("callgraph"), data);
896    }
897    let code = data
898        .get("code")
899        .and_then(Value::as_str)
900        .filter(|s| !s.is_empty());
901    let message = data
902        .get("message")
903        .and_then(Value::as_str)
904        .filter(|s| !s.is_empty())
905        .unwrap_or("request failed");
906    match (bare_name, code) {
907        ("search", Some(c)) => format!("semantic_search: {c} — {message}"),
908        _ => message.to_string(),
909    }
910}
911
912// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createWriteTool.
913fn format_write_response(data: &Value) -> String {
914    if data.get("rolled_back").and_then(Value::as_bool) == Some(true) {
915        return "Write rolled back: the content produced invalid syntax, so the file was left unchanged."
916            .to_string();
917    }
918
919    let mut output = if data.get("created").and_then(Value::as_bool) == Some(true) {
920        "Created new file.".to_string()
921    } else {
922        "File updated.".to_string()
923    };
924    if is_truthy_formatted(data) {
925        output.push_str(" Auto-formatted.");
926    }
927    if data.get("no_op").and_then(Value::as_bool) == Some(true) {
928        output.push_str(
929            " No net change — the written content is byte-identical to what was already on disk.",
930        );
931    }
932    append_lsp_error_lines(&mut output, data, true);
933    append_lsp_server_notes(&mut output, data);
934    output
935}
936
937// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createEditTool.
938fn format_edit_response(data: &Value) -> String {
939    let mut result = format_edit_summary(data);
940
941    if let Some(note) = format_glob_skip_reasons_note(data.get("format_skip_reasons")) {
942        result.push_str("\n\n");
943        result.push_str(&note);
944    }
945    if data.get("no_op").and_then(Value::as_bool) == Some(true) {
946        result.push_str(
947            "\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.",
948        );
949    }
950    append_lsp_error_lines(&mut result, data, false);
951    append_lsp_server_notes(&mut result, data);
952    result
953}
954
955fn format_glob_skip_reasons_note(reasons: Option<&Value>) -> Option<String> {
956    let actionable = reasons?
957        .as_array()?
958        .iter()
959        .filter_map(Value::as_str)
960        .filter(|reason| {
961            matches!(
962                *reason,
963                "formatter_not_installed" | "formatter_excluded_path" | "timeout" | "error"
964            )
965        })
966        .collect::<std::collections::BTreeSet<_>>();
967    if actionable.is_empty() {
968        None
969    } else {
970        Some(format!(
971            "Note: formatter skipped some glob edit result file(s): {}. See per-file format_skipped_reason values for details.",
972            actionable.into_iter().collect::<Vec<_>>().join(", ")
973        ))
974    }
975}
976
977fn append_lsp_error_lines(output: &mut String, data: &Value, trailing_newline: bool) {
978    let errors = data
979        .get("lsp_diagnostics")
980        .and_then(Value::as_array)
981        .map(|items| {
982            items
983                .iter()
984                .filter(|d| d.get("severity").and_then(Value::as_str) == Some("error"))
985                .collect::<Vec<_>>()
986        })
987        .unwrap_or_default();
988    if errors.is_empty() {
989        return;
990    }
991
992    output.push_str("\n\nLSP errors detected, please fix:\n");
993    let lines = errors
994        .iter()
995        .map(|d| {
996            let line = d
997                .get("line")
998                .and_then(Value::as_u64)
999                .map(|n| n.to_string())
1000                .unwrap_or_else(|| "undefined".to_string());
1001            let message = d
1002                .get("message")
1003                .and_then(Value::as_str)
1004                .unwrap_or("undefined");
1005            format!("  Line {line}: {message}")
1006        })
1007        .collect::<Vec<_>>();
1008    output.push_str(&lines.join("\n"));
1009    if trailing_newline {
1010        output.push('\n');
1011    }
1012}
1013
1014fn append_lsp_server_notes(output: &mut String, data: &Value) {
1015    let pending = string_array(data.get("lsp_pending_servers"));
1016    if !pending.is_empty() {
1017        output.push_str(&format!(
1018            "\n\nNote: LSP server(s) did not respond in time: {}. Diagnostics are incomplete for this call; wait for the LSP update and use the next normal aft_inspect, not repeated polling.",
1019            pending.join(", ")
1020        ));
1021    }
1022    let exited = string_array(data.get("lsp_exited_servers"));
1023    if !exited.is_empty() {
1024        output.push_str(&format!(
1025            "\n\nNote: LSP server(s) exited during this edit: {}. Their diagnostics could not be collected.",
1026            exited.join(", ")
1027        ));
1028    }
1029}
1030
1031// Mirrors packages/aft-bridge/src/edit-summary.ts formatEditSummary.
1032fn format_edit_summary(data: &Value) -> String {
1033    if data.get("rolled_back").and_then(Value::as_bool) == Some(true) {
1034        return "Edit rolled back: the change produced invalid syntax, so the file was left unchanged."
1035            .to_string();
1036    }
1037
1038    if let Some(n) = data.get("files_modified").and_then(Value::as_u64) {
1039        let n = n as usize;
1040        return format!(
1041            "Applied edits to {} file{}.",
1042            n,
1043            if n == 1 { "" } else { "s" }
1044        );
1045    }
1046
1047    if let Some(files) = data.get("total_files").and_then(Value::as_u64) {
1048        let files = files as usize;
1049        let reps = data
1050            .get("total_replacements")
1051            .and_then(Value::as_u64)
1052            .unwrap_or(0) as usize;
1053        return format!(
1054            "Edited {} file{} ({} replacement{}).",
1055            files,
1056            if files == 1 { "" } else { "s" },
1057            reps,
1058            if reps == 1 { "" } else { "s" }
1059        );
1060    }
1061
1062    let additions = data
1063        .get("diff")
1064        .and_then(Value::as_object)
1065        .and_then(|d| d.get("additions"))
1066        .and_then(Value::as_u64)
1067        .unwrap_or(0) as usize;
1068    let deletions = data
1069        .get("diff")
1070        .and_then(Value::as_object)
1071        .and_then(|d| d.get("deletions"))
1072        .and_then(Value::as_u64)
1073        .unwrap_or(0) as usize;
1074    let counts = format!("+{additions}/-{deletions}");
1075
1076    if data.get("created").and_then(Value::as_bool) == Some(true) {
1077        let mut s = format!("Created file ({counts}).");
1078        if is_truthy_formatted(data) {
1079            s.push_str(&format_auto_formatted_suffix(data));
1080        }
1081        return s;
1082    }
1083
1084    let mut detail = counts.clone();
1085    if let Some(n) = data.get("edits_applied").and_then(Value::as_u64) {
1086        if n > 1 {
1087            detail = format!("{counts}, {n} edits");
1088        }
1089    } else if let Some(n) = data.get("replacements").and_then(Value::as_u64) {
1090        if n > 1 {
1091            detail = format!("{counts}, {n} replacements");
1092        }
1093    }
1094
1095    let mut s = format!("Edited ({detail}).");
1096    if is_truthy_formatted(data) {
1097        s.push_str(&format_auto_formatted_suffix(data));
1098    }
1099    s
1100}
1101
1102fn is_truthy_formatted(data: &Value) -> bool {
1103    data.get("formatted")
1104        .and_then(Value::as_bool)
1105        .unwrap_or(false)
1106}
1107
1108fn format_auto_formatted_suffix(data: &Value) -> String {
1109    let reformatted = data.get("reformatted").and_then(Value::as_object);
1110    if let Some(text) = reformatted
1111        .and_then(|r| r.get("text"))
1112        .and_then(Value::as_str)
1113        .filter(|s| !s.is_empty())
1114    {
1115        return format!(
1116            "\nAuto-formatted — the formatter reflowed your edit. On disk now:\n{text}"
1117        );
1118    }
1119    if reformatted
1120        .and_then(|r| r.get("extensive"))
1121        .and_then(Value::as_bool)
1122        == Some(true)
1123    {
1124        return " Auto-formatted — extensive reflow; re-read the file before your next anchored edit."
1125            .to_string();
1126    }
1127    " Auto-formatted.".to_string()
1128}
1129
1130// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createReadTool.
1131fn format_read(data: &Value, agent_specified_range: bool) -> String {
1132    if let Some(entries) = data.get("entries").and_then(Value::as_array) {
1133        return entries
1134            .iter()
1135            .filter_map(|e| e.as_str())
1136            .collect::<Vec<_>>()
1137            .join("\n");
1138    }
1139
1140    if let Some(attachment_line) = format_read_attachments(data) {
1141        return attachment_line;
1142    }
1143
1144    if data.get("binary").and_then(Value::as_bool).unwrap_or(false) {
1145        return data
1146            .get("message")
1147            .and_then(Value::as_str)
1148            .unwrap_or("Binary file")
1149            .to_string();
1150    }
1151
1152    let mut text = data
1153        .get("content")
1154        .and_then(Value::as_str)
1155        .unwrap_or("")
1156        .to_string();
1157    text.push_str(&format_read_footer(agent_specified_range, data));
1158    text
1159}
1160
1161fn format_read_attachments(data: &Value) -> Option<String> {
1162    let attachments = data.get("attachments")?.as_array()?;
1163    let has_host_attachment = attachments.iter().any(|attachment| {
1164        attachment.get("mime").and_then(Value::as_str).is_some()
1165            && attachment.get("data").and_then(Value::as_str).is_some()
1166    });
1167    if !has_host_attachment {
1168        return None;
1169    }
1170
1171    if let Some(content) = data
1172        .get("content")
1173        .and_then(Value::as_str)
1174        .filter(|content| !content.is_empty())
1175    {
1176        return Some(content.to_string());
1177    }
1178
1179    let first = attachments.first()?.as_object()?;
1180    let kind = first.get("kind").and_then(Value::as_str).unwrap_or("file");
1181    let mime = first
1182        .get("mime")
1183        .and_then(Value::as_str)
1184        .unwrap_or("application/octet-stream");
1185    let size = first
1186        .get("bytes")
1187        .and_then(Value::as_u64)
1188        .map(format_attachment_size);
1189
1190    if kind == "image" || mime.starts_with("image/") {
1191        let dimensions = match (
1192            first.get("width").and_then(Value::as_u64),
1193            first.get("height").and_then(Value::as_u64),
1194        ) {
1195            (Some(width), Some(height)) => format!(", {width}×{height}"),
1196            _ => String::new(),
1197        };
1198        let resized = if first.get("resized").and_then(Value::as_bool) == Some(true) {
1199            ", resized"
1200        } else {
1201            ""
1202        };
1203        let size = size.map(|size| format!(", {size}")).unwrap_or_default();
1204        return Some(format!("Read image ({mime}{dimensions}{resized}{size})."));
1205    }
1206
1207    if kind == "pdf" || mime == "application/pdf" {
1208        let size = size.map(|size| format!(" ({size})")).unwrap_or_default();
1209        return Some(format!("Read PDF{size}."));
1210    }
1211
1212    let size = size.map(|size| format!(", {size}")).unwrap_or_default();
1213    Some(format!("Read attachment ({mime}{size})."))
1214}
1215
1216fn format_attachment_size(bytes: u64) -> String {
1217    if bytes >= 1024 * 1024 {
1218        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
1219    } else if bytes >= 1024 {
1220        format!("{} KB", bytes.div_ceil(1024))
1221    } else {
1222        format!("{bytes} bytes")
1223    }
1224}
1225
1226fn format_read_footer(agent_specified_range: bool, data: &Value) -> String {
1227    if agent_specified_range {
1228        return String::new();
1229    }
1230    if !data
1231        .get("truncated")
1232        .and_then(Value::as_bool)
1233        .unwrap_or(false)
1234    {
1235        return String::new();
1236    }
1237    let start = data.get("start_line").and_then(Value::as_u64);
1238    let end = data.get("end_line").and_then(Value::as_u64);
1239    let total = data.get("total_lines").and_then(Value::as_u64);
1240    match (start, end, total) {
1241        (Some(start), Some(end), Some(total)) => format!(
1242            "\n(Showing lines {start}-{end} of {total}. Use startLine/endLine to read other sections.)"
1243        ),
1244        _ => String::new(),
1245    }
1246}
1247
1248// Mirrors packages/opencode-plugin/src/tools/search.ts formatGrepOutput.
1249fn format_grep(data: &Value) -> String {
1250    if let Some(text) = data.get("text").and_then(Value::as_str) {
1251        return text.to_string();
1252    }
1253
1254    let matches = data
1255        .get("matches")
1256        .and_then(Value::as_array)
1257        .cloned()
1258        .unwrap_or_default();
1259    let total_matches = data
1260        .get("total_matches")
1261        .and_then(Value::as_u64)
1262        .unwrap_or(matches.len() as u64);
1263    let files_with_matches = data
1264        .get("files_with_matches")
1265        .and_then(Value::as_u64)
1266        .unwrap_or_else(|| {
1267            matches
1268                .iter()
1269                .filter_map(|m| m.get("file").and_then(Value::as_str))
1270                .collect::<std::collections::BTreeSet<_>>()
1271                .len() as u64
1272        });
1273
1274    if matches.is_empty() {
1275        return format!("Found {total_matches} match across {files_with_matches} file");
1276    }
1277
1278    let body = matches
1279        .iter()
1280        .map(|m| {
1281            let file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
1282            let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
1283            let text = m
1284                .get("line_text")
1285                .or_else(|| m.get("text"))
1286                .and_then(Value::as_str)
1287                .unwrap_or("");
1288            format!("{file}:{line}: {text}")
1289        })
1290        .collect::<Vec<_>>()
1291        .join("\n");
1292    format!("{body}\n\nFound {total_matches} match across {files_with_matches} file")
1293}
1294
1295fn format_ast_search(data: &Value) -> String {
1296    let matches = data.get("matches").and_then(Value::as_array);
1297    let match_count = data
1298        .get("total_matches")
1299        .and_then(Value::as_u64)
1300        .unwrap_or_else(|| matches.map(|m| m.len() as u64).unwrap_or(0));
1301    let files_searched = data
1302        .get("files_searched")
1303        .and_then(Value::as_u64)
1304        .unwrap_or(0);
1305    let files_with_matches = data
1306        .get("files_with_matches")
1307        .and_then(Value::as_u64)
1308        .unwrap_or(files_searched);
1309
1310    let mut output = if data.get("no_files_matched_scope").and_then(Value::as_bool) == Some(true) {
1311        let mut output =
1312            "No files matched the scope (paths/globs resolved to zero files)".to_string();
1313        append_scope_warnings(&mut output, data);
1314        output
1315    } else if match_count == 0 {
1316        let mut output = format!("No matches found (searched {files_searched} files)");
1317        append_scope_warnings(&mut output, data);
1318        append_hint(&mut output, data);
1319        output
1320    } else {
1321        let mut output = format!(
1322            "Found {match_count} match(es) in {files_with_matches} file(s) ({files_searched} searched)\n\n"
1323        );
1324        if let Some(matches) = matches {
1325            for m in matches {
1326                let rel_file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
1327                let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
1328                output.push_str(&format!("{rel_file}:{line}\n"));
1329                if let Some(text) = m.get("text").and_then(Value::as_str) {
1330                    output.push_str(&format!("  {}\n", text.trim()));
1331                }
1332                if let Some(meta_vars) = m.get("meta_variables").and_then(Value::as_object) {
1333                    if !meta_vars.is_empty() {
1334                        for (key, value) in meta_vars {
1335                            output.push_str(&format!("  {key}: {}\n", js_template_string(value)));
1336                        }
1337                    }
1338                }
1339                output.push('\n');
1340            }
1341        }
1342        output
1343    };
1344
1345    if data.get("complete").and_then(Value::as_bool) == Some(false)
1346        || data
1347            .get("skipped_files")
1348            .and_then(Value::as_array)
1349            .is_some_and(|skipped| !skipped.is_empty())
1350    {
1351        output = append_ast_skipped_files(output, data.get("skipped_files"));
1352    }
1353    output
1354}
1355
1356fn format_ast_replace(data: &Value, dry_run: bool) -> String {
1357    let matches = data.get("matches").and_then(Value::as_array);
1358    let match_count = data
1359        .get("total_replacements")
1360        .or_else(|| data.get("total_matches"))
1361        .and_then(Value::as_u64)
1362        .unwrap_or_else(|| matches.map(|m| m.len() as u64).unwrap_or(0));
1363    let files_searched = data
1364        .get("files_searched")
1365        .or_else(|| data.get("total_files"))
1366        .and_then(Value::as_u64)
1367        .unwrap_or(0);
1368    let files_with_matches = data
1369        .get("files_with_matches")
1370        .or_else(|| data.get("total_files"))
1371        .and_then(Value::as_u64)
1372        .unwrap_or(files_searched);
1373
1374    if data.get("no_files_matched_scope").and_then(Value::as_bool) == Some(true) {
1375        let mut output =
1376            "No files matched the scope (paths/globs resolved to zero files)".to_string();
1377        append_scope_warnings(&mut output, data);
1378        return output;
1379    }
1380
1381    if match_count == 0 {
1382        let mut output = format!("No matches found (searched {files_searched} files)");
1383        append_scope_warnings(&mut output, data);
1384        append_hint(&mut output, data);
1385        return output;
1386    }
1387
1388    let mut output = if dry_run {
1389        format!(
1390            "[DRY RUN] Would replace {match_count} match(es) in {files_with_matches} file(s) ({files_searched} searched)\n\n"
1391        )
1392    } else {
1393        format!(
1394            "Replaced {match_count} match(es) in {files_with_matches} file(s) ({files_searched} searched)\n\n"
1395        )
1396    };
1397
1398    if dry_run {
1399        if let Some(files) = data.get("files").and_then(Value::as_array) {
1400            if !files.is_empty() {
1401                append_ast_replace_dry_run_files(
1402                    &mut output,
1403                    files,
1404                    match_count,
1405                    files_with_matches,
1406                );
1407            }
1408        }
1409    } else if let Some(matches) = matches {
1410        for m in matches {
1411            let rel_file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
1412            let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
1413            output.push_str(&format!("{rel_file}:{line}\n"));
1414            if let (Some(text), Some(replacement)) = (
1415                m.get("text").and_then(Value::as_str),
1416                m.get("replacement").and_then(Value::as_str),
1417            ) {
1418                output.push_str(&format!("  - {}\n", text.trim()));
1419                output.push_str(&format!("  + {}\n", replacement.trim()));
1420            }
1421            output.push('\n');
1422        }
1423    } else if let Some(files) = data.get("files").and_then(Value::as_array) {
1424        if !files.is_empty() {
1425            for f in files {
1426                let rel_file = f.get("file").and_then(Value::as_str).unwrap_or("unknown");
1427                let replacements = f.get("replacements").and_then(Value::as_u64).unwrap_or(0);
1428                let suffix = if replacements == 1 { "" } else { "s" };
1429                output.push_str(&format!(
1430                    "  {rel_file}: {replacements} replacement{suffix}\n"
1431                ));
1432            }
1433        }
1434    }
1435
1436    output
1437}
1438
1439fn append_ast_replace_dry_run_files(
1440    output: &mut String,
1441    files: &[Value],
1442    match_count: u64,
1443    files_with_matches: u64,
1444) {
1445    const MAX_DIFF_BYTES: usize = 8 * 1024;
1446    let mut used = 0usize;
1447    for (index, f) in files.iter().enumerate() {
1448        let rel_file = f.get("file").and_then(Value::as_str).unwrap_or("unknown");
1449        let replacements = f.get("replacements").and_then(Value::as_u64).unwrap_or(0);
1450        let diff = f.get("diff").and_then(Value::as_str).unwrap_or("");
1451        if used + diff.len() > MAX_DIFF_BYTES {
1452            let remaining = files.len().saturating_sub(index);
1453            if remaining > 0 {
1454                output.push_str(&format!(
1455                    "\n... ({remaining} more file(s) omitted from preview to stay under {}KB; total {match_count} replacements across {files_with_matches} files)\n",
1456                    MAX_DIFF_BYTES / 1024
1457                ));
1458            }
1459            break;
1460        }
1461        let suffix = if replacements == 1 { "" } else { "s" };
1462        output.push_str(&format!(
1463            "{rel_file} ({replacements} replacement{suffix}):\n"
1464        ));
1465        output.push_str(diff);
1466        if !diff.ends_with('\n') {
1467            output.push('\n');
1468        }
1469        output.push('\n');
1470        used += diff.len();
1471    }
1472}
1473
1474fn append_scope_warnings(output: &mut String, data: &Value) {
1475    let warnings = string_array(data.get("scope_warnings"));
1476    if !warnings.is_empty() {
1477        output.push_str("\n\nScope warnings:\n");
1478        output.push_str(
1479            &warnings
1480                .iter()
1481                .map(|warning| format!("  {warning}"))
1482                .collect::<Vec<_>>()
1483                .join("\n"),
1484        );
1485    }
1486}
1487
1488fn append_hint(output: &mut String, data: &Value) {
1489    if let Some(hint) = data
1490        .get("hint")
1491        .and_then(Value::as_str)
1492        .filter(|hint| !hint.is_empty())
1493    {
1494        output.push_str("\n\n");
1495        output.push_str(hint);
1496    }
1497}
1498
1499fn append_ast_skipped_files(output: String, skipped_files: Option<&Value>) -> String {
1500    let Some(skipped_files) = skipped_files.and_then(Value::as_array) else {
1501        return output;
1502    };
1503    if skipped_files.is_empty() {
1504        return output;
1505    }
1506    let lines = skipped_files
1507        .iter()
1508        .map(|skipped| {
1509            let file = skipped
1510                .get("file")
1511                .and_then(Value::as_str)
1512                .unwrap_or("unknown");
1513            let reason = skipped
1514                .get("reason")
1515                .and_then(Value::as_str)
1516                .unwrap_or("unknown reason");
1517            format!("  {file}: {reason}")
1518        })
1519        .collect::<Vec<_>>();
1520    format!(
1521        "{output}\n\nIncomplete: skipped {} file(s)\n{}",
1522        skipped_files.len(),
1523        lines.join("\n")
1524    )
1525}
1526
1527fn js_template_string(value: &Value) -> String {
1528    match value {
1529        Value::Null => "null".to_string(),
1530        Value::Bool(value) => value.to_string(),
1531        Value::Number(value) => value.to_string(),
1532        Value::String(value) => value.clone(),
1533        Value::Array(items) => items
1534            .iter()
1535            .map(|item| match item {
1536                Value::Null => String::new(),
1537                other => js_template_string(other),
1538            })
1539            .collect::<Vec<_>>()
1540            .join(","),
1541        Value::Object(_) => "[object Object]".to_string(),
1542    }
1543}
1544
1545// Mirrors packages/opencode-plugin/src/tools/semantic.ts semanticTools.
1546fn format_search(data: &Value) -> String {
1547    let note = extra_honesty_note(data);
1548    if let Some(text) = data
1549        .get("text")
1550        .and_then(Value::as_str)
1551        .filter(|s| !s.is_empty())
1552    {
1553        return match note {
1554            Some(n) => format!("{text}\n{n}"),
1555            None => text.to_string(),
1556        };
1557    }
1558    semantic_honesty_note(data).unwrap_or_else(|| "No results.".to_string())
1559}
1560
1561fn semantic_honesty_note(data: &Value) -> Option<String> {
1562    let mut notes = Vec::new();
1563    if data.get("more_available").and_then(Value::as_bool) == Some(true) {
1564        notes.push("more results available");
1565    }
1566    if data.get("engine_capped").and_then(Value::as_bool) == Some(true) {
1567        notes.push("enumeration capped");
1568    }
1569    if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
1570        notes.push("fully degraded");
1571    }
1572    if data.get("complete").and_then(Value::as_bool) == Some(false) {
1573        notes.push("partial/incomplete");
1574    }
1575    if notes.is_empty() {
1576        None
1577    } else {
1578        Some(format!("Search status: {}.", notes.join("; ")))
1579    }
1580}
1581
1582fn extra_honesty_note(data: &Value) -> Option<String> {
1583    let mut notes = Vec::new();
1584    if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
1585        notes.push("fully degraded");
1586    }
1587    if data.get("complete").and_then(Value::as_bool) == Some(false) {
1588        notes.push("partial/incomplete");
1589    }
1590    if notes.is_empty() {
1591        None
1592    } else {
1593        Some(format!("Search status: {}.", notes.join("; ")))
1594    }
1595}
1596
1597// Mirrors packages/opencode-plugin/src/tools/reading.ts aft_outline dispatch.
1598fn format_outline(response: &Response, mode: OutlineMode) -> String {
1599    match mode {
1600        OutlineMode::Text => format_outline_text(&response.data),
1601        OutlineMode::Files | OutlineMode::DirectoryJson => {
1602            format_outline_files_text(&response.data)
1603        }
1604    }
1605}
1606
1607// Mirrors packages/opencode-plugin/src/tools/reading.ts formatOutlineFilesText.
1608fn format_outline_files_text(data: &Value) -> String {
1609    let text = format_outline_text(data);
1610    let unchecked: Vec<String> = data
1611        .get("unchecked_files")
1612        .and_then(Value::as_array)
1613        .map(|arr| {
1614            arr.iter()
1615                .filter_map(|v| v.as_str())
1616                .filter(|s| !s.is_empty())
1617                .map(str::to_string)
1618                .collect()
1619        })
1620        .unwrap_or_default();
1621
1622    let is_partial = data.get("complete").and_then(Value::as_bool) == Some(false)
1623        || data.get("walk_truncated").and_then(Value::as_bool) == Some(true)
1624        || !unchecked.is_empty();
1625
1626    if !is_partial {
1627        return text;
1628    }
1629
1630    let mut footer = Vec::new();
1631    if data.get("walk_truncated").and_then(Value::as_bool) == Some(true) {
1632        let suffix = if !unchecked.is_empty() {
1633            format!(
1634                " {} additional files in this directory were not indexed.",
1635                unchecked.len()
1636            )
1637        } else {
1638            " Some files in this directory were not indexed.".to_string()
1639        };
1640        footer.push(format!(
1641            "⚠ Partial result: walk truncated at 200 files.{suffix}"
1642        ));
1643    } else {
1644        let suffix = if !unchecked.is_empty() {
1645            format!(
1646                " {} files in this directory were not indexed.",
1647                unchecked.len()
1648            )
1649        } else {
1650            " Some files in this directory were not indexed.".to_string()
1651        };
1652        footer.push(format!("⚠ Partial result:{suffix}"));
1653    }
1654
1655    if !unchecked.is_empty() {
1656        footer.push("Unchecked files:".to_string());
1657        for file in unchecked.iter().take(MAX_UNCHECKED_FILES_IN_FOOTER) {
1658            footer.push(format!("  {file}"));
1659        }
1660        let remaining = unchecked
1661            .len()
1662            .saturating_sub(MAX_UNCHECKED_FILES_IN_FOOTER);
1663        if remaining > 0 {
1664            footer.push(format!("  ... +{remaining} more"));
1665        }
1666    }
1667
1668    if text.is_empty() {
1669        footer.join("\n")
1670    } else {
1671        format!("{text}\n\n{}", footer.join("\n"))
1672    }
1673}
1674
1675fn format_outline_text(data: &Value) -> String {
1676    let text = data.get("text").and_then(Value::as_str).unwrap_or("");
1677    let skipped = data.get("skipped_files").and_then(Value::as_array);
1678    let Some(skipped) = skipped.filter(|s| !s.is_empty()) else {
1679        return text.to_string();
1680    };
1681
1682    let lines: Vec<String> = skipped
1683        .iter()
1684        .filter_map(|item| {
1685            let obj = item.as_object()?;
1686            let file = obj.get("file").and_then(Value::as_str)?;
1687            let reason = obj
1688                .get("reason")
1689                .and_then(Value::as_str)
1690                .unwrap_or("skipped");
1691            Some(format!("  {file} — {reason}"))
1692        })
1693        .collect();
1694    if lines.is_empty() {
1695        return text.to_string();
1696    }
1697    let header = if text.is_empty() { "" } else { "\n\n" };
1698    format!(
1699        "{text}{header}Skipped {} file(s):\n{}",
1700        lines.len(),
1701        lines.join("\n")
1702    )
1703}
1704
1705// Format zoom responses as plain text so direct calls and server-side calls
1706// produce identical output.
1707fn format_zoom(data: &Value, ctx: &FormatContext) -> String {
1708    if let Some(entries) = data.get("targets").and_then(Value::as_array) {
1709        return format_zoom_multi_target_result(entries);
1710    }
1711
1712    let target_label = ctx.zoom_target_label.as_deref().unwrap_or("(no target)");
1713    if let Some((names, responses)) = unwrap_rust_zoom_batch_envelope(data) {
1714        return format_zoom_batch_result(target_label, &names, &responses);
1715    }
1716    format_zoom_text(target_label, data)
1717}
1718
1719fn format_zoom_multi_target_result(entries: &[Value]) -> String {
1720    let rendered = entries
1721        .iter()
1722        .map(|entry| {
1723            let target_label = entry
1724                .get("targetLabel")
1725                .and_then(Value::as_str)
1726                .filter(|label| !label.is_empty())
1727                .unwrap_or("(no target)");
1728            let name = entry.get("name").and_then(Value::as_str).unwrap_or("");
1729            let response = entry.get("response");
1730            if response
1731                .and_then(|response| response.get("success"))
1732                .and_then(Value::as_bool)
1733                == Some(false)
1734            {
1735                let message = response
1736                    .and_then(|response| response.get("message"))
1737                    .and_then(Value::as_str)
1738                    .filter(|message| !message.is_empty())
1739                    .unwrap_or("zoom failed");
1740                return (
1741                    false,
1742                    format!("Symbol \"{name}\" not found in {target_label}: {message}"),
1743                );
1744            }
1745            match response {
1746                Some(response) => (true, format_zoom_text(target_label, response)),
1747                None => (
1748                    false,
1749                    format!("Symbol \"{name}\" not found in {target_label}: missing zoom response"),
1750                ),
1751            }
1752        })
1753        .collect::<Vec<_>>();
1754
1755    let complete = rendered.iter().all(|(success, _)| *success);
1756    let mut sections = Vec::new();
1757    if !complete {
1758        sections.push("Incomplete zoom results: one or more symbols failed.".to_string());
1759    }
1760    sections.extend(rendered.into_iter().map(|(_, content)| content));
1761    sections.join("\n\n")
1762}
1763
1764fn unwrap_rust_zoom_batch_envelope(data: &Value) -> Option<(Vec<String>, Vec<Value>)> {
1765    let symbols = data.get("symbols")?.as_array()?;
1766    if symbols.is_empty() {
1767        return None;
1768    }
1769
1770    let mut names = Vec::with_capacity(symbols.len());
1771    let mut responses = Vec::with_capacity(symbols.len());
1772    for entry in symbols {
1773        let row = entry.as_object()?;
1774        let name = row.get("name")?.as_str()?;
1775        let response = row.get("response")?;
1776        if response.is_null() {
1777            return None;
1778        }
1779        names.push(name.to_string());
1780        responses.push(response.clone());
1781    }
1782    Some((names, responses))
1783}
1784
1785fn format_zoom_batch_result(target_label: &str, symbols: &[String], responses: &[Value]) -> String {
1786    let entries = symbols
1787        .iter()
1788        .enumerate()
1789        .map(|(index, name)| {
1790            let response = responses.get(index);
1791            if response
1792                .and_then(|r| r.get("success"))
1793                .and_then(Value::as_bool)
1794                == Some(false)
1795            {
1796                let message = response
1797                    .and_then(|r| r.get("message"))
1798                    .and_then(Value::as_str)
1799                    .filter(|message| !message.is_empty())
1800                    .unwrap_or("zoom failed");
1801                return (false, format!("Symbol \"{name}\" not found: {message}"));
1802            }
1803            match response {
1804                Some(response) => (true, format_zoom_text(target_label, response)),
1805                None => (
1806                    false,
1807                    format!("Symbol \"{name}\" not found: missing zoom response"),
1808                ),
1809            }
1810        })
1811        .collect::<Vec<_>>();
1812
1813    let complete = entries.iter().all(|(success, _)| *success);
1814    let mut sections = Vec::new();
1815    if !complete {
1816        sections.push("Incomplete zoom results: one or more symbols failed.".to_string());
1817    }
1818    sections.extend(entries.into_iter().map(|(_, content)| content));
1819    sections.join("\n\n")
1820}
1821
1822fn format_zoom_text(target_label: &str, response: &Value) -> String {
1823    let range = response.get("range");
1824    let start_line = range
1825        .and_then(|range| range.get("start_line"))
1826        .and_then(Value::as_i64)
1827        .unwrap_or(1);
1828    let end_line = range
1829        .and_then(|range| range.get("end_line"))
1830        .and_then(Value::as_i64)
1831        .unwrap_or(start_line);
1832    let kind = response
1833        .get("kind")
1834        .and_then(Value::as_str)
1835        .unwrap_or("symbol");
1836    let name = response.get("name").and_then(Value::as_str).unwrap_or("");
1837    let content_text = response
1838        .get("content")
1839        .and_then(Value::as_str)
1840        .unwrap_or("");
1841    let context_before = string_array(response.get("context_before"));
1842    let context_after = string_array(response.get("context_after"));
1843
1844    let header = if kind == "lines" {
1845        format!("{target_label}:{start_line}-{end_line}")
1846    } else {
1847        format!("{target_label}:{start_line}-{end_line} [{kind} {name}]")
1848            .trim_end()
1849            .to_string()
1850    };
1851
1852    let mut content_lines = content_text.split('\n').collect::<Vec<_>>();
1853    if content_lines.last() == Some(&"") {
1854        content_lines.pop();
1855    }
1856
1857    let last_displayed_line = end_line + context_after.len() as i64;
1858    let gutter_width = last_displayed_line.max(1).to_string().len();
1859    let mut out = vec![header, String::new()];
1860
1861    let mut line_no = start_line - context_before.len() as i64;
1862    for text in &context_before {
1863        out.push(format_zoom_line(line_no, gutter_width, text));
1864        line_no += 1;
1865    }
1866    for text in content_lines {
1867        out.push(format_zoom_line(line_no, gutter_width, text));
1868        line_no += 1;
1869    }
1870    for text in &context_after {
1871        out.push(format_zoom_line(line_no, gutter_width, text));
1872        line_no += 1;
1873    }
1874
1875    let annotations = response.get("annotations");
1876    let calls_out = annotations
1877        .and_then(|annotations| annotations.get("calls_out"))
1878        .and_then(Value::as_array);
1879    if let Some(calls_out) = calls_out.filter(|calls| !calls.is_empty()) {
1880        out.push(String::new());
1881        out.push("──── calls_out".to_string());
1882        for call in calls_out {
1883            out.push(format_zoom_call_ref(call));
1884        }
1885    }
1886
1887    let called_by = annotations
1888        .and_then(|annotations| annotations.get("called_by"))
1889        .and_then(Value::as_array);
1890    if let Some(called_by) = called_by.filter(|calls| !calls.is_empty()) {
1891        out.push(String::new());
1892        out.push("──── called_by".to_string());
1893        for call in called_by {
1894            out.push(format_zoom_call_ref(call));
1895        }
1896    }
1897
1898    out.join("\n")
1899}
1900
1901fn format_zoom_line(line_no: i64, gutter_width: usize, text: &str) -> String {
1902    format!("{line_no:>gutter_width$}: {text}")
1903}
1904
1905fn format_zoom_call_ref(call: &Value) -> String {
1906    let name = call.get("name").and_then(Value::as_str).unwrap_or("");
1907    let line = call.get("line").and_then(Value::as_i64).unwrap_or(0);
1908    let extra = call
1909        .get("extra_count")
1910        .and_then(Value::as_i64)
1911        .filter(|count| *count > 0)
1912        .map(|count| format!(" +{count}"))
1913        .unwrap_or_default();
1914    format!("  {name} (line {line}){extra}")
1915}
1916
1917// Mirrors packages/opencode-plugin/src/tools/inspect.ts inspectTools.
1918fn format_inspect(response: &Response) -> String {
1919    if let Some(text) = response.data.get("text").and_then(Value::as_str) {
1920        return append_rendered_diagnostics(text, &response.data);
1921    }
1922    let json = serde_json::to_string_pretty(response).unwrap_or_else(|_| "{}".to_string());
1923    append_rendered_diagnostics(&json, &response.data)
1924}
1925
1926// Mirrors packages/opencode-plugin/src/tools/inspect.ts appendRenderedDiagnostics.
1927fn append_rendered_diagnostics(text: &str, data: &Value) -> String {
1928    if text.lines().any(|line| {
1929        let lower = line.to_lowercase();
1930        lower.starts_with("diagnostics:") || lower.starts_with("diagnostics ")
1931    }) {
1932        return text.to_string();
1933    }
1934    let diagnostics = render_inspect_diagnostics(data);
1935    if diagnostics.is_empty() {
1936        return text.to_string();
1937    }
1938    if text.is_empty() {
1939        diagnostics
1940    } else {
1941        format!("{text}\n\n{diagnostics}")
1942    }
1943}
1944
1945fn render_inspect_diagnostics(data: &Value) -> String {
1946    let mut lines = Vec::new();
1947    if let Some(summary_line) = format_diagnostics_summary(data.get("summary")) {
1948        lines.push(summary_line);
1949    }
1950
1951    let detail_lines = format_diagnostics_details(data.get("details"));
1952    if !detail_lines.is_empty() {
1953        lines.push("diagnostics details:".to_string());
1954        for line in detail_lines {
1955            lines.push(format!("- {line}"));
1956        }
1957    }
1958
1959    lines.join("\n")
1960}
1961
1962fn format_diagnostics_summary(summary: Option<&Value>) -> Option<String> {
1963    let section = summary?.get("diagnostics")?.as_object()?;
1964    let errors = section.get("errors").and_then(Value::as_u64);
1965    let warnings = section.get("warnings").and_then(Value::as_u64);
1966    let info = section.get("info").and_then(Value::as_u64);
1967    let hints = section.get("hints").and_then(Value::as_u64);
1968    let has_counts = [errors, warnings, info, hints].iter().any(|v| v.is_some());
1969    let counts = format!(
1970        "{} errors, {} warnings, {} info, {} hints",
1971        errors.unwrap_or(0),
1972        warnings.unwrap_or(0),
1973        info.unwrap_or(0),
1974        hints.unwrap_or(0)
1975    );
1976    let status = section.get("status").and_then(Value::as_str);
1977
1978    match status {
1979        Some("pending") => {
1980            if has_counts {
1981                Some(format!(
1982                    "diagnostics: {counts} so far — still pending (servers: {}); wait for the LSP update and use the next normal aft_inspect, not repeated polling",
1983                    diagnostics_server_summary(section)
1984                ))
1985            } else {
1986                Some(format!(
1987                    "diagnostics: pending (servers: {}); wait for the LSP update and use the next normal aft_inspect, not repeated polling",
1988                    diagnostics_server_summary(section)
1989                ))
1990            }
1991        }
1992        Some("incomplete") => {
1993            if has_counts {
1994                Some(format!(
1995                    "diagnostics: {counts} (incomplete — servers: {})",
1996                    diagnostics_server_summary(section)
1997                ))
1998            } else {
1999                Some(format!(
2000                    "diagnostics: unavailable (status incomplete; servers: {})",
2001                    diagnostics_server_summary(section)
2002                ))
2003            }
2004        }
2005        _ => {
2006            if has_counts {
2007                Some(format!("diagnostics: {counts}"))
2008            } else {
2009                None
2010            }
2011        }
2012    }
2013}
2014
2015fn diagnostics_server_summary(section: &serde_json::Map<String, Value>) -> String {
2016    let pending = string_array(section.get("servers_pending"));
2017    let not_installed = string_array(section.get("servers_not_installed"));
2018    let mut parts = Vec::new();
2019    if !pending.is_empty() {
2020        parts.push(format!("pending: {}", pending.join(", ")));
2021    }
2022    if !not_installed.is_empty() {
2023        parts.push(format!("not installed: {}", not_installed.join(", ")));
2024    }
2025    if parts.is_empty() {
2026        "none reported".to_string()
2027    } else {
2028        parts.join("; ")
2029    }
2030}
2031
2032fn string_array(value: Option<&Value>) -> Vec<String> {
2033    value
2034        .and_then(Value::as_array)
2035        .map(|arr| {
2036            arr.iter()
2037                .filter_map(|v| v.as_str().map(str::to_string))
2038                .collect()
2039        })
2040        .unwrap_or_default()
2041}
2042
2043fn format_diagnostics_details(details: Option<&Value>) -> Vec<String> {
2044    let Some(details) = details.and_then(Value::as_object) else {
2045        return Vec::new();
2046    };
2047    let Some(diagnostics) = details.get("diagnostics").and_then(Value::as_array) else {
2048        return Vec::new();
2049    };
2050    diagnostics
2051        .iter()
2052        .filter_map(|item| {
2053            let d = item.as_object()?;
2054            let severity = d
2055                .get("severity")
2056                .and_then(Value::as_str)
2057                .unwrap_or("information");
2058            let message = d
2059                .get("message")
2060                .and_then(Value::as_str)
2061                .unwrap_or("(no message)");
2062            let source = d.get("source").and_then(Value::as_str);
2063            let suffix = source.map(|s| format!(" [{s}]")).unwrap_or_default();
2064            Some(format!(
2065                "{} {} {}{}",
2066                format_diagnostic_location(d),
2067                severity,
2068                message,
2069                suffix
2070            ))
2071        })
2072        .collect()
2073}
2074
2075fn format_diagnostic_location(d: &serde_json::Map<String, Value>) -> String {
2076    let file = d
2077        .get("file")
2078        .and_then(Value::as_str)
2079        .unwrap_or("(unknown file)");
2080    let line = d.get("line").and_then(Value::as_u64);
2081    let column = d.get("column").and_then(Value::as_u64);
2082    match (line, column) {
2083        (None, _) => file.to_string(),
2084        (Some(line), None) => format!("{file}:{line}"),
2085        (Some(line), Some(col)) => format!("{file}:{line}:{col}"),
2086    }
2087}
2088
2089const UNRESOLVED_SUMMARY_NAME_LIMIT: usize = 10;
2090
2091pub fn format_callgraph(op: &str, response_data: &Value, include_unresolved: bool) -> String {
2092    let Some(record) = response_data.as_object() else {
2093        return "No navigation result.".to_string();
2094    };
2095
2096    let sections = match op {
2097        "call_tree" => format_call_tree_sections(record, include_unresolved),
2098        "callers" => format_callers_sections(record),
2099        "trace_to_symbol" => format_trace_to_symbol_sections(record),
2100        "trace_to" => format_trace_to_sections(record),
2101        "impact" => format_impact_sections(record),
2102        _ => format_trace_data_sections(record),
2103    };
2104    sections.join("\n")
2105}
2106
2107fn format_callgraph_error(command: &str, data: &Value) -> String {
2108    let code = data
2109        .get("code")
2110        .and_then(Value::as_str)
2111        .filter(|s| !s.is_empty());
2112    let message = data
2113        .get("message")
2114        .and_then(Value::as_str)
2115        .filter(|s| !s.is_empty())
2116        .unwrap_or("callgraph failed");
2117
2118    if matches!(
2119        code,
2120        Some("ambiguous_target") | Some("target_symbol_not_in_file")
2121    ) {
2122        let candidates = callgraph_candidates(data);
2123        if !candidates.is_empty() {
2124            let symbol =
2125                callgraph_error_symbol(data).or_else(|| symbol_from_callgraph_message(message));
2126            let target = symbol
2127                .map(|symbol| format!("multiple symbols named \"{symbol}\""))
2128                .unwrap_or_else(|| strip_terminal_punctuation(message));
2129            let action = if code == Some("ambiguous_target") {
2130                "Pass toFile to disambiguate"
2131            } else {
2132                "Try one of these files for toFile"
2133            };
2134            let mut lines = vec![format!(
2135                "{command}: {} — {target}. {action}:",
2136                code.unwrap_or_default()
2137            )];
2138            lines.extend(
2139                candidates
2140                    .into_iter()
2141                    .map(|candidate| format!("  - {candidate}")),
2142            );
2143            return lines.join("\n");
2144        }
2145    }
2146
2147    let Some(code) = code else {
2148        return message.to_string();
2149    };
2150    let mut lines = vec![format!("{command}: {code} — {message}")];
2151    if let Some(extras) = collect_callgraph_error_extras(data) {
2152        lines.push(format!("data: {extras}"));
2153    }
2154    lines.join("\n")
2155}
2156
2157fn callgraph_candidates(data: &Value) -> Vec<String> {
2158    data.get("candidates")
2159        .and_then(Value::as_array)
2160        .or_else(|| {
2161            data.get("data")
2162                .and_then(Value::as_object)
2163                .and_then(|nested| nested.get("candidates"))
2164                .and_then(Value::as_array)
2165        })
2166        .map(|items| {
2167            items
2168                .iter()
2169                .filter_map(|candidate| {
2170                    let candidate = candidate.as_object()?;
2171                    let file = string_field(candidate, "file")?;
2172                    let line = number_field(candidate, "line");
2173                    Some(match line {
2174                        Some(line) => format!("{file}:{line}"),
2175                        None => file.to_string(),
2176                    })
2177                })
2178                .collect()
2179        })
2180        .unwrap_or_default()
2181}
2182
2183fn callgraph_error_symbol(data: &Value) -> Option<String> {
2184    data.get("symbol")
2185        .and_then(Value::as_str)
2186        .filter(|s| !s.is_empty())
2187        .or_else(|| {
2188            data.get("data")
2189                .and_then(Value::as_object)
2190                .and_then(|nested| nested.get("symbol"))
2191                .and_then(Value::as_str)
2192                .filter(|s| !s.is_empty())
2193        })
2194        .map(str::to_string)
2195}
2196
2197fn symbol_from_callgraph_message(message: &str) -> Option<String> {
2198    extract_between(message, "target symbol '", "'")
2199        .or_else(|| extract_between(message, "multiple symbols named \"", "\""))
2200}
2201
2202fn extract_between(message: &str, prefix: &str, suffix: &str) -> Option<String> {
2203    let start = message.find(prefix)? + prefix.len();
2204    let rest = &message[start..];
2205    let end = rest.find(suffix)?;
2206    let value = &rest[..end];
2207    (!value.is_empty()).then(|| value.to_string())
2208}
2209
2210fn strip_terminal_punctuation(message: &str) -> String {
2211    message.trim_end_matches(['.', '!', '?']).to_string()
2212}
2213
2214fn collect_callgraph_error_extras(data: &Value) -> Option<String> {
2215    let obj = data.as_object()?;
2216    let mut extras = serde_json::Map::new();
2217    for (key, value) in obj {
2218        if matches!(
2219            key.as_str(),
2220            "id" | "success" | "code" | "message" | "data" | "status_bar" | "bg_completions"
2221        ) {
2222            continue;
2223        }
2224        extras.insert(key.clone(), value.clone());
2225    }
2226    if extras.is_empty() {
2227        data.get("data").map(stringify_json_pretty)
2228    } else {
2229        if let Some(nested) = data.get("data") {
2230            extras.insert("data".to_string(), nested.clone());
2231        }
2232        Some(stringify_json_pretty(&Value::Object(extras)))
2233    }
2234}
2235
2236fn stringify_json_pretty(value: &Value) -> String {
2237    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
2238}
2239
2240fn format_call_tree_sections(
2241    record: &serde_json::Map<String, Value>,
2242    include_unresolved: bool,
2243) -> Vec<String> {
2244    let mut lines = Vec::new();
2245    render_call_tree_node(record, 0, &mut lines, include_unresolved);
2246    let warning = depth_warning(record, "depth_limited", "truncated");
2247    if !warning.is_empty() {
2248        lines.push(warning);
2249    }
2250    if lines.is_empty() {
2251        vec!["No call tree available.".to_string()]
2252    } else {
2253        lines
2254    }
2255}
2256
2257fn render_call_tree_node(
2258    node: &serde_json::Map<String, Value>,
2259    depth: usize,
2260    lines: &mut Vec<String>,
2261    include_unresolved: bool,
2262) {
2263    let name = string_field(node, "name").unwrap_or("(unknown)");
2264    let file = shorten_path(string_field(node, "file").unwrap_or("(unknown file)"));
2265    let line = number_field(node, "line");
2266    let unresolved = if node.get("resolved").and_then(Value::as_bool) == Some(false) {
2267        " [unresolved]"
2268    } else {
2269        ""
2270    };
2271    let name_match = name_match_edge_marker(node);
2272    let location = match line {
2273        Some(line) => format!("[{file}:{line}]"),
2274        None => format!("[{file}]"),
2275    };
2276    lines.push(tree_line(
2277        depth,
2278        &format!("{name} {location}{unresolved}{name_match}"),
2279    ));
2280
2281    let children = records_field(node, "children");
2282    if include_unresolved {
2283        for child in children {
2284            render_call_tree_node(child, depth + 1, lines, include_unresolved);
2285        }
2286        return;
2287    }
2288
2289    let unresolved_indices = children
2290        .iter()
2291        .enumerate()
2292        .filter_map(|(index, child)| is_unresolved_leaf(child).then_some(index))
2293        .collect::<Vec<_>>();
2294    if unresolved_indices.is_empty() {
2295        for child in children {
2296            render_call_tree_node(child, depth + 1, lines, include_unresolved);
2297        }
2298        return;
2299    }
2300
2301    let mut summary_inserted = false;
2302    for (index, child) in children.iter().enumerate() {
2303        if unresolved_indices.contains(&index) {
2304            if !summary_inserted {
2305                let unresolved_leaves = unresolved_indices
2306                    .iter()
2307                    .filter_map(|idx| children.get(*idx).copied())
2308                    .collect::<Vec<_>>();
2309                lines.push(tree_line(
2310                    depth + 1,
2311                    &unresolved_summary_text(&unresolved_leaves),
2312                ));
2313                summary_inserted = true;
2314            }
2315            continue;
2316        }
2317        render_call_tree_node(child, depth + 1, lines, include_unresolved);
2318    }
2319}
2320
2321fn is_unresolved_leaf(node: &serde_json::Map<String, Value>) -> bool {
2322    node.get("resolved").and_then(Value::as_bool) == Some(false)
2323        && records_field(node, "children").is_empty()
2324}
2325
2326fn unresolved_summary_text(nodes: &[&serde_json::Map<String, Value>]) -> String {
2327    let mut distinct_names = Vec::new();
2328    for node in nodes {
2329        let name = string_field(node, "name").unwrap_or("(unknown)");
2330        if !distinct_names.iter().any(|seen| seen == name) {
2331            distinct_names.push(name.to_string());
2332        }
2333    }
2334
2335    let displayed = distinct_names
2336        .iter()
2337        .take(UNRESOLVED_SUMMARY_NAME_LIMIT)
2338        .cloned()
2339        .collect::<Vec<_>>();
2340    let hidden = distinct_names.len().saturating_sub(displayed.len());
2341    let names = if hidden > 0 {
2342        format!("{}, … (+{hidden} more)", displayed.join(", "))
2343    } else {
2344        displayed.join(", ")
2345    };
2346    let noun = if nodes.len() == 1 { "call" } else { "calls" };
2347    format!("+ {} unresolved external {noun}: {names}", nodes.len())
2348}
2349
2350fn format_callers_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2351    let groups = records_field(record, "callers");
2352    let warning = depth_warning(record, "depth_limited", "truncated");
2353    let hub_summary = hub_summary_line(record);
2354    let total = number_field(record, "total_callers").unwrap_or(0);
2355    let mut sections = vec![join_non_empty(&[
2356        Some(format!(
2357            "{total} caller{}",
2358            if total == 1 { "" } else { "s" }
2359        )),
2360        Some(format!(
2361            "{} file group{}",
2362            groups.len(),
2363            if groups.len() == 1 { "" } else { "s" }
2364        )),
2365        (!warning.is_empty()).then_some(warning),
2366    ])];
2367    if let Some(summary) = hub_summary {
2368        sections.push(summary);
2369    }
2370    for group in groups {
2371        sections.push(render_callers_group_lines(group).join("\n"));
2372    }
2373    sections
2374}
2375
2376fn render_callers_group_lines(group: &serde_json::Map<String, Value>) -> Vec<String> {
2377    let file = shorten_path(string_field(group, "file").unwrap_or("(unknown file)"));
2378    let mut lines = vec![file];
2379    let callers = records_field(group, "callers");
2380    let mut by_symbol_provenance: BTreeMap<String, Vec<i64>> = BTreeMap::new();
2381    for caller in callers {
2382        let symbol = string_field(caller, "symbol").unwrap_or("(unknown)");
2383        let provenance = if string_field(caller, "resolved_by") == Some("name_match") {
2384            "name_match"
2385        } else {
2386            "exact"
2387        };
2388        let key = format!("{symbol}\0{provenance}");
2389        let bucket = by_symbol_provenance.entry(key).or_default();
2390        if let Some(line) = number_field(caller, "line") {
2391            bucket.push(line);
2392        }
2393    }
2394    for (key, mut line_nums) in by_symbol_provenance {
2395        let symbol = key.split('\0').next().unwrap_or("(unknown)");
2396        let is_name_match = key.ends_with("\0name_match");
2397        line_nums.sort_unstable();
2398        let line_part = if line_nums.is_empty() {
2399            "?".to_string()
2400        } else {
2401            line_nums
2402                .iter()
2403                .map(ToString::to_string)
2404                .collect::<Vec<_>>()
2405                .join(", ")
2406        };
2407        let marker = if is_name_match { " ~" } else { "" };
2408        lines.push(format!("  ↳ {symbol}:{line_part}{marker}"));
2409    }
2410    lines
2411}
2412
2413fn format_trace_to_symbol_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2414    let path = records_field(record, "path");
2415    let complete = record.get("complete").and_then(Value::as_bool);
2416    let reason = string_field(record, "reason");
2417    if path.is_empty() {
2418        let prefix = if complete == Some(false) {
2419            "No complete path"
2420        } else {
2421            "No path"
2422        };
2423        return vec![match reason {
2424            Some(reason) => format!("{prefix} ({reason})"),
2425            None => prefix.to_string(),
2426        }];
2427    }
2428
2429    let mut lines = vec![format!(
2430        "{} hop{}",
2431        path.len(),
2432        if path.len() == 1 { "" } else { "s" }
2433    )];
2434    for (index, hop) in path.iter().enumerate() {
2435        let symbol = string_field(hop, "symbol").unwrap_or("(unknown)");
2436        let file = shorten_path(string_field(hop, "file").unwrap_or("(unknown file)"));
2437        let line = number_field(hop, "line");
2438        let name_match = name_match_edge_marker(hop);
2439        let location = match line {
2440            Some(line) => format!("[{file}:{line}]"),
2441            None => format!("[{file}]"),
2442        };
2443        lines.push(tree_line(
2444            index + 1,
2445            &format!("{symbol} {location}{name_match}"),
2446        ));
2447    }
2448    lines
2449}
2450
2451fn format_trace_to_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2452    let paths = records_field(record, "paths");
2453    let warning = depth_warning(record, "max_depth_reached", "truncated_paths");
2454    let hub_summary = hub_summary_line(record);
2455    let total_paths = number_field(record, "total_paths").unwrap_or(paths.len() as i64);
2456    let total_paths_is_lower_bound = record
2457        .get("total_paths_is_lower_bound")
2458        .and_then(Value::as_bool)
2459        .unwrap_or(false);
2460    let entry_points = number_field(record, "entry_points_found").unwrap_or(0);
2461    let mut sections = vec![join_non_empty(&[
2462        Some(format!(
2463            "{}{total_paths} path{}",
2464            if total_paths_is_lower_bound {
2465                "at least "
2466            } else {
2467                ""
2468            },
2469            if total_paths == 1 { "" } else { "s" }
2470        )),
2471        Some(format!(
2472            "{entry_points} entry point{}",
2473            if entry_points == 1 { "" } else { "s" }
2474        )),
2475        (!warning.is_empty()).then_some(warning),
2476    ])];
2477    if let Some(summary) = hub_summary {
2478        sections.push(summary);
2479    }
2480    if paths.is_empty() {
2481        sections.push("No entry paths found.".to_string());
2482    }
2483    for (index, path) in paths.iter().enumerate() {
2484        let mut lines = Vec::new();
2485        render_trace_path(path, index, &mut lines);
2486        sections.push(lines.join("\n"));
2487    }
2488    sections
2489}
2490
2491fn render_trace_path(path: &serde_json::Map<String, Value>, index: usize, lines: &mut Vec<String>) {
2492    lines.push(format!("Path {}", index + 1));
2493    for (hop_index, hop) in records_field(path, "hops").iter().enumerate() {
2494        let symbol = string_field(hop, "symbol").unwrap_or("(unknown)");
2495        let file = shorten_path(string_field(hop, "file").unwrap_or("(unknown file)"));
2496        let line = number_field(hop, "line");
2497        let entry = if hop.get("is_entry_point").and_then(Value::as_bool) == Some(true) {
2498            " [entry]"
2499        } else {
2500            ""
2501        };
2502        let name_match = name_match_edge_marker(hop);
2503        let location = match line {
2504            Some(line) => format!("[{file}:{line}]"),
2505            None => format!("[{file}]"),
2506        };
2507        lines.push(tree_line(
2508            hop_index + 1,
2509            &format!("{symbol}{entry} {location}{name_match}"),
2510        ));
2511    }
2512}
2513
2514fn format_impact_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2515    let callers = records_field(record, "callers");
2516    let warning = depth_warning(record, "depth_limited", "truncated");
2517    let hub_summary = hub_summary_line(record);
2518    let total_affected = number_field(record, "total_affected").unwrap_or(callers.len() as i64);
2519    let affected_files = number_field(record, "affected_files").unwrap_or(0);
2520    let mut sections = vec![join_non_empty(&[
2521        Some(format!(
2522            "{total_affected} affected call site{}",
2523            if total_affected == 1 { "" } else { "s" }
2524        )),
2525        Some(format!(
2526            "{affected_files} file{}",
2527            if affected_files == 1 { "" } else { "s" }
2528        )),
2529        (!warning.is_empty()).then_some(warning),
2530    ])];
2531    if let Some(summary) = hub_summary {
2532        sections.push(summary);
2533    }
2534    if callers.is_empty() {
2535        sections.push("No impacted callers found.".to_string());
2536    }
2537    for caller in callers {
2538        let file = shorten_path(string_field(caller, "caller_file").unwrap_or("(unknown file)"));
2539        let symbol = string_field(caller, "caller_symbol").unwrap_or("(unknown)");
2540        let line = number_field(caller, "line").unwrap_or(0);
2541        let entry = if caller.get("is_entry_point").and_then(Value::as_bool) == Some(true) {
2542            " [entry]"
2543        } else {
2544            ""
2545        };
2546        let name_match = name_match_edge_marker(caller);
2547        let expression = string_field(caller, "call_expression");
2548        let params = caller
2549            .get("parameters")
2550            .and_then(Value::as_array)
2551            .map(|items| {
2552                items
2553                    .iter()
2554                    .map(value_to_plain_string)
2555                    .collect::<Vec<_>>()
2556                    .join(", ")
2557            })
2558            .unwrap_or_default();
2559        let mut lines = vec![
2560            format!("{file}:{line}"),
2561            format!("  ↳ {symbol}{entry}{name_match}"),
2562        ];
2563        if let Some(expression) = expression {
2564            lines.push(format!("  {expression}"));
2565        }
2566        if !params.is_empty() {
2567            lines.push(format!("  params: {params}"));
2568        }
2569        sections.push(lines.join("\n"));
2570    }
2571    sections
2572}
2573
2574fn format_trace_data_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2575    let hops = records_field(record, "hops");
2576    let mut sections = vec![join_non_empty(&[
2577        Some(format!(
2578            "{} hop{}",
2579            hops.len(),
2580            if hops.len() == 1 { "" } else { "s" }
2581        )),
2582        (record.get("depth_limited").and_then(Value::as_bool) == Some(true))
2583            .then_some("(depth limited)".to_string()),
2584    ])];
2585    if hops.is_empty() {
2586        sections.push("No data-flow hops found.".to_string());
2587    }
2588    for (index, hop) in hops.iter().enumerate() {
2589        let file = shorten_path(string_field(hop, "file").unwrap_or("(unknown file)"));
2590        let symbol = string_field(hop, "symbol").unwrap_or("(unknown)");
2591        let variable = string_field(hop, "variable").unwrap_or("(unknown)");
2592        let line = number_field(hop, "line").unwrap_or(0);
2593        let approximate = if hop.get("approximate").and_then(Value::as_bool) == Some(true) {
2594            " [approx]"
2595        } else {
2596            ""
2597        };
2598        let name_match = name_match_edge_marker(hop);
2599        let flow_type = string_field(hop, "flow_type").unwrap_or("flow");
2600        sections.push(tree_line(
2601            index,
2602            &format!("{variable} {flow_type} {symbol} [{file}:{line}]{approximate}{name_match}"),
2603        ));
2604    }
2605    sections
2606}
2607
2608fn records_field<'a>(
2609    record: &'a serde_json::Map<String, Value>,
2610    key: &str,
2611) -> Vec<&'a serde_json::Map<String, Value>> {
2612    record
2613        .get(key)
2614        .and_then(Value::as_array)
2615        .map(|items| items.iter().filter_map(Value::as_object).collect())
2616        .unwrap_or_default()
2617}
2618
2619fn string_field<'a>(record: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a str> {
2620    record.get(key).and_then(Value::as_str)
2621}
2622
2623fn number_field(record: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
2624    let value = record.get(key)?;
2625    value
2626        .as_i64()
2627        .or_else(|| value.as_u64().and_then(|n| i64::try_from(n).ok()))
2628}
2629
2630fn shorten_path(path: &str) -> String {
2631    let Some(home) = home_dir() else {
2632        return path.to_string();
2633    };
2634    let home = home.to_string_lossy().to_string();
2635    if path.starts_with(&home) {
2636        format!("~{}", &path[home.len()..])
2637    } else {
2638        path.to_string()
2639    }
2640}
2641
2642fn home_dir() -> Option<PathBuf> {
2643    std::env::var_os("HOME")
2644        .or_else(|| std::env::var_os("USERPROFILE"))
2645        .map(PathBuf::from)
2646}
2647
2648fn tree_line(depth: usize, text: &str) -> String {
2649    format!(
2650        "{}{}{}",
2651        "  ".repeat(depth),
2652        if depth == 0 { "" } else { "↳ " },
2653        text
2654    )
2655}
2656
2657fn name_match_edge_marker(record: &serde_json::Map<String, Value>) -> &'static str {
2658    if string_field(record, "resolved_by") == Some("name_match") {
2659        " ~"
2660    } else {
2661        ""
2662    }
2663}
2664
2665fn depth_warning(
2666    response: &serde_json::Map<String, Value>,
2667    depth_field: &str,
2668    truncated_field: &str,
2669) -> String {
2670    let limited = response.get(depth_field).and_then(Value::as_bool);
2671    let truncated = number_field(response, truncated_field).unwrap_or(0);
2672    if limited != Some(true) && truncated == 0 {
2673        return String::new();
2674    }
2675    let detail = if truncated > 0 {
2676        format!(", {truncated} truncated")
2677    } else {
2678        String::new()
2679    };
2680    format!("(depth limited{detail})")
2681}
2682
2683fn hub_summary_line(response: &serde_json::Map<String, Value>) -> Option<String> {
2684    response
2685        .get("hub_summary")
2686        .and_then(Value::as_object)
2687        .and_then(|summary| string_field(summary, "message"))
2688        .map(str::to_string)
2689}
2690
2691fn join_non_empty(parts: &[Option<String>]) -> String {
2692    parts
2693        .iter()
2694        .filter_map(|part| part.as_deref())
2695        .filter(|part| !part.is_empty())
2696        .collect::<Vec<_>>()
2697        .join(" · ")
2698}
2699
2700fn value_to_plain_string(value: &Value) -> String {
2701    value
2702        .as_str()
2703        .map(str::to_string)
2704        .unwrap_or_else(|| value.to_string())
2705}
2706
2707// Status has no TypeScript wrapper; preserve the pretty JSON summary and add
2708// one deliberately thin human-readable view of the raw memory section.
2709fn format_status(data: &Value) -> String {
2710    if let Some(text) = data
2711        .get("text")
2712        .and_then(Value::as_str)
2713        .filter(|s| !s.is_empty())
2714    {
2715        return text.to_string();
2716    }
2717
2718    // Compact human-readable summary. The full snapshot stays available to
2719    // first-party consumers via structuredContent; the agent-facing text was
2720    // previously a pretty-printed JSON dump that reached six figures of
2721    // characters on fleet-scale daemons.
2722    let mut lines = Vec::new();
2723    let version = data.get("version").and_then(Value::as_str).unwrap_or("?");
2724    let root = data
2725        .get("project_root")
2726        .and_then(Value::as_str)
2727        .unwrap_or("?");
2728    lines.push(format!("AFT {version} — {root}"));
2729
2730    if data.get("degraded").and_then(Value::as_bool) == Some(true) {
2731        let reasons = data
2732            .get("degraded_reasons")
2733            .and_then(Value::as_array)
2734            .map(|reasons| {
2735                reasons
2736                    .iter()
2737                    .filter_map(Value::as_str)
2738                    .collect::<Vec<_>>()
2739                    .join(", ")
2740            })
2741            .filter(|s| !s.is_empty())
2742            .unwrap_or_else(|| "unspecified".to_string());
2743        lines.push(format!("DEGRADED: {reasons}"));
2744    }
2745
2746    let search = status_field(data, "search_index", "status");
2747    let semantic = {
2748        let state = status_field(data, "semantic_index", "status");
2749        let stage = data
2750            .pointer("/semantic_index/stage")
2751            .and_then(Value::as_str);
2752        let model = data
2753            .pointer("/semantic_index/model")
2754            .and_then(Value::as_str);
2755        let mut s = state;
2756        if let Some(stage) = stage {
2757            s = format!("{s} ({stage})");
2758        }
2759        if let Some(model) = model {
2760            s = format!("{s} [{model}]");
2761        }
2762        s
2763    };
2764    let callgraph = data
2765        .pointer("/features/callgraph_store")
2766        .and_then(Value::as_bool)
2767        .map(|on| if on { "enabled" } else { "disabled" })
2768        .unwrap_or("?");
2769    lines.push(format!(
2770        "indexes: search {search} | semantic {semantic} | callgraph {callgraph}"
2771    ));
2772
2773    if let Some(features) = data.get("features").and_then(Value::as_object) {
2774        let flags = features
2775            .iter()
2776            .map(|(name, value)| match value {
2777                Value::Bool(true) => format!("{name} on"),
2778                Value::Bool(false) => format!("{name} off"),
2779                other => format!("{name} {}", value_as_display(other)),
2780            })
2781            .collect::<Vec<_>>()
2782            .join(", ");
2783        lines.push(format!("features: {flags}"));
2784    }
2785
2786    if let Some(disk) = data.get("disk").and_then(Value::as_object) {
2787        let storage = disk
2788            .get("storage_dir")
2789            .and_then(Value::as_str)
2790            .unwrap_or("?");
2791        let trigram = format_optional_memory_bytes(disk.get("trigram_disk_bytes"));
2792        let semantic_disk = format_optional_memory_bytes(disk.get("semantic_disk_bytes"));
2793        lines.push(format!(
2794            "storage: {storage} (trigram {trigram}, semantic {semantic_disk})"
2795        ));
2796    }
2797
2798    let tracked = data
2799        .pointer("/session/tracked_files")
2800        .and_then(Value::as_u64)
2801        .unwrap_or(0);
2802    let checkpoints = data
2803        .pointer("/session/checkpoints")
2804        .and_then(Value::as_u64)
2805        .unwrap_or(0);
2806    let lsp = data.get("lsp_servers").and_then(Value::as_u64).unwrap_or(0);
2807    lines.push(format!(
2808        "session: {tracked} tracked file(s), {checkpoints} checkpoint(s) | lsp servers: {lsp}"
2809    ));
2810
2811    if let Some(memory) = data.get("memory") {
2812        lines.push(String::new());
2813        lines.push(format_memory_block(memory));
2814    }
2815
2816    lines.join("\n")
2817}
2818
2819fn status_field(data: &Value, section: &str, key: &str) -> String {
2820    data.pointer(&format!("/{section}/{key}"))
2821        .and_then(Value::as_str)
2822        .unwrap_or("?")
2823        .to_string()
2824}
2825
2826fn value_as_display(value: &Value) -> String {
2827    match value {
2828        Value::String(s) => s.clone(),
2829        other => other.to_string(),
2830    }
2831}
2832
2833fn format_memory_block(memory: &Value) -> String {
2834    let process = memory.get("process").unwrap_or(&Value::Null);
2835    let rss = format_optional_memory_bytes(process.get("rss_bytes"));
2836    let attributed = format_optional_memory_bytes(process.get("total_attributed_bytes"));
2837    let unattributed = format_optional_memory_bytes(process.get("unattributed_bytes"));
2838    let mut lines = vec![format!(
2839        "Memory: RSS {rss} | attributed {attributed} | unattributed {unattributed}"
2840    )];
2841    if let Some(roots) = memory.get("roots").and_then(Value::as_object) {
2842        for (root, estimate) in roots {
2843            let total = format_optional_memory_bytes(estimate.get("attributed_bytes"));
2844            let subsystems = [
2845                ("semantic", "semantic"),
2846                ("trigram", "trigram"),
2847                ("symbols", "symbols"),
2848                ("callgraph", "callgraph"),
2849                ("inspect", "inspect"),
2850                ("bash", "bash"),
2851                ("lsp", "lsp"),
2852                ("parser_pool", "parsers"),
2853            ]
2854            .iter()
2855            .map(|(key, label)| {
2856                let subsystem = estimate.get(*key).unwrap_or(&Value::Null);
2857                let value = if subsystem.get("status").and_then(Value::as_str) == Some("busy") {
2858                    "busy".to_string()
2859                } else {
2860                    format_optional_memory_bytes(subsystem.get("estimated_bytes"))
2861                };
2862                format!("{label} {value}")
2863            })
2864            .collect::<Vec<_>>()
2865            .join(", ");
2866            lines.push(format!("  {root}: {total} ({subsystems})"));
2867        }
2868    }
2869    lines.join("\n")
2870}
2871
2872fn format_optional_memory_bytes(value: Option<&Value>) -> String {
2873    let Some(value) = value else {
2874        return "not estimated".to_string();
2875    };
2876    let (sign, magnitude) = if let Some(bytes) = value.as_i64() {
2877        (if bytes < 0 { "-" } else { "" }, bytes.unsigned_abs())
2878    } else if let Some(bytes) = value.as_u64() {
2879        ("", bytes)
2880    } else {
2881        return "not estimated".to_string();
2882    };
2883    let magnitude = magnitude as f64;
2884    if magnitude >= 1024.0 * 1024.0 {
2885        format!("{sign}{:.1} MiB", magnitude / (1024.0 * 1024.0))
2886    } else if magnitude >= 1024.0 {
2887        format!("{sign}{:.1} KiB", magnitude / 1024.0)
2888    } else {
2889        format!("{sign}{} B", magnitude as u64)
2890    }
2891}
2892
2893#[cfg(test)]
2894mod move_format_tests {
2895    use super::*;
2896    use serde_json::json;
2897
2898    #[test]
2899    fn source_delete_failed_renders_partially_moved_not_moved() {
2900        let ctx = FormatContext {
2901            move_file_arg: Some("a.ts".into()),
2902            move_dest_arg: Some("b.ts".into()),
2903            ..Default::default()
2904        };
2905        let rendered = format_move(
2906            &json!({
2907                "file": "/repo/src/a.ts",
2908                "destination": "/repo/src/b.ts",
2909                "moved": true,
2910                "complete": false,
2911                "source_delete_failed": true,
2912                "warning": "destination was written, but source file could not be deleted after copy: permission denied. Both paths now exist; retry deleting the source or accept the duplicate."
2913            }),
2914            &ctx,
2915        );
2916
2917        assert!(
2918            rendered.starts_with("Partially moved a.ts → b.ts"),
2919            "expected partial move header:\n{rendered}"
2920        );
2921        assert!(
2922            rendered.contains("source deletion failed: permission denied"),
2923            "expected extracted delete error:\n{rendered}"
2924        );
2925        assert!(
2926            rendered.contains("Both paths exist"),
2927            "expected both-paths guidance:\n{rendered}"
2928        );
2929        assert!(
2930            !rendered.starts_with("Moved "),
2931            "must not look like a finished move:\n{rendered}"
2932        );
2933        assert!(
2934            !rendered.contains("Moved a.ts → b.ts"),
2935            "finished-move phrasing must be absent:\n{rendered}"
2936        );
2937    }
2938
2939    #[test]
2940    fn successful_move_still_renders_moved() {
2941        let ctx = FormatContext {
2942            move_file_arg: Some("a.ts".into()),
2943            move_dest_arg: Some("b.ts".into()),
2944            ..Default::default()
2945        };
2946        let rendered = format_move(
2947            &json!({
2948                "file": "/repo/src/a.ts",
2949                "destination": "/repo/src/b.ts",
2950                "moved": true
2951            }),
2952            &ctx,
2953        );
2954        assert_eq!(rendered, "Moved a.ts → b.ts");
2955    }
2956}
2957
2958#[cfg(test)]
2959mod status_memory_tests {
2960    use super::*;
2961    use serde_json::json;
2962
2963    #[test]
2964    fn status_text_renders_compact_memory_block() {
2965        let data = json!({
2966            "version": "test",
2967            "memory": {
2968                "process": {
2969                    "rss_bytes": 8 * 1024 * 1024,
2970                    "total_attributed_bytes": 3 * 1024 * 1024,
2971                    "unattributed_bytes": 5 * 1024 * 1024
2972                },
2973                "roots": {
2974                    "/repo": {
2975                        "attributed_bytes": 3 * 1024 * 1024,
2976                        "semantic": {"status": "ready", "estimated_bytes": 2 * 1024 * 1024},
2977                        "trigram": {"status": "ready", "estimated_bytes": 1024 * 1024},
2978                        "symbols": {"status": "ready", "estimated_bytes": 0},
2979                        "callgraph": {"status": "ready", "estimated_bytes": null},
2980                        "inspect": {"status": "ready", "estimated_bytes": 0},
2981                        "bash": {"status": "ready", "estimated_bytes": 0},
2982                        "lsp": {"status": "ready", "estimated_bytes": 0},
2983                        "parser_pool": {"status": "ready", "estimated_bytes": null}
2984                    }
2985                }
2986            }
2987        });
2988        let rendered = format_status(&data);
2989        assert!(
2990            rendered.contains("Memory: RSS 8.0 MiB | attributed 3.0 MiB | unattributed 5.0 MiB")
2991        );
2992        assert!(rendered.contains("/repo: 3.0 MiB (semantic 2.0 MiB, trigram 1.0 MiB"));
2993        assert!(!rendered.contains("\"memory\""));
2994    }
2995}
2996
2997#[cfg(test)]
2998mod callgraph_format_tests {
2999    use super::*;
3000    use serde_json::json;
3001
3002    #[test]
3003    fn trace_to_formats_budgeted_path_count_as_lower_bound() {
3004        let rendered = format_callgraph(
3005            "trace_to",
3006            &json!({
3007                "total_paths": 7,
3008                "total_paths_is_lower_bound": true,
3009                "entry_points_found": 2,
3010                "hub_summary": {
3011                    "message": "Next: at least 7 paths — showing 2; traversal capped; narrow with scope"
3012                },
3013                "paths": []
3014            }),
3015            false,
3016        );
3017
3018        assert!(rendered.starts_with("at least 7 paths · 2 entry points"));
3019        assert!(rendered.contains("Next: at least 7 paths"));
3020    }
3021
3022    #[test]
3023    fn trace_to_exact_path_count_format_is_unchanged() {
3024        let rendered = format_callgraph(
3025            "trace_to",
3026            &json!({
3027                "total_paths": 1,
3028                "entry_points_found": 1,
3029                "paths": []
3030            }),
3031            false,
3032        );
3033
3034        assert!(rendered.starts_with("1 path · 1 entry point"));
3035        assert!(!rendered.contains("at least"));
3036    }
3037}
3038
3039#[cfg(test)]
3040mod outline_format_tests {
3041    use super::*;
3042    use serde_json::json;
3043
3044    #[test]
3045    fn directory_outline_preserves_walk_truncation_footer() {
3046        let response = Response::success(
3047            "1",
3048            json!({
3049                "text": "src/\n  a.rs (rs)",
3050                "complete": false,
3051                "walk_truncated": true
3052            }),
3053        );
3054
3055        let formatted = format_outline(&response, OutlineMode::DirectoryJson);
3056        assert!(formatted.contains("src/\n  a.rs (rs)"));
3057        assert!(formatted.contains("⚠ Partial result: walk truncated at 200 files. Some files in this directory were not indexed."));
3058    }
3059}