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