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