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