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