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