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