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    if data.get("hashline").and_then(Value::as_bool) == Some(true) {
951        return data
952            .get("output")
953            .and_then(Value::as_str)
954            .unwrap_or_default()
955            .to_string();
956    }
957    let mut result = format_edit_summary(data);
958
959    if let Some(note) = format_glob_skip_reasons_note(data.get("format_skip_reasons")) {
960        result.push_str("\n\n");
961        result.push_str(&note);
962    }
963    if data.get("no_op").and_then(Value::as_bool) == Some(true) {
964        result.push_str(
965            "\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.",
966        );
967    }
968    append_lsp_error_lines(&mut result, data, false);
969    append_lsp_server_notes(&mut result, data);
970    result
971}
972
973fn format_glob_skip_reasons_note(reasons: Option<&Value>) -> Option<String> {
974    let actionable = reasons?
975        .as_array()?
976        .iter()
977        .filter_map(Value::as_str)
978        .filter(|reason| {
979            matches!(
980                *reason,
981                "formatter_not_installed" | "formatter_excluded_path" | "timeout" | "error"
982            )
983        })
984        .collect::<std::collections::BTreeSet<_>>();
985    if actionable.is_empty() {
986        None
987    } else {
988        Some(format!(
989            "Note: formatter skipped some glob edit result file(s): {}. See per-file format_skipped_reason values for details.",
990            actionable.into_iter().collect::<Vec<_>>().join(", ")
991        ))
992    }
993}
994
995fn append_lsp_error_lines(output: &mut String, data: &Value, trailing_newline: bool) {
996    let errors = data
997        .get("lsp_diagnostics")
998        .and_then(Value::as_array)
999        .map(|items| {
1000            items
1001                .iter()
1002                .filter(|d| d.get("severity").and_then(Value::as_str) == Some("error"))
1003                .collect::<Vec<_>>()
1004        })
1005        .unwrap_or_default();
1006    if errors.is_empty() {
1007        return;
1008    }
1009
1010    output.push_str("\n\nLSP errors detected, please fix:\n");
1011    let lines = errors
1012        .iter()
1013        .map(|d| {
1014            let line = d
1015                .get("line")
1016                .and_then(Value::as_u64)
1017                .map(|n| n.to_string())
1018                .unwrap_or_else(|| "undefined".to_string());
1019            let message = d
1020                .get("message")
1021                .and_then(Value::as_str)
1022                .unwrap_or("undefined");
1023            format!("  Line {line}: {message}")
1024        })
1025        .collect::<Vec<_>>();
1026    output.push_str(&lines.join("\n"));
1027    if trailing_newline {
1028        output.push('\n');
1029    }
1030}
1031
1032fn append_lsp_server_notes(output: &mut String, data: &Value) {
1033    let pending = string_array(data.get("lsp_pending_servers"));
1034    if !pending.is_empty() {
1035        output.push_str(&format!(
1036            "\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.",
1037            pending.join(", ")
1038        ));
1039    }
1040    let exited = string_array(data.get("lsp_exited_servers"));
1041    if !exited.is_empty() {
1042        output.push_str(&format!(
1043            "\n\nNote: LSP server(s) exited during this edit: {}. Their diagnostics could not be collected.",
1044            exited.join(", ")
1045        ));
1046    }
1047}
1048
1049// Mirrors packages/aft-bridge/src/edit-summary.ts formatEditSummary.
1050fn format_edit_summary(data: &Value) -> String {
1051    if data.get("rolled_back").and_then(Value::as_bool) == Some(true) {
1052        return "Edit rolled back: the change produced invalid syntax, so the file was left unchanged."
1053            .to_string();
1054    }
1055
1056    if let Some(n) = data.get("files_modified").and_then(Value::as_u64) {
1057        let n = n as usize;
1058        return format!(
1059            "Applied edits to {} file{}.",
1060            n,
1061            if n == 1 { "" } else { "s" }
1062        );
1063    }
1064
1065    if let Some(files) = data.get("total_files").and_then(Value::as_u64) {
1066        let files = files as usize;
1067        let reps = data
1068            .get("total_replacements")
1069            .and_then(Value::as_u64)
1070            .unwrap_or(0) as usize;
1071        return format!(
1072            "Edited {} file{} ({} replacement{}).",
1073            files,
1074            if files == 1 { "" } else { "s" },
1075            reps,
1076            if reps == 1 { "" } else { "s" }
1077        );
1078    }
1079
1080    let additions = data
1081        .get("diff")
1082        .and_then(Value::as_object)
1083        .and_then(|d| d.get("additions"))
1084        .and_then(Value::as_u64)
1085        .unwrap_or(0) as usize;
1086    let deletions = data
1087        .get("diff")
1088        .and_then(Value::as_object)
1089        .and_then(|d| d.get("deletions"))
1090        .and_then(Value::as_u64)
1091        .unwrap_or(0) as usize;
1092    let counts = format!("+{additions}/-{deletions}");
1093
1094    if data.get("created").and_then(Value::as_bool) == Some(true) {
1095        let mut s = format!("Created file ({counts}).");
1096        if is_truthy_formatted(data) {
1097            s.push_str(&format_auto_formatted_suffix(data));
1098        }
1099        return s;
1100    }
1101
1102    let mut detail = counts.clone();
1103    if let Some(n) = data.get("edits_applied").and_then(Value::as_u64) {
1104        if n > 1 {
1105            detail = format!("{counts}, {n} edits");
1106        }
1107    } else if let Some(n) = data.get("replacements").and_then(Value::as_u64) {
1108        if n > 1 {
1109            detail = format!("{counts}, {n} replacements");
1110        }
1111    }
1112
1113    let mut s = format!("Edited ({detail}).");
1114    if is_truthy_formatted(data) {
1115        s.push_str(&format_auto_formatted_suffix(data));
1116    }
1117    s
1118}
1119
1120fn is_truthy_formatted(data: &Value) -> bool {
1121    data.get("formatted")
1122        .and_then(Value::as_bool)
1123        .unwrap_or(false)
1124}
1125
1126fn format_auto_formatted_suffix(data: &Value) -> String {
1127    let reformatted = data.get("reformatted").and_then(Value::as_object);
1128    if let Some(text) = reformatted
1129        .and_then(|r| r.get("text"))
1130        .and_then(Value::as_str)
1131        .filter(|s| !s.is_empty())
1132    {
1133        return format!(
1134            "\nAuto-formatted — the formatter reflowed your edit. On disk now:\n{text}"
1135        );
1136    }
1137    if reformatted
1138        .and_then(|r| r.get("extensive"))
1139        .and_then(Value::as_bool)
1140        == Some(true)
1141    {
1142        return " Auto-formatted — extensive reflow; re-read the file before your next anchored edit."
1143            .to_string();
1144    }
1145    " Auto-formatted.".to_string()
1146}
1147
1148// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createReadTool.
1149fn format_read(data: &Value, agent_specified_range: bool) -> String {
1150    if let Some(entries) = data.get("entries").and_then(Value::as_array) {
1151        return entries
1152            .iter()
1153            .filter_map(|e| e.as_str())
1154            .collect::<Vec<_>>()
1155            .join("\n");
1156    }
1157
1158    if let Some(attachment_line) = format_read_attachments(data) {
1159        return attachment_line;
1160    }
1161
1162    if data.get("binary").and_then(Value::as_bool).unwrap_or(false) {
1163        return data
1164            .get("message")
1165            .and_then(Value::as_str)
1166            .unwrap_or("Binary file")
1167            .to_string();
1168    }
1169
1170    let mut text = data
1171        .get("content")
1172        .and_then(Value::as_str)
1173        .unwrap_or("")
1174        .to_string();
1175    text.push_str(&format_read_footer(agent_specified_range, data));
1176    text
1177}
1178
1179fn format_read_attachments(data: &Value) -> Option<String> {
1180    let attachments = data.get("attachments")?.as_array()?;
1181    let has_host_attachment = attachments.iter().any(|attachment| {
1182        attachment.get("mime").and_then(Value::as_str).is_some()
1183            && attachment.get("data").and_then(Value::as_str).is_some()
1184    });
1185    if !has_host_attachment {
1186        return None;
1187    }
1188
1189    if let Some(content) = data
1190        .get("content")
1191        .and_then(Value::as_str)
1192        .filter(|content| !content.is_empty())
1193    {
1194        return Some(content.to_string());
1195    }
1196
1197    let first = attachments.first()?.as_object()?;
1198    let kind = first.get("kind").and_then(Value::as_str).unwrap_or("file");
1199    let mime = first
1200        .get("mime")
1201        .and_then(Value::as_str)
1202        .unwrap_or("application/octet-stream");
1203    let size = first
1204        .get("bytes")
1205        .and_then(Value::as_u64)
1206        .map(format_attachment_size);
1207
1208    if kind == "image" || mime.starts_with("image/") {
1209        let dimensions = match (
1210            first.get("width").and_then(Value::as_u64),
1211            first.get("height").and_then(Value::as_u64),
1212        ) {
1213            (Some(width), Some(height)) => format!(", {width}×{height}"),
1214            _ => String::new(),
1215        };
1216        let resized = if first.get("resized").and_then(Value::as_bool) == Some(true) {
1217            ", resized"
1218        } else {
1219            ""
1220        };
1221        let size = size.map(|size| format!(", {size}")).unwrap_or_default();
1222        return Some(format!("Read image ({mime}{dimensions}{resized}{size})."));
1223    }
1224
1225    if kind == "pdf" || mime == "application/pdf" {
1226        let size = size.map(|size| format!(" ({size})")).unwrap_or_default();
1227        return Some(format!("Read PDF{size}."));
1228    }
1229
1230    let size = size.map(|size| format!(", {size}")).unwrap_or_default();
1231    Some(format!("Read attachment ({mime}{size})."))
1232}
1233
1234fn format_attachment_size(bytes: u64) -> String {
1235    if bytes >= 1024 * 1024 {
1236        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
1237    } else if bytes >= 1024 {
1238        format!("{} KB", bytes.div_ceil(1024))
1239    } else {
1240        format!("{bytes} bytes")
1241    }
1242}
1243
1244fn format_read_footer(agent_specified_range: bool, data: &Value) -> String {
1245    if agent_specified_range {
1246        return String::new();
1247    }
1248    if !data
1249        .get("truncated")
1250        .and_then(Value::as_bool)
1251        .unwrap_or(false)
1252    {
1253        // Not truncated. If the caller read the file whole and it came back
1254        // large, offer a soft note that ranges exist — but only when the
1255        // caller did not already pick a range (checked above).
1256        let content_len = data
1257            .get("content")
1258            .and_then(Value::as_str)
1259            .map(str::len)
1260            .unwrap_or(0);
1261        if content_len > READ_SOFT_NOTE_BYTES {
1262            return READ_SOFT_NOTE.to_string();
1263        }
1264        return String::new();
1265    }
1266    let start = data.get("start_line").and_then(Value::as_u64);
1267    let end = data.get("end_line").and_then(Value::as_u64);
1268    let total = data.get("total_lines").and_then(Value::as_u64);
1269    match (start, end, total) {
1270        (Some(start), Some(end), Some(total)) => format!(
1271            "\n(Showing lines {start}-{end} of {total}. Use startLine/endLine or offset/limit to read other sections.)"
1272        ),
1273        _ => String::new(),
1274    }
1275}
1276
1277// Mirrors packages/opencode-plugin/src/tools/search.ts formatGrepOutput.
1278fn format_grep(data: &Value) -> String {
1279    if let Some(text) = data.get("text").and_then(Value::as_str) {
1280        return text.to_string();
1281    }
1282
1283    let matches = data
1284        .get("matches")
1285        .and_then(Value::as_array)
1286        .cloned()
1287        .unwrap_or_default();
1288    let total_matches = data
1289        .get("total_matches")
1290        .and_then(Value::as_u64)
1291        .unwrap_or(matches.len() as u64);
1292    let files_with_matches = data
1293        .get("files_with_matches")
1294        .and_then(Value::as_u64)
1295        .unwrap_or_else(|| {
1296            matches
1297                .iter()
1298                .filter_map(|m| m.get("file").and_then(Value::as_str))
1299                .collect::<std::collections::BTreeSet<_>>()
1300                .len() as u64
1301        });
1302
1303    if matches.is_empty() {
1304        return format!("Found {total_matches} match across {files_with_matches} file");
1305    }
1306
1307    let body = matches
1308        .iter()
1309        .map(|m| {
1310            let file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
1311            let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
1312            let text = m
1313                .get("line_text")
1314                .or_else(|| m.get("text"))
1315                .and_then(Value::as_str)
1316                .unwrap_or("");
1317            format!("{file}:{line}: {text}")
1318        })
1319        .collect::<Vec<_>>()
1320        .join("\n");
1321    format!("{body}\n\nFound {total_matches} match across {files_with_matches} file")
1322}
1323
1324fn format_ast_search(data: &Value) -> String {
1325    let matches = data.get("matches").and_then(Value::as_array);
1326    let match_count = data
1327        .get("total_matches")
1328        .and_then(Value::as_u64)
1329        .unwrap_or_else(|| matches.map(|m| m.len() as u64).unwrap_or(0));
1330    let files_searched = data
1331        .get("files_searched")
1332        .and_then(Value::as_u64)
1333        .unwrap_or(0);
1334    let files_with_matches = data
1335        .get("files_with_matches")
1336        .and_then(Value::as_u64)
1337        .unwrap_or(files_searched);
1338
1339    let mut output = if data.get("no_files_matched_scope").and_then(Value::as_bool) == Some(true) {
1340        let mut output =
1341            "No files matched the scope (paths/globs resolved to zero files)".to_string();
1342        append_scope_warnings(&mut output, data);
1343        output
1344    } else if match_count == 0 {
1345        let mut output = format!("No matches found (searched {files_searched} files)");
1346        append_scope_warnings(&mut output, data);
1347        append_hint(&mut output, data);
1348        output
1349    } else {
1350        let mut output = format!(
1351            "Found {match_count} match(es) in {files_with_matches} file(s) ({files_searched} searched)\n\n"
1352        );
1353        if let Some(matches) = matches {
1354            for m in matches {
1355                let rel_file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
1356                let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
1357                output.push_str(&format!("{rel_file}:{line}\n"));
1358                if let Some(text) = m.get("text").and_then(Value::as_str) {
1359                    output.push_str(&format!("  {}\n", text.trim()));
1360                }
1361                if let Some(meta_vars) = m.get("meta_variables").and_then(Value::as_object) {
1362                    if !meta_vars.is_empty() {
1363                        for (key, value) in meta_vars {
1364                            output.push_str(&format!("  {key}: {}\n", js_template_string(value)));
1365                        }
1366                    }
1367                }
1368                output.push('\n');
1369            }
1370        }
1371        output
1372    };
1373
1374    if data.get("complete").and_then(Value::as_bool) == Some(false)
1375        || data
1376            .get("skipped_files")
1377            .and_then(Value::as_array)
1378            .is_some_and(|skipped| !skipped.is_empty())
1379    {
1380        output = append_ast_skipped_files(output, data.get("skipped_files"));
1381    }
1382    output
1383}
1384
1385fn format_ast_replace(data: &Value, dry_run: bool) -> String {
1386    let matches = data.get("matches").and_then(Value::as_array);
1387    let match_count = data
1388        .get("total_replacements")
1389        .or_else(|| data.get("total_matches"))
1390        .and_then(Value::as_u64)
1391        .unwrap_or_else(|| matches.map(|m| m.len() as u64).unwrap_or(0));
1392    let files_searched = data
1393        .get("files_searched")
1394        .or_else(|| data.get("total_files"))
1395        .and_then(Value::as_u64)
1396        .unwrap_or(0);
1397    let files_with_matches = data
1398        .get("files_with_matches")
1399        .or_else(|| data.get("total_files"))
1400        .and_then(Value::as_u64)
1401        .unwrap_or(files_searched);
1402
1403    if data.get("no_files_matched_scope").and_then(Value::as_bool) == Some(true) {
1404        let mut output =
1405            "No files matched the scope (paths/globs resolved to zero files)".to_string();
1406        append_scope_warnings(&mut output, data);
1407        return output;
1408    }
1409
1410    if match_count == 0 {
1411        let mut output = format!("No matches found (searched {files_searched} files)");
1412        append_scope_warnings(&mut output, data);
1413        append_hint(&mut output, data);
1414        return output;
1415    }
1416
1417    let mut output = if dry_run {
1418        format!(
1419            "[DRY RUN] Would replace {match_count} match(es) in {files_with_matches} file(s) ({files_searched} searched)\n\n"
1420        )
1421    } else {
1422        format!(
1423            "Replaced {match_count} match(es) in {files_with_matches} file(s) ({files_searched} searched)\n\n"
1424        )
1425    };
1426
1427    if dry_run {
1428        if let Some(files) = data.get("files").and_then(Value::as_array) {
1429            if !files.is_empty() {
1430                append_ast_replace_dry_run_files(
1431                    &mut output,
1432                    files,
1433                    match_count,
1434                    files_with_matches,
1435                );
1436            }
1437        }
1438    } else if let Some(matches) = matches {
1439        for m in matches {
1440            let rel_file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
1441            let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
1442            output.push_str(&format!("{rel_file}:{line}\n"));
1443            if let (Some(text), Some(replacement)) = (
1444                m.get("text").and_then(Value::as_str),
1445                m.get("replacement").and_then(Value::as_str),
1446            ) {
1447                output.push_str(&format!("  - {}\n", text.trim()));
1448                output.push_str(&format!("  + {}\n", replacement.trim()));
1449            }
1450            output.push('\n');
1451        }
1452    } else if let Some(files) = data.get("files").and_then(Value::as_array) {
1453        if !files.is_empty() {
1454            for f in files {
1455                let rel_file = f.get("file").and_then(Value::as_str).unwrap_or("unknown");
1456                let replacements = f.get("replacements").and_then(Value::as_u64).unwrap_or(0);
1457                let suffix = if replacements == 1 { "" } else { "s" };
1458                output.push_str(&format!(
1459                    "  {rel_file}: {replacements} replacement{suffix}\n"
1460                ));
1461            }
1462        }
1463    }
1464
1465    output
1466}
1467
1468fn append_ast_replace_dry_run_files(
1469    output: &mut String,
1470    files: &[Value],
1471    match_count: u64,
1472    files_with_matches: u64,
1473) {
1474    const MAX_DIFF_BYTES: usize = 8 * 1024;
1475    let mut used = 0usize;
1476    for (index, f) in files.iter().enumerate() {
1477        let rel_file = f.get("file").and_then(Value::as_str).unwrap_or("unknown");
1478        let replacements = f.get("replacements").and_then(Value::as_u64).unwrap_or(0);
1479        let diff = f.get("diff").and_then(Value::as_str).unwrap_or("");
1480        if used + diff.len() > MAX_DIFF_BYTES {
1481            let remaining = files.len().saturating_sub(index);
1482            if remaining > 0 {
1483                output.push_str(&format!(
1484                    "\n... ({remaining} more file(s) omitted from preview to stay under {}KB; total {match_count} replacements across {files_with_matches} files)\n",
1485                    MAX_DIFF_BYTES / 1024
1486                ));
1487            }
1488            break;
1489        }
1490        let suffix = if replacements == 1 { "" } else { "s" };
1491        output.push_str(&format!(
1492            "{rel_file} ({replacements} replacement{suffix}):\n"
1493        ));
1494        output.push_str(diff);
1495        if !diff.ends_with('\n') {
1496            output.push('\n');
1497        }
1498        output.push('\n');
1499        used += diff.len();
1500    }
1501}
1502
1503fn append_scope_warnings(output: &mut String, data: &Value) {
1504    let warnings = string_array(data.get("scope_warnings"));
1505    if !warnings.is_empty() {
1506        output.push_str("\n\nScope warnings:\n");
1507        output.push_str(
1508            &warnings
1509                .iter()
1510                .map(|warning| format!("  {warning}"))
1511                .collect::<Vec<_>>()
1512                .join("\n"),
1513        );
1514    }
1515}
1516
1517fn append_hint(output: &mut String, data: &Value) {
1518    if let Some(hint) = data
1519        .get("hint")
1520        .and_then(Value::as_str)
1521        .filter(|hint| !hint.is_empty())
1522    {
1523        output.push_str("\n\n");
1524        output.push_str(hint);
1525    }
1526}
1527
1528fn append_ast_skipped_files(output: String, skipped_files: Option<&Value>) -> String {
1529    let Some(skipped_files) = skipped_files.and_then(Value::as_array) else {
1530        return output;
1531    };
1532    if skipped_files.is_empty() {
1533        return output;
1534    }
1535    let lines = skipped_files
1536        .iter()
1537        .map(|skipped| {
1538            let file = skipped
1539                .get("file")
1540                .and_then(Value::as_str)
1541                .unwrap_or("unknown");
1542            let reason = skipped
1543                .get("reason")
1544                .and_then(Value::as_str)
1545                .unwrap_or("unknown reason");
1546            format!("  {file}: {reason}")
1547        })
1548        .collect::<Vec<_>>();
1549    format!(
1550        "{output}\n\nIncomplete: skipped {} file(s)\n{}",
1551        skipped_files.len(),
1552        lines.join("\n")
1553    )
1554}
1555
1556fn js_template_string(value: &Value) -> String {
1557    match value {
1558        Value::Null => "null".to_string(),
1559        Value::Bool(value) => value.to_string(),
1560        Value::Number(value) => value.to_string(),
1561        Value::String(value) => value.clone(),
1562        Value::Array(items) => items
1563            .iter()
1564            .map(|item| match item {
1565                Value::Null => String::new(),
1566                other => js_template_string(other),
1567            })
1568            .collect::<Vec<_>>()
1569            .join(","),
1570        Value::Object(_) => "[object Object]".to_string(),
1571    }
1572}
1573
1574// Mirrors packages/opencode-plugin/src/tools/semantic.ts semanticTools.
1575fn format_search(data: &Value) -> String {
1576    let note = extra_honesty_note(data);
1577    if let Some(text) = data
1578        .get("text")
1579        .and_then(Value::as_str)
1580        .filter(|s| !s.is_empty())
1581    {
1582        return match note {
1583            Some(n) => format!("{text}\n{n}"),
1584            None => text.to_string(),
1585        };
1586    }
1587    semantic_honesty_note(data).unwrap_or_else(|| "No results.".to_string())
1588}
1589
1590fn semantic_honesty_note(data: &Value) -> Option<String> {
1591    let mut notes = Vec::new();
1592    if data.get("more_available").and_then(Value::as_bool) == Some(true) {
1593        notes.push("more results available");
1594    }
1595    if data.get("engine_capped").and_then(Value::as_bool) == Some(true) {
1596        notes.push("enumeration capped");
1597    }
1598    if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
1599        notes.push("fully degraded");
1600    }
1601    if data.get("complete").and_then(Value::as_bool) == Some(false) {
1602        notes.push("partial/incomplete");
1603    }
1604    if notes.is_empty() {
1605        None
1606    } else {
1607        Some(format!("Search status: {}.", notes.join("; ")))
1608    }
1609}
1610
1611fn extra_honesty_note(data: &Value) -> Option<String> {
1612    let mut notes = Vec::new();
1613    if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
1614        notes.push("fully degraded");
1615    }
1616    if data.get("complete").and_then(Value::as_bool) == Some(false) {
1617        notes.push("partial/incomplete");
1618    }
1619    if notes.is_empty() {
1620        None
1621    } else {
1622        Some(format!("Search status: {}.", notes.join("; ")))
1623    }
1624}
1625
1626// Mirrors packages/opencode-plugin/src/tools/reading.ts aft_outline dispatch.
1627fn format_outline(response: &Response, mode: OutlineMode) -> String {
1628    match mode {
1629        OutlineMode::Text => format_outline_text(&response.data),
1630        OutlineMode::Files | OutlineMode::DirectoryJson => {
1631            format_outline_files_text(&response.data)
1632        }
1633    }
1634}
1635
1636// Mirrors packages/opencode-plugin/src/tools/reading.ts formatOutlineFilesText.
1637fn format_outline_files_text(data: &Value) -> String {
1638    let text = format_outline_text(data);
1639    let unchecked: Vec<String> = data
1640        .get("unchecked_files")
1641        .and_then(Value::as_array)
1642        .map(|arr| {
1643            arr.iter()
1644                .filter_map(|v| v.as_str())
1645                .filter(|s| !s.is_empty())
1646                .map(str::to_string)
1647                .collect()
1648        })
1649        .unwrap_or_default();
1650
1651    let is_partial = data.get("complete").and_then(Value::as_bool) == Some(false)
1652        || data.get("walk_truncated").and_then(Value::as_bool) == Some(true)
1653        || !unchecked.is_empty();
1654
1655    if !is_partial {
1656        return text;
1657    }
1658
1659    let mut footer = Vec::new();
1660    if data.get("walk_truncated").and_then(Value::as_bool) == Some(true) {
1661        let suffix = if !unchecked.is_empty() {
1662            format!(
1663                " {} additional files in this directory were not indexed.",
1664                unchecked.len()
1665            )
1666        } else {
1667            " Some files in this directory were not indexed.".to_string()
1668        };
1669        footer.push(format!(
1670            "⚠ Partial result: walk truncated at 200 files.{suffix}"
1671        ));
1672    } else {
1673        let suffix = if !unchecked.is_empty() {
1674            format!(
1675                " {} files in this directory were not indexed.",
1676                unchecked.len()
1677            )
1678        } else {
1679            " Some files in this directory were not indexed.".to_string()
1680        };
1681        footer.push(format!("⚠ Partial result:{suffix}"));
1682    }
1683
1684    if !unchecked.is_empty() {
1685        footer.push("Unchecked files:".to_string());
1686        for file in unchecked.iter().take(MAX_UNCHECKED_FILES_IN_FOOTER) {
1687            footer.push(format!("  {file}"));
1688        }
1689        let remaining = unchecked
1690            .len()
1691            .saturating_sub(MAX_UNCHECKED_FILES_IN_FOOTER);
1692        if remaining > 0 {
1693            footer.push(format!("  ... +{remaining} more"));
1694        }
1695    }
1696
1697    if text.is_empty() {
1698        footer.join("\n")
1699    } else {
1700        format!("{text}\n\n{}", footer.join("\n"))
1701    }
1702}
1703
1704fn format_outline_text(data: &Value) -> String {
1705    let text = data.get("text").and_then(Value::as_str).unwrap_or("");
1706    let skipped = data.get("skipped_files").and_then(Value::as_array);
1707    let Some(skipped) = skipped.filter(|s| !s.is_empty()) else {
1708        return text.to_string();
1709    };
1710
1711    let lines: Vec<String> = skipped
1712        .iter()
1713        .filter_map(|item| {
1714            let obj = item.as_object()?;
1715            let file = obj.get("file").and_then(Value::as_str)?;
1716            let reason = obj
1717                .get("reason")
1718                .and_then(Value::as_str)
1719                .unwrap_or("skipped");
1720            Some(format!("  {file} — {reason}"))
1721        })
1722        .collect();
1723    if lines.is_empty() {
1724        return text.to_string();
1725    }
1726    let header = if text.is_empty() { "" } else { "\n\n" };
1727    format!(
1728        "{text}{header}Skipped {} file(s):\n{}",
1729        lines.len(),
1730        lines.join("\n")
1731    )
1732}
1733
1734// Format zoom responses as plain text so direct calls and server-side calls
1735// produce identical output.
1736fn format_zoom(data: &Value, ctx: &FormatContext) -> String {
1737    if let Some(entries) = data.get("targets").and_then(Value::as_array) {
1738        return format_zoom_multi_target_result(entries);
1739    }
1740
1741    let target_label = ctx.zoom_target_label.as_deref().unwrap_or("(no target)");
1742    if let Some((names, responses)) = unwrap_rust_zoom_batch_envelope(data) {
1743        return format_zoom_batch_result(target_label, &names, &responses);
1744    }
1745    format_zoom_text(target_label, data)
1746}
1747
1748fn format_zoom_multi_target_result(entries: &[Value]) -> String {
1749    let rendered = entries
1750        .iter()
1751        .map(|entry| {
1752            let target_label = entry
1753                .get("targetLabel")
1754                .and_then(Value::as_str)
1755                .filter(|label| !label.is_empty())
1756                .unwrap_or("(no target)");
1757            let name = entry.get("name").and_then(Value::as_str).unwrap_or("");
1758            let response = entry.get("response");
1759            if response
1760                .and_then(|response| response.get("success"))
1761                .and_then(Value::as_bool)
1762                == Some(false)
1763            {
1764                let message = response
1765                    .and_then(|response| response.get("message"))
1766                    .and_then(Value::as_str)
1767                    .filter(|message| !message.is_empty())
1768                    .unwrap_or("zoom failed");
1769                return (
1770                    false,
1771                    format!("Symbol \"{name}\" not found in {target_label}: {message}"),
1772                );
1773            }
1774            match response {
1775                Some(response) => (true, format_zoom_text(target_label, response)),
1776                None => (
1777                    false,
1778                    format!("Symbol \"{name}\" not found in {target_label}: missing zoom response"),
1779                ),
1780            }
1781        })
1782        .collect::<Vec<_>>();
1783
1784    let complete = rendered.iter().all(|(success, _)| *success);
1785    let mut sections = Vec::new();
1786    if !complete {
1787        sections.push("Incomplete zoom results: one or more symbols failed.".to_string());
1788    }
1789    sections.extend(rendered.into_iter().map(|(_, content)| content));
1790    sections.join("\n\n")
1791}
1792
1793fn unwrap_rust_zoom_batch_envelope(data: &Value) -> Option<(Vec<String>, Vec<Value>)> {
1794    let symbols = data.get("symbols")?.as_array()?;
1795    if symbols.is_empty() {
1796        return None;
1797    }
1798
1799    let mut names = Vec::with_capacity(symbols.len());
1800    let mut responses = Vec::with_capacity(symbols.len());
1801    for entry in symbols {
1802        let row = entry.as_object()?;
1803        let name = row.get("name")?.as_str()?;
1804        let response = row.get("response")?;
1805        if response.is_null() {
1806            return None;
1807        }
1808        names.push(name.to_string());
1809        responses.push(response.clone());
1810    }
1811    Some((names, responses))
1812}
1813
1814fn format_zoom_batch_result(target_label: &str, symbols: &[String], responses: &[Value]) -> String {
1815    let entries = symbols
1816        .iter()
1817        .enumerate()
1818        .map(|(index, name)| {
1819            let response = responses.get(index);
1820            if response
1821                .and_then(|r| r.get("success"))
1822                .and_then(Value::as_bool)
1823                == Some(false)
1824            {
1825                let message = response
1826                    .and_then(|r| r.get("message"))
1827                    .and_then(Value::as_str)
1828                    .filter(|message| !message.is_empty())
1829                    .unwrap_or("zoom failed");
1830                return (false, format!("Symbol \"{name}\" not found: {message}"));
1831            }
1832            match response {
1833                Some(response) => (true, format_zoom_text(target_label, response)),
1834                None => (
1835                    false,
1836                    format!("Symbol \"{name}\" not found: missing zoom response"),
1837                ),
1838            }
1839        })
1840        .collect::<Vec<_>>();
1841
1842    let complete = entries.iter().all(|(success, _)| *success);
1843    let mut sections = Vec::new();
1844    if !complete {
1845        sections.push("Incomplete zoom results: one or more symbols failed.".to_string());
1846    }
1847    sections.extend(entries.into_iter().map(|(_, content)| content));
1848    sections.join("\n\n")
1849}
1850
1851fn format_zoom_text(target_label: &str, response: &Value) -> String {
1852    let range = response.get("range");
1853    let start_line = range
1854        .and_then(|range| range.get("start_line"))
1855        .and_then(Value::as_i64)
1856        .unwrap_or(1);
1857    let end_line = range
1858        .and_then(|range| range.get("end_line"))
1859        .and_then(Value::as_i64)
1860        .unwrap_or(start_line);
1861    let kind = response
1862        .get("kind")
1863        .and_then(Value::as_str)
1864        .unwrap_or("symbol");
1865    let name = response.get("name").and_then(Value::as_str).unwrap_or("");
1866    let content_text = response
1867        .get("content")
1868        .and_then(Value::as_str)
1869        .unwrap_or("");
1870    let context_before = string_array(response.get("context_before"));
1871    let context_after = string_array(response.get("context_after"));
1872
1873    let header = if kind == "lines" {
1874        format!("{target_label}:{start_line}-{end_line}")
1875    } else {
1876        format!("{target_label}:{start_line}-{end_line} [{kind} {name}]")
1877            .trim_end()
1878            .to_string()
1879    };
1880
1881    let mut content_lines = content_text.split('\n').collect::<Vec<_>>();
1882    if content_lines.last() == Some(&"") {
1883        content_lines.pop();
1884    }
1885
1886    let last_displayed_line = end_line + context_after.len() as i64;
1887    let gutter_width = last_displayed_line.max(1).to_string().len();
1888    let mut out = vec![header, String::new()];
1889
1890    let mut line_no = start_line - context_before.len() as i64;
1891    for text in &context_before {
1892        out.push(format_zoom_line(line_no, gutter_width, text));
1893        line_no += 1;
1894    }
1895    for text in content_lines {
1896        out.push(format_zoom_line(line_no, gutter_width, text));
1897        line_no += 1;
1898    }
1899    for text in &context_after {
1900        out.push(format_zoom_line(line_no, gutter_width, text));
1901        line_no += 1;
1902    }
1903
1904    let annotations = response.get("annotations");
1905    let calls_out = annotations
1906        .and_then(|annotations| annotations.get("calls_out"))
1907        .and_then(Value::as_array);
1908    if let Some(calls_out) = calls_out.filter(|calls| !calls.is_empty()) {
1909        out.push(String::new());
1910        out.push("──── calls_out".to_string());
1911        for call in calls_out {
1912            out.push(format_zoom_call_ref(call));
1913        }
1914    }
1915
1916    let called_by = annotations
1917        .and_then(|annotations| annotations.get("called_by"))
1918        .and_then(Value::as_array);
1919    if let Some(called_by) = called_by.filter(|calls| !calls.is_empty()) {
1920        out.push(String::new());
1921        out.push("──── called_by".to_string());
1922        for call in called_by {
1923            out.push(format_zoom_call_ref(call));
1924        }
1925    }
1926
1927    out.join("\n")
1928}
1929
1930fn format_zoom_line(line_no: i64, gutter_width: usize, text: &str) -> String {
1931    format!("{line_no:>gutter_width$}: {text}")
1932}
1933
1934fn format_zoom_call_ref(call: &Value) -> String {
1935    let name = call.get("name").and_then(Value::as_str).unwrap_or("");
1936    let line = call.get("line").and_then(Value::as_i64).unwrap_or(0);
1937    let extra = call
1938        .get("extra_count")
1939        .and_then(Value::as_i64)
1940        .filter(|count| *count > 0)
1941        .map(|count| format!(" +{count}"))
1942        .unwrap_or_default();
1943    format!("  {name} (line {line}){extra}")
1944}
1945
1946// Mirrors packages/opencode-plugin/src/tools/inspect.ts inspectTools.
1947fn format_inspect(response: &Response) -> String {
1948    if let Some(text) = response.data.get("text").and_then(Value::as_str) {
1949        return append_rendered_diagnostics(text, &response.data);
1950    }
1951    let json = serde_json::to_string_pretty(response).unwrap_or_else(|_| "{}".to_string());
1952    append_rendered_diagnostics(&json, &response.data)
1953}
1954
1955// Mirrors packages/opencode-plugin/src/tools/inspect.ts appendRenderedDiagnostics.
1956fn append_rendered_diagnostics(text: &str, data: &Value) -> String {
1957    if text.lines().any(|line| {
1958        let lower = line.to_lowercase();
1959        lower.starts_with("diagnostics:") || lower.starts_with("diagnostics ")
1960    }) {
1961        return text.to_string();
1962    }
1963    let diagnostics = render_inspect_diagnostics(data);
1964    if diagnostics.is_empty() {
1965        return text.to_string();
1966    }
1967    if text.is_empty() {
1968        diagnostics
1969    } else {
1970        format!("{text}\n\n{diagnostics}")
1971    }
1972}
1973
1974fn render_inspect_diagnostics(data: &Value) -> String {
1975    let mut lines = Vec::new();
1976    if let Some(summary_line) = format_diagnostics_summary(data.get("summary")) {
1977        lines.push(summary_line);
1978    }
1979
1980    let detail_lines = format_diagnostics_details(data.get("details"));
1981    if !detail_lines.is_empty() {
1982        let provisional = data
1983            .get("summary")
1984            .and_then(|summary| summary.get("diagnostics"))
1985            .is_some_and(|section| {
1986                section.get("status").and_then(Value::as_str) == Some("pending")
1987                    || section.get("status").and_then(Value::as_str) == Some("incomplete")
1988                    || section.get("provisional_counts").is_some()
1989            });
1990        lines.push(if provisional {
1991            "diagnostics details (provisional — analyzer not ready; counts excluded from E/W):"
1992                .to_string()
1993        } else {
1994            "diagnostics details:".to_string()
1995        });
1996        for line in detail_lines {
1997            lines.push(format!("- {line}"));
1998        }
1999    }
2000
2001    lines.join("\n")
2002}
2003
2004fn format_diagnostics_summary(summary: Option<&Value>) -> Option<String> {
2005    let section = summary?.get("diagnostics")?.as_object()?;
2006    let errors = section.get("errors").and_then(Value::as_u64);
2007    let warnings = section.get("warnings").and_then(Value::as_u64);
2008    let info = section.get("info").and_then(Value::as_u64);
2009    let hints = section.get("hints").and_then(Value::as_u64);
2010    let has_counts = [errors, warnings, info, hints].iter().any(|v| v.is_some());
2011    let counts = format!(
2012        "{} errors, {} warnings, {} info, {} hints",
2013        errors.unwrap_or(0),
2014        warnings.unwrap_or(0),
2015        info.unwrap_or(0),
2016        hints.unwrap_or(0)
2017    );
2018    let status = section.get("status").and_then(Value::as_str);
2019    let provisional_counts = section.get("provisional_counts").and_then(Value::as_object);
2020    let provisional_text = provisional_counts.map(|counts| {
2021        format!(
2022            " ({} errors, {} warnings, {} info, {} hints)",
2023            counts.get("errors").and_then(Value::as_u64).unwrap_or(0),
2024            counts.get("warnings").and_then(Value::as_u64).unwrap_or(0),
2025            counts.get("info").and_then(Value::as_u64).unwrap_or(0),
2026            counts.get("hints").and_then(Value::as_u64).unwrap_or(0),
2027        )
2028    });
2029    let provisional_framing = || {
2030        format!(
2031            "provisional — analyzer not ready; counts excluded from E/W{}",
2032            provisional_text.as_deref().unwrap_or("")
2033        )
2034    };
2035
2036    match status {
2037        Some("pending") => Some(format!(
2038            "diagnostics: {} — still pending (servers: {}); wait for the LSP update and use the next normal aft_inspect, not repeated polling",
2039            provisional_framing(),
2040            diagnostics_server_summary(section)
2041        )),
2042        Some("incomplete") => Some(format!(
2043            "diagnostics: {} (incomplete — servers: {})",
2044            provisional_framing(),
2045            diagnostics_server_summary(section)
2046        )),
2047        _ if provisional_counts.is_some() => Some(format!(
2048            "diagnostics: {}",
2049            provisional_framing()
2050        )),
2051        _ => {
2052            if has_counts {
2053                Some(format!("diagnostics: {counts}"))
2054            } else {
2055                None
2056            }
2057        }
2058    }
2059}
2060
2061fn diagnostics_server_summary(section: &serde_json::Map<String, Value>) -> String {
2062    let pending = string_array(section.get("servers_pending"));
2063    let not_installed = string_array(section.get("servers_not_installed"));
2064    let mut parts = Vec::new();
2065    if !pending.is_empty() {
2066        parts.push(format!("pending: {}", pending.join(", ")));
2067    }
2068    if !not_installed.is_empty() {
2069        parts.push(format!("not installed: {}", not_installed.join(", ")));
2070    }
2071    if parts.is_empty() {
2072        "none reported".to_string()
2073    } else {
2074        parts.join("; ")
2075    }
2076}
2077
2078fn string_array(value: Option<&Value>) -> Vec<String> {
2079    value
2080        .and_then(Value::as_array)
2081        .map(|arr| {
2082            arr.iter()
2083                .filter_map(|v| v.as_str().map(str::to_string))
2084                .collect()
2085        })
2086        .unwrap_or_default()
2087}
2088
2089fn format_diagnostics_details(details: Option<&Value>) -> Vec<String> {
2090    let Some(details) = details.and_then(Value::as_object) else {
2091        return Vec::new();
2092    };
2093    let Some(diagnostics) = details.get("diagnostics").and_then(Value::as_array) else {
2094        return Vec::new();
2095    };
2096    diagnostics
2097        .iter()
2098        .filter_map(|item| {
2099            let d = item.as_object()?;
2100            let severity = d
2101                .get("severity")
2102                .and_then(Value::as_str)
2103                .unwrap_or("information");
2104            let message = d
2105                .get("message")
2106                .and_then(Value::as_str)
2107                .unwrap_or("(no message)");
2108            let source = d.get("source").and_then(Value::as_str);
2109            let suffix = source.map(|s| format!(" [{s}]")).unwrap_or_default();
2110            Some(format!(
2111                "{} {} {}{}",
2112                format_diagnostic_location(d),
2113                severity,
2114                message,
2115                suffix
2116            ))
2117        })
2118        .collect()
2119}
2120
2121fn format_diagnostic_location(d: &serde_json::Map<String, Value>) -> String {
2122    let file = d
2123        .get("file")
2124        .and_then(Value::as_str)
2125        .unwrap_or("(unknown file)");
2126    let line = d.get("line").and_then(Value::as_u64);
2127    let column = d.get("column").and_then(Value::as_u64);
2128    match (line, column) {
2129        (None, _) => file.to_string(),
2130        (Some(line), None) => format!("{file}:{line}"),
2131        (Some(line), Some(col)) => format!("{file}:{line}:{col}"),
2132    }
2133}
2134
2135const UNRESOLVED_SUMMARY_NAME_LIMIT: usize = 10;
2136
2137pub fn format_callgraph(op: &str, response_data: &Value, include_unresolved: bool) -> String {
2138    let Some(record) = response_data.as_object() else {
2139        return "No navigation result.".to_string();
2140    };
2141
2142    let sections = match op {
2143        "call_tree" => format_call_tree_sections(record, include_unresolved),
2144        "callers" => format_callers_sections(record),
2145        "trace_to_symbol" => format_trace_to_symbol_sections(record),
2146        "trace_to" => format_trace_to_sections(record),
2147        "impact" => format_impact_sections(record),
2148        _ => format_trace_data_sections(record),
2149    };
2150    sections.join("\n")
2151}
2152
2153fn format_callgraph_error(command: &str, data: &Value) -> String {
2154    let code = data
2155        .get("code")
2156        .and_then(Value::as_str)
2157        .filter(|s| !s.is_empty());
2158    let message = data
2159        .get("message")
2160        .and_then(Value::as_str)
2161        .filter(|s| !s.is_empty())
2162        .unwrap_or("callgraph failed");
2163
2164    if matches!(
2165        code,
2166        Some("ambiguous_target") | Some("target_symbol_not_in_file")
2167    ) {
2168        let candidates = callgraph_candidates(data);
2169        if !candidates.is_empty() {
2170            let symbol =
2171                callgraph_error_symbol(data).or_else(|| symbol_from_callgraph_message(message));
2172            let target = symbol
2173                .map(|symbol| format!("multiple symbols named \"{symbol}\""))
2174                .unwrap_or_else(|| strip_terminal_punctuation(message));
2175            let action = if code == Some("ambiguous_target") {
2176                "Pass toFile to disambiguate"
2177            } else {
2178                "Try one of these files for toFile"
2179            };
2180            let mut lines = vec![format!(
2181                "{command}: {} — {target}. {action}:",
2182                code.unwrap_or_default()
2183            )];
2184            lines.extend(
2185                candidates
2186                    .into_iter()
2187                    .map(|candidate| format!("  - {candidate}")),
2188            );
2189            return lines.join("\n");
2190        }
2191    }
2192
2193    let Some(code) = code else {
2194        return message.to_string();
2195    };
2196    let mut lines = vec![format!("{command}: {code} — {message}")];
2197    if let Some(extras) = collect_callgraph_error_extras(data) {
2198        lines.push(format!("data: {extras}"));
2199    }
2200    lines.join("\n")
2201}
2202
2203fn callgraph_candidates(data: &Value) -> Vec<String> {
2204    data.get("candidates")
2205        .and_then(Value::as_array)
2206        .or_else(|| {
2207            data.get("data")
2208                .and_then(Value::as_object)
2209                .and_then(|nested| nested.get("candidates"))
2210                .and_then(Value::as_array)
2211        })
2212        .map(|items| {
2213            items
2214                .iter()
2215                .filter_map(|candidate| {
2216                    let candidate = candidate.as_object()?;
2217                    let file = string_field(candidate, "file")?;
2218                    let line = number_field(candidate, "line");
2219                    Some(match line {
2220                        Some(line) => format!("{file}:{line}"),
2221                        None => file.to_string(),
2222                    })
2223                })
2224                .collect()
2225        })
2226        .unwrap_or_default()
2227}
2228
2229fn callgraph_error_symbol(data: &Value) -> Option<String> {
2230    data.get("symbol")
2231        .and_then(Value::as_str)
2232        .filter(|s| !s.is_empty())
2233        .or_else(|| {
2234            data.get("data")
2235                .and_then(Value::as_object)
2236                .and_then(|nested| nested.get("symbol"))
2237                .and_then(Value::as_str)
2238                .filter(|s| !s.is_empty())
2239        })
2240        .map(str::to_string)
2241}
2242
2243fn symbol_from_callgraph_message(message: &str) -> Option<String> {
2244    extract_between(message, "target symbol '", "'")
2245        .or_else(|| extract_between(message, "multiple symbols named \"", "\""))
2246}
2247
2248fn extract_between(message: &str, prefix: &str, suffix: &str) -> Option<String> {
2249    let start = message.find(prefix)? + prefix.len();
2250    let rest = &message[start..];
2251    let end = rest.find(suffix)?;
2252    let value = &rest[..end];
2253    (!value.is_empty()).then(|| value.to_string())
2254}
2255
2256fn strip_terminal_punctuation(message: &str) -> String {
2257    message.trim_end_matches(['.', '!', '?']).to_string()
2258}
2259
2260fn collect_callgraph_error_extras(data: &Value) -> Option<String> {
2261    let obj = data.as_object()?;
2262    let mut extras = serde_json::Map::new();
2263    for (key, value) in obj {
2264        if matches!(
2265            key.as_str(),
2266            "id" | "success" | "code" | "message" | "data" | "status_bar" | "bg_completions"
2267        ) {
2268            continue;
2269        }
2270        extras.insert(key.clone(), value.clone());
2271    }
2272    if extras.is_empty() {
2273        data.get("data").map(stringify_json_pretty)
2274    } else {
2275        if let Some(nested) = data.get("data") {
2276            extras.insert("data".to_string(), nested.clone());
2277        }
2278        Some(stringify_json_pretty(&Value::Object(extras)))
2279    }
2280}
2281
2282fn stringify_json_pretty(value: &Value) -> String {
2283    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
2284}
2285
2286fn format_call_tree_sections(
2287    record: &serde_json::Map<String, Value>,
2288    include_unresolved: bool,
2289) -> Vec<String> {
2290    let mut lines = Vec::new();
2291    render_call_tree_node(record, 0, &mut lines, include_unresolved);
2292    let warning = depth_warning(record, "depth_limited", "truncated");
2293    if !warning.is_empty() {
2294        lines.push(warning);
2295    }
2296    if lines.is_empty() {
2297        vec!["No call tree available.".to_string()]
2298    } else {
2299        lines
2300    }
2301}
2302
2303fn render_call_tree_node(
2304    node: &serde_json::Map<String, Value>,
2305    depth: usize,
2306    lines: &mut Vec<String>,
2307    include_unresolved: bool,
2308) {
2309    let name = string_field(node, "name").unwrap_or("(unknown)");
2310    let file = shorten_path(string_field(node, "file").unwrap_or("(unknown file)"));
2311    let line = number_field(node, "line");
2312    let unresolved = if node.get("resolved").and_then(Value::as_bool) == Some(false) {
2313        " [unresolved]"
2314    } else {
2315        ""
2316    };
2317    let name_match = name_match_edge_marker(node);
2318    let location = match line {
2319        Some(line) => format!("[{file}:{line}]"),
2320        None => format!("[{file}]"),
2321    };
2322    lines.push(tree_line(
2323        depth,
2324        &format!("{name} {location}{unresolved}{name_match}"),
2325    ));
2326
2327    let children = records_field(node, "children");
2328    if include_unresolved {
2329        for child in children {
2330            render_call_tree_node(child, depth + 1, lines, include_unresolved);
2331        }
2332        return;
2333    }
2334
2335    let unresolved_indices = children
2336        .iter()
2337        .enumerate()
2338        .filter_map(|(index, child)| is_unresolved_leaf(child).then_some(index))
2339        .collect::<Vec<_>>();
2340    if unresolved_indices.is_empty() {
2341        for child in children {
2342            render_call_tree_node(child, depth + 1, lines, include_unresolved);
2343        }
2344        return;
2345    }
2346
2347    let mut summary_inserted = false;
2348    for (index, child) in children.iter().enumerate() {
2349        if unresolved_indices.contains(&index) {
2350            if !summary_inserted {
2351                let unresolved_leaves = unresolved_indices
2352                    .iter()
2353                    .filter_map(|idx| children.get(*idx).copied())
2354                    .collect::<Vec<_>>();
2355                lines.push(tree_line(
2356                    depth + 1,
2357                    &unresolved_summary_text(&unresolved_leaves),
2358                ));
2359                summary_inserted = true;
2360            }
2361            continue;
2362        }
2363        render_call_tree_node(child, depth + 1, lines, include_unresolved);
2364    }
2365}
2366
2367fn is_unresolved_leaf(node: &serde_json::Map<String, Value>) -> bool {
2368    node.get("resolved").and_then(Value::as_bool) == Some(false)
2369        && records_field(node, "children").is_empty()
2370}
2371
2372fn unresolved_summary_text(nodes: &[&serde_json::Map<String, Value>]) -> String {
2373    let mut distinct_names = Vec::new();
2374    for node in nodes {
2375        let name = string_field(node, "name").unwrap_or("(unknown)");
2376        if !distinct_names.iter().any(|seen| seen == name) {
2377            distinct_names.push(name.to_string());
2378        }
2379    }
2380
2381    let displayed = distinct_names
2382        .iter()
2383        .take(UNRESOLVED_SUMMARY_NAME_LIMIT)
2384        .cloned()
2385        .collect::<Vec<_>>();
2386    let hidden = distinct_names.len().saturating_sub(displayed.len());
2387    let names = if hidden > 0 {
2388        format!("{}, … (+{hidden} more)", displayed.join(", "))
2389    } else {
2390        displayed.join(", ")
2391    };
2392    let noun = if nodes.len() == 1 { "call" } else { "calls" };
2393    format!("+ {} unresolved external {noun}: {names}", nodes.len())
2394}
2395
2396fn format_callers_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2397    let groups = records_field(record, "callers");
2398    let warning = depth_warning(record, "depth_limited", "truncated");
2399    let hub_summary = hub_summary_line(record);
2400    let total = number_field(record, "total_callers").unwrap_or(0);
2401    let mut sections = vec![join_non_empty(&[
2402        Some(format!(
2403            "{total} caller{}",
2404            if total == 1 { "" } else { "s" }
2405        )),
2406        Some(format!(
2407            "{} file group{}",
2408            groups.len(),
2409            if groups.len() == 1 { "" } else { "s" }
2410        )),
2411        (!warning.is_empty()).then_some(warning),
2412    ])];
2413    if let Some(summary) = hub_summary {
2414        sections.push(summary);
2415    }
2416    for group in groups {
2417        sections.push(render_callers_group_lines(group).join("\n"));
2418    }
2419    sections
2420}
2421
2422fn render_callers_group_lines(group: &serde_json::Map<String, Value>) -> Vec<String> {
2423    let file = shorten_path(string_field(group, "file").unwrap_or("(unknown file)"));
2424    let mut lines = vec![file];
2425    let callers = records_field(group, "callers");
2426    let mut by_symbol_provenance: BTreeMap<String, Vec<i64>> = BTreeMap::new();
2427    for caller in callers {
2428        let symbol = string_field(caller, "symbol").unwrap_or("(unknown)");
2429        let provenance = if string_field(caller, "resolved_by") == Some("name_match") {
2430            "name_match"
2431        } else {
2432            "exact"
2433        };
2434        let key = format!("{symbol}\0{provenance}");
2435        let bucket = by_symbol_provenance.entry(key).or_default();
2436        if let Some(line) = number_field(caller, "line") {
2437            bucket.push(line);
2438        }
2439    }
2440    for (key, mut line_nums) in by_symbol_provenance {
2441        let symbol = key.split('\0').next().unwrap_or("(unknown)");
2442        let is_name_match = key.ends_with("\0name_match");
2443        line_nums.sort_unstable();
2444        let line_part = if line_nums.is_empty() {
2445            "?".to_string()
2446        } else {
2447            line_nums
2448                .iter()
2449                .map(ToString::to_string)
2450                .collect::<Vec<_>>()
2451                .join(", ")
2452        };
2453        let marker = if is_name_match { " ~" } else { "" };
2454        lines.push(format!("  ↳ {symbol}:{line_part}{marker}"));
2455    }
2456    lines
2457}
2458
2459fn format_trace_to_symbol_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2460    let path = records_field(record, "path");
2461    let complete = record.get("complete").and_then(Value::as_bool);
2462    let reason = string_field(record, "reason");
2463    if path.is_empty() {
2464        let prefix = if complete == Some(false) {
2465            "No complete path"
2466        } else {
2467            "No path"
2468        };
2469        return vec![match reason {
2470            Some(reason) => format!("{prefix} ({reason})"),
2471            None => prefix.to_string(),
2472        }];
2473    }
2474
2475    let mut lines = vec![format!(
2476        "{} hop{}",
2477        path.len(),
2478        if path.len() == 1 { "" } else { "s" }
2479    )];
2480    for (index, hop) in path.iter().enumerate() {
2481        let symbol = string_field(hop, "symbol").unwrap_or("(unknown)");
2482        let file = shorten_path(string_field(hop, "file").unwrap_or("(unknown file)"));
2483        let line = number_field(hop, "line");
2484        let name_match = name_match_edge_marker(hop);
2485        let location = match line {
2486            Some(line) => format!("[{file}:{line}]"),
2487            None => format!("[{file}]"),
2488        };
2489        lines.push(tree_line(
2490            index + 1,
2491            &format!("{symbol} {location}{name_match}"),
2492        ));
2493    }
2494    lines
2495}
2496
2497fn format_trace_to_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2498    let paths = records_field(record, "paths");
2499    let warning = depth_warning(record, "max_depth_reached", "truncated_paths");
2500    let hub_summary = hub_summary_line(record);
2501    let total_paths = number_field(record, "total_paths").unwrap_or(paths.len() as i64);
2502    let total_paths_is_lower_bound = record
2503        .get("total_paths_is_lower_bound")
2504        .and_then(Value::as_bool)
2505        .unwrap_or(false);
2506    let entry_points = number_field(record, "entry_points_found").unwrap_or(0);
2507    let mut sections = vec![join_non_empty(&[
2508        Some(format!(
2509            "{}{total_paths} path{}",
2510            if total_paths_is_lower_bound {
2511                "at least "
2512            } else {
2513                ""
2514            },
2515            if total_paths == 1 { "" } else { "s" }
2516        )),
2517        Some(format!(
2518            "{entry_points} entry point{}",
2519            if entry_points == 1 { "" } else { "s" }
2520        )),
2521        (!warning.is_empty()).then_some(warning),
2522    ])];
2523    if let Some(summary) = hub_summary {
2524        sections.push(summary);
2525    }
2526    if paths.is_empty() {
2527        sections.push("No entry paths found.".to_string());
2528    }
2529    for (index, path) in paths.iter().enumerate() {
2530        let mut lines = Vec::new();
2531        render_trace_path(path, index, &mut lines);
2532        sections.push(lines.join("\n"));
2533    }
2534    sections
2535}
2536
2537fn render_trace_path(path: &serde_json::Map<String, Value>, index: usize, lines: &mut Vec<String>) {
2538    lines.push(format!("Path {}", index + 1));
2539    for (hop_index, hop) in records_field(path, "hops").iter().enumerate() {
2540        let symbol = string_field(hop, "symbol").unwrap_or("(unknown)");
2541        let file = shorten_path(string_field(hop, "file").unwrap_or("(unknown file)"));
2542        let line = number_field(hop, "line");
2543        let entry = if hop.get("is_entry_point").and_then(Value::as_bool) == Some(true) {
2544            " [entry]"
2545        } else {
2546            ""
2547        };
2548        let name_match = name_match_edge_marker(hop);
2549        let location = match line {
2550            Some(line) => format!("[{file}:{line}]"),
2551            None => format!("[{file}]"),
2552        };
2553        lines.push(tree_line(
2554            hop_index + 1,
2555            &format!("{symbol}{entry} {location}{name_match}"),
2556        ));
2557    }
2558}
2559
2560fn format_impact_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2561    let callers = records_field(record, "callers");
2562    let warning = depth_warning(record, "depth_limited", "truncated");
2563    let hub_summary = hub_summary_line(record);
2564    let total_affected = number_field(record, "total_affected").unwrap_or(callers.len() as i64);
2565    let affected_files = number_field(record, "affected_files").unwrap_or(0);
2566    let mut sections = vec![join_non_empty(&[
2567        Some(format!(
2568            "{total_affected} affected call site{}",
2569            if total_affected == 1 { "" } else { "s" }
2570        )),
2571        Some(format!(
2572            "{affected_files} file{}",
2573            if affected_files == 1 { "" } else { "s" }
2574        )),
2575        (!warning.is_empty()).then_some(warning),
2576    ])];
2577    if let Some(summary) = hub_summary {
2578        sections.push(summary);
2579    }
2580    if callers.is_empty() {
2581        sections.push("No impacted callers found.".to_string());
2582    }
2583    for caller in callers {
2584        let file = shorten_path(string_field(caller, "caller_file").unwrap_or("(unknown file)"));
2585        let symbol = string_field(caller, "caller_symbol").unwrap_or("(unknown)");
2586        let line = number_field(caller, "line").unwrap_or(0);
2587        let entry = if caller.get("is_entry_point").and_then(Value::as_bool) == Some(true) {
2588            " [entry]"
2589        } else {
2590            ""
2591        };
2592        let name_match = name_match_edge_marker(caller);
2593        let expression = string_field(caller, "call_expression");
2594        let params = caller
2595            .get("parameters")
2596            .and_then(Value::as_array)
2597            .map(|items| {
2598                items
2599                    .iter()
2600                    .map(value_to_plain_string)
2601                    .collect::<Vec<_>>()
2602                    .join(", ")
2603            })
2604            .unwrap_or_default();
2605        let mut lines = vec![
2606            format!("{file}:{line}"),
2607            format!("  ↳ {symbol}{entry}{name_match}"),
2608        ];
2609        if let Some(expression) = expression {
2610            lines.push(format!("  {expression}"));
2611        }
2612        if !params.is_empty() {
2613            lines.push(format!("  params: {params}"));
2614        }
2615        sections.push(lines.join("\n"));
2616    }
2617    sections
2618}
2619
2620fn format_trace_data_sections(record: &serde_json::Map<String, Value>) -> Vec<String> {
2621    let hops = records_field(record, "hops");
2622    let mut sections = vec![join_non_empty(&[
2623        Some(format!(
2624            "{} hop{}",
2625            hops.len(),
2626            if hops.len() == 1 { "" } else { "s" }
2627        )),
2628        (record.get("depth_limited").and_then(Value::as_bool) == Some(true))
2629            .then_some("(depth limited)".to_string()),
2630    ])];
2631    if hops.is_empty() {
2632        sections.push("No data-flow hops found.".to_string());
2633    }
2634    for (index, hop) in hops.iter().enumerate() {
2635        let file = shorten_path(string_field(hop, "file").unwrap_or("(unknown file)"));
2636        let symbol = string_field(hop, "symbol").unwrap_or("(unknown)");
2637        let variable = string_field(hop, "variable").unwrap_or("(unknown)");
2638        let line = number_field(hop, "line").unwrap_or(0);
2639        let approximate = if hop.get("approximate").and_then(Value::as_bool) == Some(true) {
2640            " [approx]"
2641        } else {
2642            ""
2643        };
2644        let name_match = name_match_edge_marker(hop);
2645        let flow_type = string_field(hop, "flow_type").unwrap_or("flow");
2646        sections.push(tree_line(
2647            index,
2648            &format!("{variable} {flow_type} {symbol} [{file}:{line}]{approximate}{name_match}"),
2649        ));
2650    }
2651    sections
2652}
2653
2654fn records_field<'a>(
2655    record: &'a serde_json::Map<String, Value>,
2656    key: &str,
2657) -> Vec<&'a serde_json::Map<String, Value>> {
2658    record
2659        .get(key)
2660        .and_then(Value::as_array)
2661        .map(|items| items.iter().filter_map(Value::as_object).collect())
2662        .unwrap_or_default()
2663}
2664
2665fn string_field<'a>(record: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a str> {
2666    record.get(key).and_then(Value::as_str)
2667}
2668
2669fn number_field(record: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
2670    let value = record.get(key)?;
2671    value
2672        .as_i64()
2673        .or_else(|| value.as_u64().and_then(|n| i64::try_from(n).ok()))
2674}
2675
2676fn shorten_path(path: &str) -> String {
2677    let Some(home) = home_dir() else {
2678        return path.to_string();
2679    };
2680    let home = home.to_string_lossy().to_string();
2681    if path.starts_with(&home) {
2682        format!("~{}", &path[home.len()..])
2683    } else {
2684        path.to_string()
2685    }
2686}
2687
2688fn home_dir() -> Option<PathBuf> {
2689    std::env::var_os("HOME")
2690        .or_else(|| std::env::var_os("USERPROFILE"))
2691        .map(PathBuf::from)
2692}
2693
2694fn tree_line(depth: usize, text: &str) -> String {
2695    format!(
2696        "{}{}{}",
2697        "  ".repeat(depth),
2698        if depth == 0 { "" } else { "↳ " },
2699        text
2700    )
2701}
2702
2703fn name_match_edge_marker(record: &serde_json::Map<String, Value>) -> &'static str {
2704    if string_field(record, "resolved_by") == Some("name_match") {
2705        " ~"
2706    } else {
2707        ""
2708    }
2709}
2710
2711fn depth_warning(
2712    response: &serde_json::Map<String, Value>,
2713    depth_field: &str,
2714    truncated_field: &str,
2715) -> String {
2716    let limited = response.get(depth_field).and_then(Value::as_bool);
2717    let truncated = number_field(response, truncated_field).unwrap_or(0);
2718    if limited != Some(true) && truncated == 0 {
2719        return String::new();
2720    }
2721    let detail = if truncated > 0 {
2722        format!(", {truncated} truncated")
2723    } else {
2724        String::new()
2725    };
2726    format!("(depth limited{detail})")
2727}
2728
2729fn hub_summary_line(response: &serde_json::Map<String, Value>) -> Option<String> {
2730    response
2731        .get("hub_summary")
2732        .and_then(Value::as_object)
2733        .and_then(|summary| string_field(summary, "message"))
2734        .map(str::to_string)
2735}
2736
2737fn join_non_empty(parts: &[Option<String>]) -> String {
2738    parts
2739        .iter()
2740        .filter_map(|part| part.as_deref())
2741        .filter(|part| !part.is_empty())
2742        .collect::<Vec<_>>()
2743        .join(" · ")
2744}
2745
2746fn value_to_plain_string(value: &Value) -> String {
2747    value
2748        .as_str()
2749        .map(str::to_string)
2750        .unwrap_or_else(|| value.to_string())
2751}
2752
2753// Status has no TypeScript wrapper; preserve the pretty JSON summary and add
2754// one deliberately thin human-readable view of the raw memory section.
2755fn format_status(data: &Value) -> String {
2756    if let Some(text) = data
2757        .get("text")
2758        .and_then(Value::as_str)
2759        .filter(|s| !s.is_empty())
2760    {
2761        return text.to_string();
2762    }
2763
2764    // Compact human-readable summary. The full snapshot stays available to
2765    // first-party consumers via structuredContent; the agent-facing text was
2766    // previously a pretty-printed JSON dump that reached six figures of
2767    // characters on fleet-scale daemons.
2768    let mut lines = Vec::new();
2769    let version = data.get("version").and_then(Value::as_str).unwrap_or("?");
2770    let root = data
2771        .get("project_root")
2772        .and_then(Value::as_str)
2773        .unwrap_or("?");
2774    lines.push(format!("AFT {version} — {root}"));
2775
2776    if data.get("degraded").and_then(Value::as_bool) == Some(true) {
2777        let reasons = data
2778            .get("degraded_reasons")
2779            .and_then(Value::as_array)
2780            .map(|reasons| {
2781                reasons
2782                    .iter()
2783                    .filter_map(Value::as_str)
2784                    .collect::<Vec<_>>()
2785                    .join(", ")
2786            })
2787            .filter(|s| !s.is_empty())
2788            .unwrap_or_else(|| "unspecified".to_string());
2789        lines.push(format!("DEGRADED: {reasons}"));
2790    }
2791
2792    let search = status_field(data, "search_index", "status");
2793    let semantic = {
2794        let state = status_field(data, "semantic_index", "status");
2795        let stage = data
2796            .pointer("/semantic_index/stage")
2797            .and_then(Value::as_str);
2798        let model = data
2799            .pointer("/semantic_index/model")
2800            .and_then(Value::as_str);
2801        let mut s = state;
2802        if let Some(stage) = stage {
2803            s = format!("{s} ({stage})");
2804        }
2805        if let Some(model) = model {
2806            s = format!("{s} [{model}]");
2807        }
2808        s
2809    };
2810    let callgraph = data
2811        .pointer("/features/callgraph_store")
2812        .and_then(Value::as_bool)
2813        .map(|on| if on { "enabled" } else { "disabled" })
2814        .unwrap_or("?");
2815    lines.push(format!(
2816        "indexes: search {search} | semantic {semantic} | callgraph {callgraph}"
2817    ));
2818
2819    if let Some(features) = data.get("features").and_then(Value::as_object) {
2820        let flags = features
2821            .iter()
2822            .map(|(name, value)| match value {
2823                Value::Bool(true) => format!("{name} on"),
2824                Value::Bool(false) => format!("{name} off"),
2825                other => format!("{name} {}", value_as_display(other)),
2826            })
2827            .collect::<Vec<_>>()
2828            .join(", ");
2829        lines.push(format!("features: {flags}"));
2830    }
2831
2832    if let Some(disk) = data.get("disk").and_then(Value::as_object) {
2833        let storage = disk
2834            .get("storage_dir")
2835            .and_then(Value::as_str)
2836            .unwrap_or("?");
2837        let trigram = format_optional_memory_bytes(disk.get("trigram_disk_bytes"));
2838        let semantic_disk = format_optional_memory_bytes(disk.get("semantic_disk_bytes"));
2839        lines.push(format!(
2840            "storage: {storage} (trigram {trigram}, semantic {semantic_disk})"
2841        ));
2842    }
2843
2844    let tracked = data
2845        .pointer("/session/tracked_files")
2846        .and_then(Value::as_u64)
2847        .unwrap_or(0);
2848    let checkpoints = data
2849        .pointer("/session/checkpoints")
2850        .and_then(Value::as_u64)
2851        .unwrap_or(0);
2852    let lsp = data.get("lsp_servers").and_then(Value::as_u64).unwrap_or(0);
2853    lines.push(format!(
2854        "session: {tracked} tracked file(s), {checkpoints} checkpoint(s) | lsp servers: {lsp}"
2855    ));
2856
2857    if let Some(memory) = data.get("memory") {
2858        lines.push(String::new());
2859        lines.push(format_memory_block(memory));
2860    }
2861
2862    lines.join("\n")
2863}
2864
2865fn status_field(data: &Value, section: &str, key: &str) -> String {
2866    data.pointer(&format!("/{section}/{key}"))
2867        .and_then(Value::as_str)
2868        .unwrap_or("?")
2869        .to_string()
2870}
2871
2872fn value_as_display(value: &Value) -> String {
2873    match value {
2874        Value::String(s) => s.clone(),
2875        other => other.to_string(),
2876    }
2877}
2878
2879fn format_memory_block(memory: &Value) -> String {
2880    let process = memory.get("process").unwrap_or(&Value::Null);
2881    let rss = format_optional_memory_bytes(process.get("rss_bytes"));
2882    let attributed = format_optional_memory_bytes(process.get("total_attributed_bytes"));
2883    let unattributed = format_optional_memory_bytes(process.get("unattributed_bytes"));
2884    let mut lines = vec![format!(
2885        "Memory: RSS {rss} | attributed {attributed} | unattributed {unattributed}"
2886    )];
2887    if let Some(roots) = memory.get("roots").and_then(Value::as_object) {
2888        for (root, estimate) in roots {
2889            let total = format_optional_memory_bytes(estimate.get("attributed_bytes"));
2890            let subsystems = [
2891                ("semantic", "semantic"),
2892                ("trigram", "trigram"),
2893                ("symbols", "symbols"),
2894                ("callgraph", "callgraph"),
2895                ("inspect", "inspect"),
2896                ("bash", "bash"),
2897                ("lsp", "lsp"),
2898                ("parser_pool", "parsers"),
2899            ]
2900            .iter()
2901            .map(|(key, label)| {
2902                let subsystem = estimate.get(*key).unwrap_or(&Value::Null);
2903                let value = if subsystem.get("status").and_then(Value::as_str) == Some("busy") {
2904                    "busy".to_string()
2905                } else {
2906                    format_optional_memory_bytes(subsystem.get("estimated_bytes"))
2907                };
2908                format!("{label} {value}")
2909            })
2910            .collect::<Vec<_>>()
2911            .join(", ");
2912            lines.push(format!("  {root}: {total} ({subsystems})"));
2913        }
2914    }
2915    lines.join("\n")
2916}
2917
2918fn format_optional_memory_bytes(value: Option<&Value>) -> String {
2919    let Some(value) = value else {
2920        return "not estimated".to_string();
2921    };
2922    let (sign, magnitude) = if let Some(bytes) = value.as_i64() {
2923        (if bytes < 0 { "-" } else { "" }, bytes.unsigned_abs())
2924    } else if let Some(bytes) = value.as_u64() {
2925        ("", bytes)
2926    } else {
2927        return "not estimated".to_string();
2928    };
2929    let magnitude = magnitude as f64;
2930    if magnitude >= 1024.0 * 1024.0 {
2931        format!("{sign}{:.1} MiB", magnitude / (1024.0 * 1024.0))
2932    } else if magnitude >= 1024.0 {
2933        format!("{sign}{:.1} KiB", magnitude / 1024.0)
2934    } else {
2935        format!("{sign}{} B", magnitude as u64)
2936    }
2937}
2938
2939#[cfg(test)]
2940mod move_format_tests {
2941    use super::*;
2942    use serde_json::json;
2943
2944    #[test]
2945    fn source_delete_failed_renders_partially_moved_not_moved() {
2946        let ctx = FormatContext {
2947            move_file_arg: Some("a.ts".into()),
2948            move_dest_arg: Some("b.ts".into()),
2949            ..Default::default()
2950        };
2951        let rendered = format_move(
2952            &json!({
2953                "file": "/repo/src/a.ts",
2954                "destination": "/repo/src/b.ts",
2955                "moved": true,
2956                "complete": false,
2957                "source_delete_failed": true,
2958                "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."
2959            }),
2960            &ctx,
2961        );
2962
2963        assert!(
2964            rendered.starts_with("Partially moved a.ts → b.ts"),
2965            "expected partial move header:\n{rendered}"
2966        );
2967        assert!(
2968            rendered.contains("source deletion failed: permission denied"),
2969            "expected extracted delete error:\n{rendered}"
2970        );
2971        assert!(
2972            rendered.contains("Both paths exist"),
2973            "expected both-paths guidance:\n{rendered}"
2974        );
2975        assert!(
2976            !rendered.starts_with("Moved "),
2977            "must not look like a finished move:\n{rendered}"
2978        );
2979        assert!(
2980            !rendered.contains("Moved a.ts → b.ts"),
2981            "finished-move phrasing must be absent:\n{rendered}"
2982        );
2983    }
2984
2985    #[test]
2986    fn successful_move_still_renders_moved() {
2987        let ctx = FormatContext {
2988            move_file_arg: Some("a.ts".into()),
2989            move_dest_arg: Some("b.ts".into()),
2990            ..Default::default()
2991        };
2992        let rendered = format_move(
2993            &json!({
2994                "file": "/repo/src/a.ts",
2995                "destination": "/repo/src/b.ts",
2996                "moved": true
2997            }),
2998            &ctx,
2999        );
3000        assert_eq!(rendered, "Moved a.ts → b.ts");
3001    }
3002}
3003
3004#[cfg(test)]
3005mod status_memory_tests {
3006    use super::*;
3007    use serde_json::json;
3008
3009    #[test]
3010    fn status_text_renders_compact_memory_block() {
3011        let data = json!({
3012            "version": "test",
3013            "memory": {
3014                "process": {
3015                    "rss_bytes": 8 * 1024 * 1024,
3016                    "total_attributed_bytes": 3 * 1024 * 1024,
3017                    "unattributed_bytes": 5 * 1024 * 1024
3018                },
3019                "roots": {
3020                    "/repo": {
3021                        "attributed_bytes": 3 * 1024 * 1024,
3022                        "semantic": {"status": "ready", "estimated_bytes": 2 * 1024 * 1024},
3023                        "trigram": {"status": "ready", "estimated_bytes": 1024 * 1024},
3024                        "symbols": {"status": "ready", "estimated_bytes": 0},
3025                        "callgraph": {"status": "ready", "estimated_bytes": null},
3026                        "inspect": {"status": "ready", "estimated_bytes": 0},
3027                        "bash": {"status": "ready", "estimated_bytes": 0},
3028                        "lsp": {"status": "ready", "estimated_bytes": 0},
3029                        "parser_pool": {"status": "ready", "estimated_bytes": null}
3030                    }
3031                }
3032            }
3033        });
3034        let rendered = format_status(&data);
3035        assert!(
3036            rendered.contains("Memory: RSS 8.0 MiB | attributed 3.0 MiB | unattributed 5.0 MiB")
3037        );
3038        assert!(rendered.contains("/repo: 3.0 MiB (semantic 2.0 MiB, trigram 1.0 MiB"));
3039        assert!(!rendered.contains("\"memory\""));
3040    }
3041}
3042
3043#[cfg(test)]
3044mod callgraph_format_tests {
3045    use super::*;
3046    use serde_json::json;
3047
3048    #[test]
3049    fn trace_to_formats_budgeted_path_count_as_lower_bound() {
3050        let rendered = format_callgraph(
3051            "trace_to",
3052            &json!({
3053                "total_paths": 7,
3054                "total_paths_is_lower_bound": true,
3055                "entry_points_found": 2,
3056                "hub_summary": {
3057                    "message": "Next: at least 7 paths — showing 2; traversal capped; narrow with scope"
3058                },
3059                "paths": []
3060            }),
3061            false,
3062        );
3063
3064        assert!(rendered.starts_with("at least 7 paths · 2 entry points"));
3065        assert!(rendered.contains("Next: at least 7 paths"));
3066    }
3067
3068    #[test]
3069    fn trace_to_exact_path_count_format_is_unchanged() {
3070        let rendered = format_callgraph(
3071            "trace_to",
3072            &json!({
3073                "total_paths": 1,
3074                "entry_points_found": 1,
3075                "paths": []
3076            }),
3077            false,
3078        );
3079
3080        assert!(rendered.starts_with("1 path · 1 entry point"));
3081        assert!(!rendered.contains("at least"));
3082    }
3083}
3084
3085#[cfg(test)]
3086mod outline_format_tests {
3087    use super::*;
3088    use serde_json::json;
3089
3090    #[test]
3091    fn directory_outline_preserves_walk_truncation_footer() {
3092        let response = Response::success(
3093            "1",
3094            json!({
3095                "text": "src/\n  a.rs (rs)",
3096                "complete": false,
3097                "walk_truncated": true
3098            }),
3099        );
3100
3101        let formatted = format_outline(&response, OutlineMode::DirectoryJson);
3102        assert!(formatted.contains("src/\n  a.rs (rs)"));
3103        assert!(formatted.contains("⚠ Partial result: walk truncated at 200 files. Some files in this directory were not indexed."));
3104    }
3105}