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