Skip to main content

aft/
subc_format.rs

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