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 data.get("binary").and_then(Value::as_bool).unwrap_or(false) {
373 return data
374 .get("message")
375 .and_then(Value::as_str)
376 .unwrap_or("Binary file")
377 .to_string();
378 }
379
380 let mut text = data
381 .get("content")
382 .and_then(Value::as_str)
383 .unwrap_or("")
384 .to_string();
385 text.push_str(&format_read_footer(agent_specified_range, data));
386 text
387}
388
389fn format_read_footer(agent_specified_range: bool, data: &Value) -> String {
390 if agent_specified_range {
391 return String::new();
392 }
393 if !data
394 .get("truncated")
395 .and_then(Value::as_bool)
396 .unwrap_or(false)
397 {
398 return String::new();
399 }
400 let start = data.get("start_line").and_then(Value::as_u64);
401 let end = data.get("end_line").and_then(Value::as_u64);
402 let total = data.get("total_lines").and_then(Value::as_u64);
403 match (start, end, total) {
404 (Some(start), Some(end), Some(total)) => format!(
405 "\n(Showing lines {start}-{end} of {total}. Use startLine/endLine to read other sections.)"
406 ),
407 _ => String::new(),
408 }
409}
410
411fn format_grep(data: &Value) -> String {
413 if let Some(text) = data.get("text").and_then(Value::as_str) {
414 return text.to_string();
415 }
416
417 let matches = data
418 .get("matches")
419 .and_then(Value::as_array)
420 .cloned()
421 .unwrap_or_default();
422 let total_matches = data
423 .get("total_matches")
424 .and_then(Value::as_u64)
425 .unwrap_or(matches.len() as u64);
426 let files_with_matches = data
427 .get("files_with_matches")
428 .and_then(Value::as_u64)
429 .unwrap_or_else(|| {
430 matches
431 .iter()
432 .filter_map(|m| m.get("file").and_then(Value::as_str))
433 .collect::<std::collections::BTreeSet<_>>()
434 .len() as u64
435 });
436
437 if matches.is_empty() {
438 return format!("Found {total_matches} match across {files_with_matches} file");
439 }
440
441 let body = matches
442 .iter()
443 .map(|m| {
444 let file = m.get("file").and_then(Value::as_str).unwrap_or("unknown");
445 let line = m.get("line").and_then(Value::as_u64).unwrap_or(0);
446 let text = m
447 .get("line_text")
448 .or_else(|| m.get("text"))
449 .and_then(Value::as_str)
450 .unwrap_or("");
451 format!("{file}:{line}: {text}")
452 })
453 .collect::<Vec<_>>()
454 .join("\n");
455 format!("{body}\n\nFound {total_matches} match across {files_with_matches} file")
456}
457
458fn format_search(data: &Value) -> String {
460 let note = extra_honesty_note(data);
461 if let Some(text) = data
462 .get("text")
463 .and_then(Value::as_str)
464 .filter(|s| !s.is_empty())
465 {
466 return match note {
467 Some(n) => format!("{text}\n{n}"),
468 None => text.to_string(),
469 };
470 }
471 semantic_honesty_note(data).unwrap_or_else(|| "No results.".to_string())
472}
473
474fn semantic_honesty_note(data: &Value) -> Option<String> {
475 let mut notes = Vec::new();
476 if data.get("more_available").and_then(Value::as_bool) == Some(true) {
477 notes.push("more results available");
478 }
479 if data.get("engine_capped").and_then(Value::as_bool) == Some(true) {
480 notes.push("enumeration capped");
481 }
482 if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
483 notes.push("fully degraded");
484 }
485 if data.get("complete").and_then(Value::as_bool) == Some(false) {
486 notes.push("partial/incomplete");
487 }
488 if notes.is_empty() {
489 None
490 } else {
491 Some(format!("Search status: {}.", notes.join("; ")))
492 }
493}
494
495fn extra_honesty_note(data: &Value) -> Option<String> {
496 let mut notes = Vec::new();
497 if data.get("fully_degraded").and_then(Value::as_bool) == Some(true) {
498 notes.push("fully degraded");
499 }
500 if data.get("complete").and_then(Value::as_bool) == Some(false) {
501 notes.push("partial/incomplete");
502 }
503 if notes.is_empty() {
504 None
505 } else {
506 Some(format!("Search status: {}.", notes.join("; ")))
507 }
508}
509
510fn format_outline(response: &Response, mode: OutlineMode) -> String {
512 match mode {
513 OutlineMode::Text => format_outline_text(&response.data),
514 OutlineMode::Files => format_outline_files_text(&response.data),
515 OutlineMode::DirectoryJson => {
516 serde_json::to_string_pretty(response).unwrap_or_else(|_| "{}".to_string())
517 }
518 }
519}
520
521fn format_outline_files_text(data: &Value) -> String {
523 let text = format_outline_text(data);
524 let unchecked: Vec<String> = data
525 .get("unchecked_files")
526 .and_then(Value::as_array)
527 .map(|arr| {
528 arr.iter()
529 .filter_map(|v| v.as_str())
530 .filter(|s| !s.is_empty())
531 .map(str::to_string)
532 .collect()
533 })
534 .unwrap_or_default();
535
536 let is_partial = data.get("complete").and_then(Value::as_bool) == Some(false)
537 || data.get("walk_truncated").and_then(Value::as_bool) == Some(true)
538 || !unchecked.is_empty();
539
540 if !is_partial {
541 return text;
542 }
543
544 let mut footer = Vec::new();
545 if data.get("walk_truncated").and_then(Value::as_bool) == Some(true) {
546 let suffix = if !unchecked.is_empty() {
547 format!(
548 " {} additional files in this directory were not indexed.",
549 unchecked.len()
550 )
551 } else {
552 " Some files in this directory were not indexed.".to_string()
553 };
554 footer.push(format!(
555 "⚠ Partial result: walk truncated at 200 files.{suffix}"
556 ));
557 } else {
558 let suffix = if !unchecked.is_empty() {
559 format!(
560 " {} files in this directory were not indexed.",
561 unchecked.len()
562 )
563 } else {
564 " Some files in this directory were not indexed.".to_string()
565 };
566 footer.push(format!("⚠ Partial result:{suffix}"));
567 }
568
569 if !unchecked.is_empty() {
570 footer.push("Unchecked files:".to_string());
571 for file in unchecked.iter().take(MAX_UNCHECKED_FILES_IN_FOOTER) {
572 footer.push(format!(" {file}"));
573 }
574 let remaining = unchecked
575 .len()
576 .saturating_sub(MAX_UNCHECKED_FILES_IN_FOOTER);
577 if remaining > 0 {
578 footer.push(format!(" ... +{remaining} more"));
579 }
580 }
581
582 if text.is_empty() {
583 footer.join("\n")
584 } else {
585 format!("{text}\n\n{}", footer.join("\n"))
586 }
587}
588
589fn format_outline_text(data: &Value) -> String {
590 let text = data.get("text").and_then(Value::as_str).unwrap_or("");
591 let skipped = data.get("skipped_files").and_then(Value::as_array);
592 let Some(skipped) = skipped.filter(|s| !s.is_empty()) else {
593 return text.to_string();
594 };
595
596 let lines: Vec<String> = skipped
597 .iter()
598 .filter_map(|item| {
599 let obj = item.as_object()?;
600 let file = obj.get("file").and_then(Value::as_str)?;
601 let reason = obj
602 .get("reason")
603 .and_then(Value::as_str)
604 .unwrap_or("skipped");
605 Some(format!(" {file} — {reason}"))
606 })
607 .collect();
608 if lines.is_empty() {
609 return text.to_string();
610 }
611 let header = if text.is_empty() { "" } else { "\n\n" };
612 format!(
613 "{text}{header}Skipped {} file(s):\n{}",
614 lines.len(),
615 lines.join("\n")
616 )
617}
618
619fn format_inspect(response: &Response) -> String {
621 if let Some(text) = response.data.get("text").and_then(Value::as_str) {
622 return append_rendered_diagnostics(text, &response.data);
623 }
624 let json = serde_json::to_string_pretty(response).unwrap_or_else(|_| "{}".to_string());
625 append_rendered_diagnostics(&json, &response.data)
626}
627
628fn append_rendered_diagnostics(text: &str, data: &Value) -> String {
630 if text.lines().any(|line| {
631 let lower = line.to_lowercase();
632 lower.starts_with("diagnostics:") || lower.starts_with("diagnostics ")
633 }) {
634 return text.to_string();
635 }
636 let diagnostics = render_inspect_diagnostics(data);
637 if diagnostics.is_empty() {
638 return text.to_string();
639 }
640 if text.is_empty() {
641 diagnostics
642 } else {
643 format!("{text}\n\n{diagnostics}")
644 }
645}
646
647fn render_inspect_diagnostics(data: &Value) -> String {
648 let mut lines = Vec::new();
649 if let Some(summary_line) = format_diagnostics_summary(data.get("summary")) {
650 lines.push(summary_line);
651 }
652
653 let detail_lines = format_diagnostics_details(data.get("details"));
654 if !detail_lines.is_empty() {
655 lines.push("diagnostics details:".to_string());
656 for line in detail_lines {
657 lines.push(format!("- {line}"));
658 }
659 }
660
661 lines.join("\n")
662}
663
664fn format_diagnostics_summary(summary: Option<&Value>) -> Option<String> {
665 let section = summary?.get("diagnostics")?.as_object()?;
666 let errors = section.get("errors").and_then(Value::as_u64);
667 let warnings = section.get("warnings").and_then(Value::as_u64);
668 let info = section.get("info").and_then(Value::as_u64);
669 let hints = section.get("hints").and_then(Value::as_u64);
670 let has_counts = [errors, warnings, info, hints].iter().any(|v| v.is_some());
671 let counts = format!(
672 "{} errors, {} warnings, {} info, {} hints",
673 errors.unwrap_or(0),
674 warnings.unwrap_or(0),
675 info.unwrap_or(0),
676 hints.unwrap_or(0)
677 );
678 let status = section.get("status").and_then(Value::as_str);
679
680 match status {
681 Some("pending") => {
682 if has_counts {
683 Some(format!(
684 "diagnostics: {counts} so far — still pending (servers: {})",
685 diagnostics_server_summary(section)
686 ))
687 } else {
688 Some(format!(
689 "diagnostics: pending (servers: {})",
690 diagnostics_server_summary(section)
691 ))
692 }
693 }
694 Some("incomplete") => {
695 if has_counts {
696 Some(format!(
697 "diagnostics: {counts} (incomplete — servers: {})",
698 diagnostics_server_summary(section)
699 ))
700 } else {
701 Some(format!(
702 "diagnostics: unavailable (status incomplete; servers: {})",
703 diagnostics_server_summary(section)
704 ))
705 }
706 }
707 _ => {
708 if has_counts {
709 Some(format!("diagnostics: {counts}"))
710 } else {
711 None
712 }
713 }
714 }
715}
716
717fn diagnostics_server_summary(section: &serde_json::Map<String, Value>) -> String {
718 let pending = string_array(section.get("servers_pending"));
719 let not_installed = string_array(section.get("servers_not_installed"));
720 let mut parts = Vec::new();
721 if !pending.is_empty() {
722 parts.push(format!("pending: {}", pending.join(", ")));
723 }
724 if !not_installed.is_empty() {
725 parts.push(format!("not installed: {}", not_installed.join(", ")));
726 }
727 if parts.is_empty() {
728 "none reported".to_string()
729 } else {
730 parts.join("; ")
731 }
732}
733
734fn string_array(value: Option<&Value>) -> Vec<String> {
735 value
736 .and_then(Value::as_array)
737 .map(|arr| {
738 arr.iter()
739 .filter_map(|v| v.as_str().map(str::to_string))
740 .collect()
741 })
742 .unwrap_or_default()
743}
744
745fn format_diagnostics_details(details: Option<&Value>) -> Vec<String> {
746 let Some(details) = details.and_then(Value::as_object) else {
747 return Vec::new();
748 };
749 let Some(diagnostics) = details.get("diagnostics").and_then(Value::as_array) else {
750 return Vec::new();
751 };
752 diagnostics
753 .iter()
754 .filter_map(|item| {
755 let d = item.as_object()?;
756 let severity = d
757 .get("severity")
758 .and_then(Value::as_str)
759 .unwrap_or("information");
760 let message = d
761 .get("message")
762 .and_then(Value::as_str)
763 .unwrap_or("(no message)");
764 let source = d.get("source").and_then(Value::as_str);
765 let suffix = source.map(|s| format!(" [{s}]")).unwrap_or_default();
766 Some(format!(
767 "{} {} {}{}",
768 format_diagnostic_location(d),
769 severity,
770 message,
771 suffix
772 ))
773 })
774 .collect()
775}
776
777fn format_diagnostic_location(d: &serde_json::Map<String, Value>) -> String {
778 let file = d
779 .get("file")
780 .and_then(Value::as_str)
781 .unwrap_or("(unknown file)");
782 let line = d.get("line").and_then(Value::as_u64);
783 let column = d.get("column").and_then(Value::as_u64);
784 match (line, column) {
785 (None, _) => file.to_string(),
786 (Some(line), None) => format!("{file}:{line}"),
787 (Some(line), Some(col)) => format!("{file}:{line}:{col}"),
788 }
789}
790
791fn format_status(data: &Value) -> String {
793 if let Some(text) = data
794 .get("text")
795 .and_then(Value::as_str)
796 .filter(|s| !s.is_empty())
797 {
798 return text.to_string();
799 }
800 serde_json::to_string_pretty(data).unwrap_or_else(|_| "{}".to_string())
801}