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