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 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
471// Mirrors packages/opencode-plugin/src/tools/search.ts formatGrepOutput.
472fn 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
518// Mirrors packages/opencode-plugin/src/tools/semantic.ts semanticTools.
519fn 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
570// Mirrors packages/opencode-plugin/src/tools/reading.ts aft_outline dispatch.
571fn 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
581// Mirrors packages/opencode-plugin/src/tools/reading.ts formatOutlineFilesText.
582fn 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
679// Mirrors packages/opencode-plugin/src/tools/inspect.ts inspectTools.
680fn 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
688// Mirrors packages/opencode-plugin/src/tools/inspect.ts appendRenderedDiagnostics.
689fn 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
851// Status has no TypeScript wrapper; this mirrors the subc bare status fallback.
852fn 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}