1use std::path::Path;
4
5use crate::protocol::Response;
6use crate::subc_translate::resolve_path_from_project_root;
7use serde_json::Value;
8
9const MAX_UNCHECKED_FILES_IN_FOOTER: usize = 10;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum OutlineMode {
13 Text,
14 Files,
15 DirectoryJson,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct FormatContext {
20 pub agent_specified_range: bool,
21 pub outline_mode: OutlineMode,
22}
23
24impl Default for FormatContext {
25 fn default() -> Self {
26 Self {
27 agent_specified_range: false,
28 outline_mode: OutlineMode::Text,
29 }
30 }
31}
32
33impl FormatContext {
34 pub fn from_tool_call(bare_name: &str, arguments: &Value, project_root: &Path) -> Self {
35 Self {
36 agent_specified_range: agent_specified_read_range(arguments),
37 outline_mode: outline_mode_for_call(bare_name, arguments, project_root),
38 }
39 }
40}
41
42fn agent_specified_read_range(arguments: &Value) -> bool {
43 let Some(obj) = arguments.as_object() else {
44 return false;
45 };
46 obj.contains_key("startLine")
47 || obj.contains_key("endLine")
48 || obj.contains_key("offset")
49 || obj.contains_key("limit")
50}
51
52fn outline_mode_for_call(bare_name: &str, arguments: &Value, project_root: &Path) -> OutlineMode {
53 if bare_name != "outline" {
54 return OutlineMode::Text;
55 }
56 let Some(obj) = arguments.as_object() else {
57 return OutlineMode::Text;
58 };
59 if obj.get("files").and_then(Value::as_bool) == Some(true) {
60 return OutlineMode::Files;
61 }
62 let Some(target) = obj.get("target").and_then(Value::as_str) else {
63 return OutlineMode::Text;
64 };
65 if target.starts_with("http://") || target.starts_with("https://") {
66 return OutlineMode::Text;
67 }
68 let resolved = resolve_path_from_project_root(project_root, target);
69 if std::fs::metadata(resolved)
70 .map(|m| m.is_dir())
71 .unwrap_or(false)
72 {
73 OutlineMode::DirectoryJson
74 } else {
75 OutlineMode::Text
76 }
77}
78
79fn is_core_agent_tool(bare_name: &str) -> bool {
80 matches!(
81 bare_name,
82 "status" | "read" | "write" | "edit" | "grep" | "search" | "outline" | "inspect"
83 )
84}
85
86pub fn format_response(
88 bare_name: &str,
89 response: &Response,
90 agent_specified_range: bool,
91) -> String {
92 let ctx = FormatContext {
93 agent_specified_range,
94 ..FormatContext::default()
95 };
96 format_response_with_context(bare_name, response, &ctx)
97}
98
99pub fn format_response_with_context(
101 bare_name: &str,
102 response: &Response,
103 ctx: &FormatContext,
104) -> String {
105 if !is_core_agent_tool(bare_name) {
106 return serde_json::to_string(response).unwrap_or_else(|_| "{}".to_string());
107 }
108
109 let data = &response.data;
110 if !response.success {
111 return format_error(bare_name, data);
112 }
113
114 match bare_name {
115 "edit" => format_edit_response(data),
116 "write" => format_write_response(data),
117 "read" => format_read(data, ctx.agent_specified_range),
118 "grep" => format_grep(data),
119 "search" => format_search(data),
120 "outline" => format_outline(response, ctx.outline_mode),
121 "inspect" => format_inspect(response),
122 "status" => format_status(data),
123 _ => unreachable!("core agent tools are exhaustive"),
124 }
125}
126
127fn format_error(bare_name: &str, data: &Value) -> String {
129 let code = data
130 .get("code")
131 .and_then(Value::as_str)
132 .filter(|s| !s.is_empty());
133 let message = data
134 .get("message")
135 .and_then(Value::as_str)
136 .filter(|s| !s.is_empty())
137 .unwrap_or("request failed");
138 match (bare_name, code) {
139 ("search", Some(c)) => format!("semantic_search: {c} — {message}"),
140 _ => message.to_string(),
141 }
142}
143
144fn format_write_response(data: &Value) -> String {
146 if data.get("rolled_back").and_then(Value::as_bool) == Some(true) {
147 return "Write rolled back: the content produced invalid syntax, so the file was left unchanged."
148 .to_string();
149 }
150
151 let mut output = if data.get("created").and_then(Value::as_bool) == Some(true) {
152 "Created new file.".to_string()
153 } else {
154 "File updated.".to_string()
155 };
156 if is_truthy_formatted(data) {
157 output.push_str(" Auto-formatted.");
158 }
159 if data.get("no_op").and_then(Value::as_bool) == Some(true) {
160 output.push_str(
161 " No net change — the written content is byte-identical to what was already on disk.",
162 );
163 }
164 append_lsp_error_lines(&mut output, data, true);
165 append_lsp_server_notes(&mut output, data);
166 output
167}
168
169fn format_edit_response(data: &Value) -> String {
171 let mut result = format_edit_summary(data);
172
173 if let Some(note) = format_glob_skip_reasons_note(data.get("format_skip_reasons")) {
174 result.push_str("\n\n");
175 result.push_str(¬e);
176 }
177 if data.get("no_op").and_then(Value::as_bool) == Some(true) {
178 result.push_str(
179 "\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.",
180 );
181 }
182 append_lsp_error_lines(&mut result, data, false);
183 append_lsp_server_notes(&mut result, data);
184 result
185}
186
187fn format_glob_skip_reasons_note(reasons: Option<&Value>) -> Option<String> {
188 let actionable = reasons?
189 .as_array()?
190 .iter()
191 .filter_map(Value::as_str)
192 .filter(|reason| {
193 matches!(
194 *reason,
195 "formatter_not_installed" | "formatter_excluded_path" | "timeout" | "error"
196 )
197 })
198 .collect::<std::collections::BTreeSet<_>>();
199 if actionable.is_empty() {
200 None
201 } else {
202 Some(format!(
203 "Note: formatter skipped some glob edit result file(s): {}. See per-file format_skipped_reason values for details.",
204 actionable.into_iter().collect::<Vec<_>>().join(", ")
205 ))
206 }
207}
208
209fn append_lsp_error_lines(output: &mut String, data: &Value, trailing_newline: bool) {
210 let errors = data
211 .get("lsp_diagnostics")
212 .and_then(Value::as_array)
213 .map(|items| {
214 items
215 .iter()
216 .filter(|d| d.get("severity").and_then(Value::as_str) == Some("error"))
217 .collect::<Vec<_>>()
218 })
219 .unwrap_or_default();
220 if errors.is_empty() {
221 return;
222 }
223
224 output.push_str("\n\nLSP errors detected, please fix:\n");
225 let lines = errors
226 .iter()
227 .map(|d| {
228 let line = d
229 .get("line")
230 .and_then(Value::as_u64)
231 .map(|n| n.to_string())
232 .unwrap_or_else(|| "undefined".to_string());
233 let message = d
234 .get("message")
235 .and_then(Value::as_str)
236 .unwrap_or("undefined");
237 format!(" Line {line}: {message}")
238 })
239 .collect::<Vec<_>>();
240 output.push_str(&lines.join("\n"));
241 if trailing_newline {
242 output.push('\n');
243 }
244}
245
246fn append_lsp_server_notes(output: &mut String, data: &Value) {
247 let pending = string_array(data.get("lsp_pending_servers"));
248 if !pending.is_empty() {
249 output.push_str(&format!(
250 "\n\nNote: LSP server(s) did not respond in time: {}. Diagnostics may be incomplete; call aft_inspect for a checkpoint diagnostics snapshot.",
251 pending.join(", ")
252 ));
253 }
254 let exited = string_array(data.get("lsp_exited_servers"));
255 if !exited.is_empty() {
256 output.push_str(&format!(
257 "\n\nNote: LSP server(s) exited during this edit: {}. Their diagnostics could not be collected.",
258 exited.join(", ")
259 ));
260 }
261}
262
263fn format_edit_summary(data: &Value) -> String {
265 if data.get("rolled_back").and_then(Value::as_bool) == Some(true) {
266 return "Edit rolled back: the change produced invalid syntax, so the file was left unchanged."
267 .to_string();
268 }
269
270 if let Some(n) = data.get("files_modified").and_then(Value::as_u64) {
271 let n = n as usize;
272 return format!(
273 "Applied edits to {} file{}.",
274 n,
275 if n == 1 { "" } else { "s" }
276 );
277 }
278
279 if let Some(files) = data.get("total_files").and_then(Value::as_u64) {
280 let files = files as usize;
281 let reps = data
282 .get("total_replacements")
283 .and_then(Value::as_u64)
284 .unwrap_or(0) as usize;
285 return format!(
286 "Edited {} file{} ({} replacement{}).",
287 files,
288 if files == 1 { "" } else { "s" },
289 reps,
290 if reps == 1 { "" } else { "s" }
291 );
292 }
293
294 let additions = data
295 .get("diff")
296 .and_then(Value::as_object)
297 .and_then(|d| d.get("additions"))
298 .and_then(Value::as_u64)
299 .unwrap_or(0) as usize;
300 let deletions = data
301 .get("diff")
302 .and_then(Value::as_object)
303 .and_then(|d| d.get("deletions"))
304 .and_then(Value::as_u64)
305 .unwrap_or(0) as usize;
306 let counts = format!("+{additions}/-{deletions}");
307
308 if data.get("created").and_then(Value::as_bool) == Some(true) {
309 let mut s = format!("Created file ({counts}).");
310 if is_truthy_formatted(data) {
311 s.push_str(&format_auto_formatted_suffix(data));
312 }
313 return s;
314 }
315
316 let mut detail = counts.clone();
317 if let Some(n) = data.get("edits_applied").and_then(Value::as_u64) {
318 if n > 1 {
319 detail = format!("{counts}, {n} edits");
320 }
321 } else if let Some(n) = data.get("replacements").and_then(Value::as_u64) {
322 if n > 1 {
323 detail = format!("{counts}, {n} replacements");
324 }
325 }
326
327 let mut s = format!("Edited ({detail}).");
328 if is_truthy_formatted(data) {
329 s.push_str(&format_auto_formatted_suffix(data));
330 }
331 s
332}
333
334fn is_truthy_formatted(data: &Value) -> bool {
335 data.get("formatted")
336 .and_then(Value::as_bool)
337 .unwrap_or(false)
338}
339
340fn format_auto_formatted_suffix(data: &Value) -> String {
341 let reformatted = data.get("reformatted").and_then(Value::as_object);
342 if let Some(text) = reformatted
343 .and_then(|r| r.get("text"))
344 .and_then(Value::as_str)
345 .filter(|s| !s.is_empty())
346 {
347 return format!(
348 "\nAuto-formatted — the formatter reflowed your edit. On disk now:\n{text}"
349 );
350 }
351 if reformatted
352 .and_then(|r| r.get("extensive"))
353 .and_then(Value::as_bool)
354 == Some(true)
355 {
356 return " Auto-formatted — extensive reflow; re-read the file before your next anchored edit."
357 .to_string();
358 }
359 " Auto-formatted.".to_string()
360}
361
362fn format_read(data: &Value, agent_specified_range: bool) -> String {
364 if let Some(entries) = data.get("entries").and_then(Value::as_array) {
365 return entries
366 .iter()
367 .filter_map(|e| e.as_str())
368 .collect::<Vec<_>>()
369 .join("\n");
370 }
371
372 if let Some(attachment_line) = format_read_attachments(data) {
373 return attachment_line;
374 }
375
376 if data.get("binary").and_then(Value::as_bool).unwrap_or(false) {
377 return data
378 .get("message")
379 .and_then(Value::as_str)
380 .unwrap_or("Binary file")
381 .to_string();
382 }
383
384 let mut text = data
385 .get("content")
386 .and_then(Value::as_str)
387 .unwrap_or("")
388 .to_string();
389 text.push_str(&format_read_footer(agent_specified_range, data));
390 text
391}
392
393fn format_read_attachments(data: &Value) -> Option<String> {
394 let attachments = data.get("attachments")?.as_array()?;
395 let first = attachments.first()?.as_object()?;
396 let kind = first.get("kind").and_then(Value::as_str).unwrap_or("file");
397 let mime = first
398 .get("mime")
399 .and_then(Value::as_str)
400 .unwrap_or("application/octet-stream");
401 let size = first
402 .get("bytes")
403 .and_then(Value::as_u64)
404 .map(format_attachment_size);
405 let extra_count = attachments.len().saturating_sub(1);
406 let suffix = if extra_count > 0 {
407 format!("; +{extra_count} more")
408 } else {
409 String::new()
410 };
411
412 if kind == "image" || mime.starts_with("image/") {
413 let dimensions = match (
414 first.get("width").and_then(Value::as_u64),
415 first.get("height").and_then(Value::as_u64),
416 ) {
417 (Some(width), Some(height)) => format!(", {width}×{height}"),
418 _ => String::new(),
419 };
420 let size = size.map(|size| format!(", {size}")).unwrap_or_default();
421 return Some(format!(
422 "[image attachment: {mime}{dimensions}{size}{suffix} — inline delivery pending MCP image support]"
423 ));
424 }
425
426 if kind == "pdf" || mime == "application/pdf" {
427 let size = size.map(|size| format!(", {size}")).unwrap_or_default();
428 return Some(format!(
429 "[pdf attachment: {mime}{size}{suffix} — inline delivery pending MCP file support]"
430 ));
431 }
432
433 let size = size.map(|size| format!(", {size}")).unwrap_or_default();
434 Some(format!(
435 "[attachment: {mime}{size}{suffix} — inline delivery pending MCP file support]"
436 ))
437}
438
439fn format_attachment_size(bytes: u64) -> String {
440 if bytes >= 1024 * 1024 {
441 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
442 } else if bytes >= 1024 {
443 format!("{} KB", bytes.div_ceil(1024))
444 } else {
445 format!("{bytes} bytes")
446 }
447}
448
449fn format_read_footer(agent_specified_range: bool, data: &Value) -> String {
450 if agent_specified_range {
451 return String::new();
452 }
453 if !data
454 .get("truncated")
455 .and_then(Value::as_bool)
456 .unwrap_or(false)
457 {
458 return String::new();
459 }
460 let start = data.get("start_line").and_then(Value::as_u64);
461 let end = data.get("end_line").and_then(Value::as_u64);
462 let total = data.get("total_lines").and_then(Value::as_u64);
463 match (start, end, total) {
464 (Some(start), Some(end), Some(total)) => format!(
465 "\n(Showing lines {start}-{end} of {total}. Use startLine/endLine to read other sections.)"
466 ),
467 _ => String::new(),
468 }
469}
470
471fn format_grep(data: &Value) -> String {
473 if let Some(text) = data.get("text").and_then(Value::as_str) {
474 return text.to_string();
475 }
476
477 let matches = data
478 .get("matches")
479 .and_then(Value::as_array)
480 .cloned()
481 .unwrap_or_default();
482 let total_matches = data
483 .get("total_matches")
484 .and_then(Value::as_u64)
485 .unwrap_or(matches.len() as u64);
486 let files_with_matches = data
487 .get("files_with_matches")
488 .and_then(Value::as_u64)
489 .unwrap_or_else(|| {
490 matches
491 .iter()
492 .filter_map(|m| m.get("file").and_then(Value::as_str))
493 .collect::<std::collections::BTreeSet<_>>()
494 .len() as u64
495 });
496
497 if matches.is_empty() {
498 return format!("Found {total_matches} match across {files_with_matches} file");
499 }
500
501 let body = matches
502 .iter()
503 .map(|m| {
504 let file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
505 let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
506 let text = m
507 .get("line_text")
508 .or_else(|| m.get("text"))
509 .and_then(Value::as_str)
510 .unwrap_or("");
511 format!("{file}:{line}: {text}")
512 })
513 .collect::<Vec<_>>()
514 .join("\n");
515 format!("{body}\n\nFound {total_matches} match across {files_with_matches} file")
516}
517
518fn format_search(data: &Value) -> String {
520 let note = extra_honesty_note(data);
521 if let Some(text) = data
522 .get("text")
523 .and_then(Value::as_str)
524 .filter(|s| !s.is_empty())
525 {
526 return match note {
527 Some(n) => format!("{text}\n{n}"),
528 None => text.to_string(),
529 };
530 }
531 semantic_honesty_note(data).unwrap_or_else(|| "No results.".to_string())
532}
533
534fn semantic_honesty_note(data: &Value) -> Option<String> {
535 let mut notes = Vec::new();
536 if data.get("more_available").and_then(Value::as_bool) == Some(true) {
537 notes.push("more results available");
538 }
539 if data.get("engine_capped").and_then(Value::as_bool) == Some(true) {
540 notes.push("enumeration capped");
541 }
542 if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
543 notes.push("fully degraded");
544 }
545 if data.get("complete").and_then(Value::as_bool) == Some(false) {
546 notes.push("partial/incomplete");
547 }
548 if notes.is_empty() {
549 None
550 } else {
551 Some(format!("Search status: {}.", notes.join("; ")))
552 }
553}
554
555fn extra_honesty_note(data: &Value) -> Option<String> {
556 let mut notes = Vec::new();
557 if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
558 notes.push("fully degraded");
559 }
560 if data.get("complete").and_then(Value::as_bool) == Some(false) {
561 notes.push("partial/incomplete");
562 }
563 if notes.is_empty() {
564 None
565 } else {
566 Some(format!("Search status: {}.", notes.join("; ")))
567 }
568}
569
570fn format_outline(response: &Response, mode: OutlineMode) -> String {
572 match mode {
573 OutlineMode::Text => format_outline_text(&response.data),
574 OutlineMode::Files => format_outline_files_text(&response.data),
575 OutlineMode::DirectoryJson => {
576 serde_json::to_string_pretty(response).unwrap_or_else(|_| "{}".to_string())
577 }
578 }
579}
580
581fn format_outline_files_text(data: &Value) -> String {
583 let text = format_outline_text(data);
584 let unchecked: Vec<String> = data
585 .get("unchecked_files")
586 .and_then(Value::as_array)
587 .map(|arr| {
588 arr.iter()
589 .filter_map(|v| v.as_str())
590 .filter(|s| !s.is_empty())
591 .map(str::to_string)
592 .collect()
593 })
594 .unwrap_or_default();
595
596 let is_partial = data.get("complete").and_then(Value::as_bool) == Some(false)
597 || data.get("walk_truncated").and_then(Value::as_bool) == Some(true)
598 || !unchecked.is_empty();
599
600 if !is_partial {
601 return text;
602 }
603
604 let mut footer = Vec::new();
605 if data.get("walk_truncated").and_then(Value::as_bool) == Some(true) {
606 let suffix = if !unchecked.is_empty() {
607 format!(
608 " {} additional files in this directory were not indexed.",
609 unchecked.len()
610 )
611 } else {
612 " Some files in this directory were not indexed.".to_string()
613 };
614 footer.push(format!(
615 "⚠ Partial result: walk truncated at 200 files.{suffix}"
616 ));
617 } else {
618 let suffix = if !unchecked.is_empty() {
619 format!(
620 " {} files in this directory were not indexed.",
621 unchecked.len()
622 )
623 } else {
624 " Some files in this directory were not indexed.".to_string()
625 };
626 footer.push(format!("⚠ Partial result:{suffix}"));
627 }
628
629 if !unchecked.is_empty() {
630 footer.push("Unchecked files:".to_string());
631 for file in unchecked.iter().take(MAX_UNCHECKED_FILES_IN_FOOTER) {
632 footer.push(format!(" {file}"));
633 }
634 let remaining = unchecked
635 .len()
636 .saturating_sub(MAX_UNCHECKED_FILES_IN_FOOTER);
637 if remaining > 0 {
638 footer.push(format!(" ... +{remaining} more"));
639 }
640 }
641
642 if text.is_empty() {
643 footer.join("\n")
644 } else {
645 format!("{text}\n\n{}", footer.join("\n"))
646 }
647}
648
649fn format_outline_text(data: &Value) -> String {
650 let text = data.get("text").and_then(Value::as_str).unwrap_or("");
651 let skipped = data.get("skipped_files").and_then(Value::as_array);
652 let Some(skipped) = skipped.filter(|s| !s.is_empty()) else {
653 return text.to_string();
654 };
655
656 let lines: Vec<String> = skipped
657 .iter()
658 .filter_map(|item| {
659 let obj = item.as_object()?;
660 let file = obj.get("file").and_then(Value::as_str)?;
661 let reason = obj
662 .get("reason")
663 .and_then(Value::as_str)
664 .unwrap_or("skipped");
665 Some(format!(" {file} — {reason}"))
666 })
667 .collect();
668 if lines.is_empty() {
669 return text.to_string();
670 }
671 let header = if text.is_empty() { "" } else { "\n\n" };
672 format!(
673 "{text}{header}Skipped {} file(s):\n{}",
674 lines.len(),
675 lines.join("\n")
676 )
677}
678
679fn format_inspect(response: &Response) -> String {
681 if let Some(text) = response.data.get("text").and_then(Value::as_str) {
682 return append_rendered_diagnostics(text, &response.data);
683 }
684 let json = serde_json::to_string_pretty(response).unwrap_or_else(|_| "{}".to_string());
685 append_rendered_diagnostics(&json, &response.data)
686}
687
688fn append_rendered_diagnostics(text: &str, data: &Value) -> String {
690 if text.lines().any(|line| {
691 let lower = line.to_lowercase();
692 lower.starts_with("diagnostics:") || lower.starts_with("diagnostics ")
693 }) {
694 return text.to_string();
695 }
696 let diagnostics = render_inspect_diagnostics(data);
697 if diagnostics.is_empty() {
698 return text.to_string();
699 }
700 if text.is_empty() {
701 diagnostics
702 } else {
703 format!("{text}\n\n{diagnostics}")
704 }
705}
706
707fn render_inspect_diagnostics(data: &Value) -> String {
708 let mut lines = Vec::new();
709 if let Some(summary_line) = format_diagnostics_summary(data.get("summary")) {
710 lines.push(summary_line);
711 }
712
713 let detail_lines = format_diagnostics_details(data.get("details"));
714 if !detail_lines.is_empty() {
715 lines.push("diagnostics details:".to_string());
716 for line in detail_lines {
717 lines.push(format!("- {line}"));
718 }
719 }
720
721 lines.join("\n")
722}
723
724fn format_diagnostics_summary(summary: Option<&Value>) -> Option<String> {
725 let section = summary?.get("diagnostics")?.as_object()?;
726 let errors = section.get("errors").and_then(Value::as_u64);
727 let warnings = section.get("warnings").and_then(Value::as_u64);
728 let info = section.get("info").and_then(Value::as_u64);
729 let hints = section.get("hints").and_then(Value::as_u64);
730 let has_counts = [errors, warnings, info, hints].iter().any(|v| v.is_some());
731 let counts = format!(
732 "{} errors, {} warnings, {} info, {} hints",
733 errors.unwrap_or(0),
734 warnings.unwrap_or(0),
735 info.unwrap_or(0),
736 hints.unwrap_or(0)
737 );
738 let status = section.get("status").and_then(Value::as_str);
739
740 match status {
741 Some("pending") => {
742 if has_counts {
743 Some(format!(
744 "diagnostics: {counts} so far — still pending (servers: {})",
745 diagnostics_server_summary(section)
746 ))
747 } else {
748 Some(format!(
749 "diagnostics: pending (servers: {})",
750 diagnostics_server_summary(section)
751 ))
752 }
753 }
754 Some("incomplete") => {
755 if has_counts {
756 Some(format!(
757 "diagnostics: {counts} (incomplete — servers: {})",
758 diagnostics_server_summary(section)
759 ))
760 } else {
761 Some(format!(
762 "diagnostics: unavailable (status incomplete; servers: {})",
763 diagnostics_server_summary(section)
764 ))
765 }
766 }
767 _ => {
768 if has_counts {
769 Some(format!("diagnostics: {counts}"))
770 } else {
771 None
772 }
773 }
774 }
775}
776
777fn diagnostics_server_summary(section: &serde_json::Map<String, Value>) -> String {
778 let pending = string_array(section.get("servers_pending"));
779 let not_installed = string_array(section.get("servers_not_installed"));
780 let mut parts = Vec::new();
781 if !pending.is_empty() {
782 parts.push(format!("pending: {}", pending.join(", ")));
783 }
784 if !not_installed.is_empty() {
785 parts.push(format!("not installed: {}", not_installed.join(", ")));
786 }
787 if parts.is_empty() {
788 "none reported".to_string()
789 } else {
790 parts.join("; ")
791 }
792}
793
794fn string_array(value: Option<&Value>) -> Vec<String> {
795 value
796 .and_then(Value::as_array)
797 .map(|arr| {
798 arr.iter()
799 .filter_map(|v| v.as_str().map(str::to_string))
800 .collect()
801 })
802 .unwrap_or_default()
803}
804
805fn format_diagnostics_details(details: Option<&Value>) -> Vec<String> {
806 let Some(details) = details.and_then(Value::as_object) else {
807 return Vec::new();
808 };
809 let Some(diagnostics) = details.get("diagnostics").and_then(Value::as_array) else {
810 return Vec::new();
811 };
812 diagnostics
813 .iter()
814 .filter_map(|item| {
815 let d = item.as_object()?;
816 let severity = d
817 .get("severity")
818 .and_then(Value::as_str)
819 .unwrap_or("information");
820 let message = d
821 .get("message")
822 .and_then(Value::as_str)
823 .unwrap_or("(no message)");
824 let source = d.get("source").and_then(Value::as_str);
825 let suffix = source.map(|s| format!(" [{s}]")).unwrap_or_default();
826 Some(format!(
827 "{} {} {}{}",
828 format_diagnostic_location(d),
829 severity,
830 message,
831 suffix
832 ))
833 })
834 .collect()
835}
836
837fn format_diagnostic_location(d: &serde_json::Map<String, Value>) -> String {
838 let file = d
839 .get("file")
840 .and_then(Value::as_str)
841 .unwrap_or("(unknown file)");
842 let line = d.get("line").and_then(Value::as_u64);
843 let column = d.get("column").and_then(Value::as_u64);
844 match (line, column) {
845 (None, _) => file.to_string(),
846 (Some(line), None) => format!("{file}:{line}"),
847 (Some(line), Some(col)) => format!("{file}:{line}:{col}"),
848 }
849}
850
851fn format_status(data: &Value) -> String {
853 if let Some(text) = data
854 .get("text")
855 .and_then(Value::as_str)
856 .filter(|s| !s.is_empty())
857 {
858 return text.to_string();
859 }
860 serde_json::to_string_pretty(data).unwrap_or_else(|_| "{}".to_string())
861}