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