Skip to main content

aft/
subc_format.rs

1//! Agent-facing text formatters for subc-mode tool results (parity with TS plugins).
2
3use 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
86/// Render the text block for a tool `CallToolResult` from the structured AFT `Response`.
87pub 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
99/// Render the text block for a tool `CallToolResult` from the structured AFT `Response`.
100pub 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
127// Mirrors per-tool OpenCode wrapper error handling in packages/opencode-plugin/src/tools/*.ts.
128fn 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
144// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createWriteTool.
145fn 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
169// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createEditTool.
170fn 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(&note);
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
263// Mirrors packages/aft-bridge/src/edit-summary.ts formatEditSummary.
264fn 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
362// Mirrors packages/opencode-plugin/src/tools/hoisted.ts createReadTool.
363fn 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
411// Mirrors packages/opencode-plugin/src/tools/search.ts formatGrepOutput.
412fn 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
458// Mirrors packages/opencode-plugin/src/tools/semantic.ts semanticTools.
459fn 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
510// Mirrors packages/opencode-plugin/src/tools/reading.ts aft_outline dispatch.
511fn 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
521// Mirrors packages/opencode-plugin/src/tools/reading.ts formatOutlineFilesText.
522fn 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
619// Mirrors packages/opencode-plugin/src/tools/inspect.ts inspectTools.
620fn 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
628// Mirrors packages/opencode-plugin/src/tools/inspect.ts appendRenderedDiagnostics.
629fn 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
791// Status has no TypeScript wrapper; this mirrors the subc bare status fallback.
792fn 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}