Skip to main content

aft/
subc_format.rs

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