Skip to main content

krait/output/
compact.rs

1use std::fmt::Write;
2
3use serde_json::Value;
4
5use crate::commands::search::SearchOutput;
6use crate::protocol::Response;
7
8/// Format response as compact, token-optimized output for LLM consumption.
9#[must_use]
10#[allow(clippy::too_many_lines)]
11pub fn format(response: &Response) -> String {
12    if let Some(error) = &response.error {
13        let mut out = format!("error: {} ({})", error.message, error.code);
14        if let Some(advice) = &error.advice {
15            let _ = write!(out, "\nadvice: {advice}");
16        }
17        return out;
18    }
19
20    let Some(data) = &response.data else {
21        return String::new();
22    };
23
24    // Status response
25    if data.get("daemon").is_some() {
26        return format_status(data);
27    }
28
29    // Init response: has "files_indexed" key
30    if data.get("files_indexed").is_some() {
31        return format_init(data);
32    }
33
34    // File content: has "content" + "path" keys
35    if data.get("content").is_some() && data.get("path").is_some() {
36        return format_file_content(data);
37    }
38
39    // Directory symbols: has "dir": true
40    if data.get("dir").and_then(Value::as_bool).unwrap_or(false) {
41        return format_dir_symbols(data);
42    }
43
44    // Check response: has "diagnostics" key
45    if let Some(diags) = data.get("diagnostics").and_then(|v| v.as_array()) {
46        return format_check(data, diags);
47    }
48
49    // Edit replace: has "lines_before" key
50    if data.get("lines_before").is_some() {
51        return crate::commands::edit::format_replace(data);
52    }
53
54    // Hover response: has "hover_content" key
55    if data.get("hover_content").is_some() {
56        return format_hover(data);
57    }
58
59    // Format response: has "edits_applied" key
60    if data.get("edits_applied").is_some() {
61        return format_format(data);
62    }
63
64    // Rename response: has "files_changed" key
65    if data.get("files_changed").is_some() {
66        return format_rename(data);
67    }
68
69    // Fix response: has "fixes_applied" key
70    if data.get("fixes_applied").is_some() {
71        return format_fix(data);
72    }
73
74    // Server restart: {"restarted": lang, "server_name": name}
75    if let Some(lang) = data.get("restarted").and_then(Value::as_str) {
76        let server = data
77            .get("server_name")
78            .and_then(Value::as_str)
79            .unwrap_or("?");
80        return format!("restarted {lang} ({server})");
81    }
82
83    // Server clean: {"cleaned": true, ...}
84    if data
85        .get("cleaned")
86        .and_then(Value::as_bool)
87        .unwrap_or(false)
88    {
89        let bytes = data.get("bytes_freed").and_then(Value::as_u64).unwrap_or(0);
90        if bytes == 0 {
91            return "nothing to clean".to_string();
92        }
93        #[allow(clippy::cast_precision_loss)]
94        let mb = bytes as f64 / 1_048_576.0;
95        return format!("cleaned ~/.krait/servers/ ({mb:.1} MB freed)");
96    }
97
98    // Server install: {"installed": binary, "path": ...}
99    if let Some(binary) = data.get("installed").and_then(Value::as_str) {
100        let path = data.get("path").and_then(Value::as_str).unwrap_or("?");
101        return format!("installed {binary} → {path}");
102    }
103
104    // Server status from daemon: {"servers": [...], "count": N}
105    if let Some(servers) = data.get("servers").and_then(Value::as_array) {
106        return format_daemon_server_status(servers);
107    }
108
109    // Edit insert: has "inserted_at" + "operation" keys
110    if data.get("inserted_at").is_some() {
111        let kind = data
112            .get("operation")
113            .and_then(|v| v.as_str())
114            .unwrap_or("after");
115        return crate::commands::edit::format_insert(data, kind);
116    }
117
118    // Array results (symbol search, references, document symbols)
119    if let Some(items) = data.as_array() {
120        if items.is_empty() {
121            return "no results".to_string();
122        }
123
124        let mut out = String::new();
125
126        // Document symbols: {name, kind, line, children}
127        if items.first().and_then(|i| i.get("name")).is_some()
128            && items.first().and_then(|i| i.get("path")).is_none()
129        {
130            format_symbol_tree(items, &mut out, 0);
131            return out.trim_end().to_string();
132        }
133
134        // Enriched references: has "containing_symbol"
135        let is_enriched = items.iter().any(|i| i.get("containing_symbol").is_some());
136        if is_enriched {
137            format_enriched_refs(items, &mut out);
138            return out.trim_end().to_string();
139        }
140
141        // Symbol search / references: {path, line, kind?, preview, body?}
142        for item in items {
143            if let Some(path) = item.get("path").and_then(Value::as_str) {
144                let line = item.get("line").and_then(Value::as_u64).unwrap_or(0);
145                let kind = item.get("kind").and_then(Value::as_str).unwrap_or("");
146                let preview = item.get("preview").and_then(Value::as_str).unwrap_or("");
147                let is_def = item
148                    .get("is_definition")
149                    .and_then(Value::as_bool)
150                    .unwrap_or(false);
151                let tag = if is_def { " [definition]" } else { "" };
152
153                if kind.is_empty() {
154                    let _ = writeln!(out, "{path}:{line} {preview}{tag}");
155                } else {
156                    let _ = writeln!(out, "{path}:{line} {kind} {preview}{tag}");
157                }
158
159                // Inline body when present (--include-body)
160                if let Some(body) = item.get("body").and_then(Value::as_str) {
161                    for (i, body_line) in body.lines().enumerate() {
162                        #[allow(clippy::cast_possible_truncation)]
163                        let num = line as usize + i;
164                        let _ = writeln!(out, "  {num:>4}\t{body_line}");
165                    }
166                    let _ = writeln!(out, "---");
167                }
168            }
169        }
170
171        return out.trim_end().to_string();
172    }
173
174    // Generic: compact JSON on one line
175    serde_json::to_string(data).unwrap_or_default()
176}
177
178fn format_init(data: &Value) -> String {
179    let files = data
180        .get("files_indexed")
181        .and_then(Value::as_u64)
182        .unwrap_or(0);
183    let cached = data
184        .get("files_cached")
185        .and_then(Value::as_u64)
186        .unwrap_or(0);
187    let symbols = data
188        .get("symbols_total")
189        .and_then(Value::as_u64)
190        .unwrap_or(0);
191    let total = data.get("files_total").and_then(Value::as_u64).unwrap_or(0);
192
193    let elapsed = data.get("elapsed_ms").and_then(Value::as_u64).unwrap_or(0);
194    let time_str = if elapsed >= 1000 {
195        format!(" in {}.{}s", elapsed / 1000, (elapsed % 1000) / 100)
196    } else if elapsed > 0 {
197        format!(" in {elapsed}ms")
198    } else {
199        String::new()
200    };
201
202    if cached > 0 {
203        format!("indexed {files}/{total} files ({cached} cached), {symbols} symbols{time_str}")
204    } else {
205        format!("indexed {files} files, {symbols} symbols{time_str}")
206    }
207}
208
209fn format_file_content(data: &Value) -> String {
210    let path = data.get("path").and_then(Value::as_str).unwrap_or("?");
211    let from = data.get("from").and_then(Value::as_u64).unwrap_or(0);
212    let to = data.get("to").and_then(Value::as_u64).unwrap_or(0);
213    let total = data.get("total").and_then(Value::as_u64);
214    let truncated = data
215        .get("truncated")
216        .and_then(Value::as_bool)
217        .unwrap_or(false);
218    let content = data.get("content").and_then(Value::as_str).unwrap_or("");
219
220    let mut header = String::new();
221
222    // Symbol read: has "symbol" + "kind"
223    if let Some(symbol) = data.get("symbol").and_then(Value::as_str) {
224        let kind = data.get("kind").and_then(Value::as_str).unwrap_or("?");
225        let _ = write!(header, "{kind} {symbol} in {path} ({from}-{to})");
226    } else {
227        // File read
228        let _ = write!(header, "{path} ({from}-{to}");
229        if let Some(t) = total {
230            let _ = write!(header, "/{t}");
231        }
232        header.push(')');
233    }
234
235    if truncated {
236        header.push_str(" [truncated]");
237    }
238
239    format!("{header}\n{}", content.trim_end())
240}
241
242fn format_status(data: &Value) -> String {
243    let daemon = &data["daemon"];
244    let pid = daemon.get("pid").and_then(Value::as_u64).unwrap_or(0);
245    let uptime = daemon
246        .get("uptime_secs")
247        .and_then(Value::as_u64)
248        .unwrap_or(0);
249    let mut out = format!("daemon: pid={pid} uptime={}", format_duration(uptime));
250
251    // Show config source (only if not auto-detected)
252    if let Some(config) = data.get("config").and_then(|v| v.as_str()) {
253        if config != "auto-detected" {
254            let workspace_count = data
255                .get("project")
256                .and_then(|p| p.get("workspaces"))
257                .and_then(serde_json::Value::as_u64)
258                .unwrap_or(0);
259            let _ = write!(out, "\nconfig: {config} ({workspace_count} workspaces)");
260        }
261    }
262
263    if let Some(lsp) = data.get("lsp") {
264        if !lsp.is_null() {
265            format_lsp_status(lsp, data, &mut out);
266        }
267    }
268
269    if let Some(project) = data.get("project") {
270        let discovered = project
271            .get("workspaces_discovered")
272            .and_then(Value::as_u64)
273            .unwrap_or(0);
274        let attached = project
275            .get("workspaces_attached")
276            .and_then(Value::as_u64)
277            .unwrap_or(0);
278        if discovered > 0 {
279            let _ = write!(
280                out,
281                "\nworkspaces: {discovered} discovered, {attached} attached"
282            );
283        }
284
285        if let Some(langs) = project.get("languages").and_then(|v| v.as_array()) {
286            let names: Vec<&str> = langs.iter().filter_map(|v| v.as_str()).collect();
287            if !names.is_empty() {
288                let _ = write!(out, "\nproject: languages=[{}]", names.join(","));
289            }
290        }
291    }
292
293    // Index / watcher status
294    if let Some(index) = data.get("index") {
295        let watcher = index
296            .get("watcher_active")
297            .and_then(Value::as_bool)
298            .unwrap_or(false);
299        let dirty = index
300            .get("dirty_files")
301            .and_then(Value::as_u64)
302            .unwrap_or(0);
303        if watcher {
304            let _ = write!(out, "\nindex: watcher active, {dirty} dirty files");
305        } else {
306            let _ = write!(out, "\nindex: watcher inactive (BLAKE3 fallback)");
307        }
308    }
309
310    out
311}
312
313fn format_lsp_status(lsp: &Value, _data: &Value, out: &mut String) {
314    let lsp_status = lsp.get("status").and_then(|v| v.as_str()).unwrap_or("?");
315    let progress = lsp.get("progress").and_then(|v| v.as_str()).unwrap_or("");
316
317    if let Some(servers) = lsp.get("servers").and_then(|v| v.as_array()) {
318        let sessions = lsp.get("sessions").and_then(Value::as_u64).unwrap_or(0);
319        let status_tag = if lsp_status != "ready" && !progress.is_empty() {
320            format!(" [{lsp_status} {progress}]")
321        } else {
322            String::new()
323        };
324        let _ = write!(out, "\nlsp: {sessions} servers{status_tag}");
325
326        for s in servers {
327            let lang = s.get("language").and_then(|v| v.as_str()).unwrap_or("?");
328            let server = s.get("server").and_then(|v| v.as_str()).unwrap_or("?");
329            let s_status = s.get("status").and_then(|v| v.as_str()).unwrap_or("?");
330            let attached = s
331                .get("attached_folders")
332                .and_then(Value::as_u64)
333                .unwrap_or(0);
334            let total = s.get("total_folders").and_then(Value::as_u64).unwrap_or(0);
335            let state_tag = if s_status == "ready" {
336                String::new()
337            } else {
338                format!(" [{s_status}]")
339            };
340            let folders = format!("{attached}/{total} folders");
341            let _ = write!(out, "\n  {lang} ({server}) — {folders}{state_tag}");
342        }
343    } else if lsp_status == "pending" && !progress.is_empty() {
344        let _ = write!(out, "\nlsp: pending ({progress})");
345    } else {
346        let lang = lsp.get("language").and_then(|v| v.as_str()).unwrap_or("?");
347        let server = lsp.get("server").and_then(|v| v.as_str()).unwrap_or("?");
348        let _ = write!(out, "\nlsp: {lang} {lsp_status} ({server})");
349    }
350}
351
352fn format_dir_symbols(data: &Value) -> String {
353    let files = match data.get("files").and_then(Value::as_array) {
354        Some(f) if !f.is_empty() => f,
355        _ => return "no results".to_string(),
356    };
357
358    let mut out = String::new();
359    for (i, entry) in files.iter().enumerate() {
360        let file = entry.get("file").and_then(Value::as_str).unwrap_or("?");
361        let _ = writeln!(out, "{file}");
362        if let Some(symbols) = entry.get("symbols").and_then(Value::as_array) {
363            format_symbol_tree(symbols, &mut out, 1);
364        }
365        if i + 1 < files.len() {
366            out.push('\n');
367        }
368    }
369    out.trim_end().to_string()
370}
371
372/// Format references enriched with `--with-symbol`.
373///
374/// Each reference is printed as:
375///   `path:line  [in containingFn (kind:N)]  preview`
376/// Definition sites are printed as:
377///   `path:line  [definition]  preview`
378fn format_enriched_refs(items: &[Value], out: &mut String) {
379    for item in items {
380        let path = item.get("path").and_then(Value::as_str).unwrap_or("?");
381        let line = item.get("line").and_then(Value::as_u64).unwrap_or(0);
382        let preview = item
383            .get("preview")
384            .and_then(Value::as_str)
385            .unwrap_or("")
386            .trim();
387        let is_def = item
388            .get("is_definition")
389            .and_then(Value::as_bool)
390            .unwrap_or(false);
391
392        if is_def {
393            let _ = writeln!(out, "{path}:{line}  [definition]  {preview}");
394            continue;
395        }
396
397        let tag = if let Some(cs) = item.get("containing_symbol") {
398            let sym_name = cs.get("name").and_then(Value::as_str).unwrap_or("?");
399            let sym_kind = cs.get("kind").and_then(Value::as_str).unwrap_or("?");
400            let sym_line = cs.get("line").and_then(Value::as_u64).unwrap_or(0);
401            format!("  [in {sym_name} ({sym_kind}:{sym_line})]")
402        } else {
403            String::new()
404        };
405
406        let _ = writeln!(out, "{path}:{line}{tag}  {preview}");
407    }
408}
409
410fn format_symbol_tree(items: &[Value], out: &mut String, indent: usize) {
411    for item in items {
412        let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
413        let kind = item.get("kind").and_then(Value::as_str).unwrap_or("?");
414        let prefix = "  ".repeat(indent);
415        let _ = writeln!(out, "{prefix}{kind} {name}");
416        if let Some(children) = item.get("children").and_then(Value::as_array) {
417            format_symbol_tree(children, out, indent + 1);
418        }
419    }
420}
421
422fn format_check(data: &Value, diags: &[Value]) -> String {
423    if diags.is_empty() {
424        return "No diagnostics".to_string();
425    }
426
427    let mut out = String::new();
428    for d in diags {
429        let sev = d.get("severity").and_then(Value::as_str).unwrap_or("?");
430        let path = d.get("path").and_then(Value::as_str).unwrap_or("?");
431        let line = d.get("line").and_then(Value::as_u64).unwrap_or(0);
432        let col = d.get("col").and_then(Value::as_u64).unwrap_or(0);
433        let code = d
434            .get("code")
435            .and_then(Value::as_str)
436            .filter(|s| !s.is_empty())
437            .unwrap_or("");
438        let msg = d.get("message").and_then(Value::as_str).unwrap_or("");
439
440        if code.is_empty() {
441            let _ = writeln!(out, "{sev:<5} {path}:{line}:{col} {msg}");
442        } else {
443            let _ = writeln!(out, "{sev:<5} {path}:{line}:{col} {code} {msg}");
444        }
445    }
446
447    let total = data.get("total").and_then(Value::as_u64).unwrap_or(0);
448    let errors = data.get("errors").and_then(Value::as_u64).unwrap_or(0);
449    let warnings = data.get("warnings").and_then(Value::as_u64).unwrap_or(0);
450
451    let mut summary = format!("{total} diagnostic");
452    if total != 1 {
453        summary.push('s');
454    }
455
456    let mut parts: Vec<String> = vec![];
457    if errors > 0 {
458        parts.push(format!(
459            "{errors} error{}",
460            if errors == 1 { "" } else { "s" }
461        ));
462    }
463    if warnings > 0 {
464        parts.push(format!(
465            "{warnings} warning{}",
466            if warnings == 1 { "" } else { "s" }
467        ));
468    }
469    if !parts.is_empty() {
470        let joined = parts.join(", ");
471        summary.push_str(" (");
472        summary.push_str(&joined);
473        summary.push(')');
474    }
475
476    out.push_str(&summary);
477    out
478}
479
480fn format_hover(data: &Value) -> String {
481    let content = data
482        .get("hover_content")
483        .and_then(Value::as_str)
484        .unwrap_or("")
485        .trim();
486    let path = data.get("path").and_then(Value::as_str).unwrap_or("?");
487    let line = data.get("line").and_then(Value::as_u64).unwrap_or(0);
488
489    if content.is_empty() {
490        return format!("No hover information available ({path}:{line})");
491    }
492
493    format!("{content}\n{path}:{line}")
494}
495
496fn format_format(data: &Value) -> String {
497    let path = data.get("path").and_then(Value::as_str).unwrap_or("?");
498    let n = data
499        .get("edits_applied")
500        .and_then(Value::as_u64)
501        .unwrap_or(0);
502    if n == 0 {
503        format!("No changes needed ({path})")
504    } else {
505        format!("Formatted {path} ({n} edits)")
506    }
507}
508
509fn format_rename(data: &Value) -> String {
510    let files = data
511        .get("files_changed")
512        .and_then(Value::as_u64)
513        .unwrap_or(0);
514    let refs = data
515        .get("refs_changed")
516        .and_then(Value::as_u64)
517        .unwrap_or(0);
518    if files == 0 {
519        "No references renamed".to_string()
520    } else {
521        format!("Renamed {refs} refs across {files} files")
522    }
523}
524
525fn format_fix(data: &Value) -> String {
526    let n = data
527        .get("fixes_applied")
528        .and_then(Value::as_u64)
529        .unwrap_or(0);
530    if n == 0 {
531        return "No fixes available".to_string();
532    }
533
534    let files: Vec<&str> = data
535        .get("files")
536        .and_then(Value::as_array)
537        .map(|arr| arr.iter().filter_map(Value::as_str).collect())
538        .unwrap_or_default();
539
540    let file_list = files.join(", ");
541    format!("Applied {n} fix(es) in {file_list}")
542}
543
544/// Format search results as compact output.
545#[must_use]
546pub fn format_search(output: &SearchOutput, with_context: bool, files_only: bool) -> String {
547    let mut out = String::new();
548
549    if files_only {
550        let mut seen = std::collections::BTreeSet::new();
551        for m in &output.matches {
552            seen.insert(m.path.as_str());
553        }
554        for path in &seen {
555            let _ = writeln!(out, "{path}");
556        }
557        let n = seen.len();
558        let _ = write!(out, "{n} {}", if n == 1 { "file" } else { "files" });
559        return out;
560    }
561
562    if with_context {
563        format_search_with_context(output, &mut out);
564    } else {
565        format_search_flat(output, &mut out);
566    }
567
568    out
569}
570
571fn format_search_flat(output: &SearchOutput, out: &mut String) {
572    // Compute max width of "path:line:col" prefix for alignment
573    let max_loc_len = output
574        .matches
575        .iter()
576        .map(|m| format!("{}:{}:{}", m.path, m.line, m.column).len())
577        .max()
578        .unwrap_or(0);
579
580    for m in &output.matches {
581        let loc = format!("{}:{}:{}", m.path, m.line, m.column);
582        let _ = writeln!(
583            out,
584            "{loc:<width$}  {preview}",
585            width = max_loc_len,
586            preview = m.preview.trim()
587        );
588    }
589
590    let n = output.total_matches;
591    let f = output.files_with_matches;
592    let trunc = if output.truncated { " [truncated]" } else { "" };
593    let _ = write!(
594        out,
595        "{n} {} in {f} {}{}",
596        if n == 1 { "match" } else { "matches" },
597        if f == 1 { "file" } else { "files" },
598        trunc,
599    );
600}
601
602fn format_search_with_context(output: &SearchOutput, out: &mut String) {
603    // Group matches by file, preserving order
604    let mut current_file: Option<&str> = None;
605
606    for m in &output.matches {
607        if current_file != Some(m.path.as_str()) {
608            if current_file.is_some() {
609                out.push_str("──\n");
610            }
611            let _ = writeln!(out, "{}", m.path);
612            current_file = Some(m.path.as_str());
613        }
614
615        // Compute line number width for this block
616        let max_line = m.line as usize + m.context_after.len();
617        let width = max_line.to_string().len();
618
619        let start_line = m.line as usize - m.context_before.len();
620        for (i, ctx) in m.context_before.iter().enumerate() {
621            let lno = start_line + i;
622            let _ = writeln!(out, "  {lno:>width$}  {ctx}");
623        }
624        let _ = writeln!(out, "> {:>width$}  {}", m.line, m.preview.trim());
625        for (i, ctx) in m.context_after.iter().enumerate() {
626            let lno = m.line as usize + 1 + i;
627            let _ = writeln!(out, "  {lno:>width$}  {ctx}");
628        }
629    }
630
631    if current_file.is_some() {
632        out.push_str("──\n");
633    }
634
635    let n = output.total_matches;
636    let f = output.files_with_matches;
637    let trunc = if output.truncated { " [truncated]" } else { "" };
638    let _ = write!(
639        out,
640        "{n} {} in {f} {}{}",
641        if n == 1 { "match" } else { "matches" },
642        if f == 1 { "file" } else { "files" },
643        trunc,
644    );
645}
646
647fn format_daemon_server_status(servers: &[Value]) -> String {
648    if servers.is_empty() {
649        return "no servers running".to_string();
650    }
651    let mut out = String::new();
652    for s in servers {
653        let lang = s.get("language").and_then(Value::as_str).unwrap_or("?");
654        let server = s.get("server").and_then(Value::as_str).unwrap_or("?");
655        let status = s.get("status").and_then(Value::as_str).unwrap_or("?");
656        let attached = s
657            .get("attached_folders")
658            .and_then(Value::as_u64)
659            .unwrap_or(0);
660        let total = s.get("total_folders").and_then(Value::as_u64).unwrap_or(0);
661        let uptime = s.get("uptime_secs").and_then(Value::as_u64).unwrap_or(0);
662        let uptime_str = if uptime > 0 {
663            format!(" uptime={}", format_duration(uptime))
664        } else {
665            String::new()
666        };
667        let state_tag = if status == "ready" {
668            String::new()
669        } else {
670            format!(" [{status}]")
671        };
672        let _ = writeln!(
673            out,
674            "{lang:<12}  {server:<24}  {attached}/{total} folders{state_tag}{uptime_str}"
675        );
676    }
677    out.trim_end().to_string()
678}
679
680fn format_duration(secs: u64) -> String {
681    if secs < 60 {
682        format!("{secs}s")
683    } else if secs < 3600 {
684        format!("{}m", secs / 60)
685    } else {
686        let h = secs / 3600;
687        let m = (secs % 3600) / 60;
688        if m == 0 {
689            format!("{h}h")
690        } else {
691            format!("{h}h{m}m")
692        }
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use serde_json::json;
699
700    use super::*;
701
702    #[test]
703    fn compact_status_output() {
704        let resp = Response::ok(json!({"daemon": {"pid": 12345, "uptime_secs": 300}}));
705        let out = format(&resp);
706        assert_eq!(out, "daemon: pid=12345 uptime=5m");
707    }
708
709    #[test]
710    fn compact_error_output() {
711        let resp = Response::err_with_advice("lsp_not_found", "LSP not detected", "Install it");
712        let out = format(&resp);
713        assert!(out.contains("error: LSP not detected"));
714        assert!(out.contains("advice: Install it"));
715    }
716
717    #[test]
718    fn compact_symbol_results() {
719        let resp = Response::ok(json!([
720            {"path": "src/lib.rs", "line": 5, "kind": "function", "preview": "fn greet(name: &str) -> String"},
721            {"path": "src/lib.rs", "line": 15, "kind": "struct", "preview": "struct Config"}
722        ]));
723        let out = format(&resp);
724        assert!(out.contains("src/lib.rs:5 function fn greet"));
725        assert!(out.contains("src/lib.rs:15 struct struct Config"));
726    }
727
728    #[test]
729    fn compact_reference_results() {
730        let resp = Response::ok(json!([
731            {"path": "src/lib.rs", "line": 5, "preview": "pub fn greet()", "is_definition": true},
732            {"path": "src/main.rs", "line": 8, "preview": "let msg = greet(\"world\");", "is_definition": false}
733        ]));
734        let out = format(&resp);
735        assert!(out.contains("[definition]"));
736        assert!(out.contains("src/main.rs:8"));
737    }
738
739    #[test]
740    fn compact_empty_results() {
741        let resp = Response::ok(json!([]));
742        let out = format(&resp);
743        assert_eq!(out, "no results");
744    }
745
746    #[test]
747    fn compact_file_content_output() {
748        let resp = Response::ok(json!({
749            "path": "src/main.rs",
750            "content": "   1\tfn main() {\n   2\t    println!(\"hello\");\n   3\t}\n",
751            "from": 1,
752            "to": 3,
753            "total": 3,
754            "truncated": false,
755        }));
756        let out = format(&resp);
757        assert!(out.starts_with("src/main.rs (1-3/3)"));
758        assert!(out.contains("fn main()"));
759    }
760
761    #[test]
762    fn compact_file_content_truncated() {
763        let resp = Response::ok(json!({
764            "path": "big.rs",
765            "content": "   1\tline1\n",
766            "from": 1,
767            "to": 200,
768            "total": 500,
769            "truncated": true,
770        }));
771        let out = format(&resp);
772        assert!(out.contains("[truncated]"));
773    }
774
775    #[test]
776    fn compact_symbol_content_output() {
777        let resp = Response::ok(json!({
778            "path": "src/lib.rs",
779            "symbol": "Config",
780            "kind": "struct",
781            "content": "   5\tpub struct Config {\n   6\t    name: String,\n   7\t}\n",
782            "from": 5,
783            "to": 7,
784            "truncated": false,
785        }));
786        let out = format(&resp);
787        assert!(out.starts_with("struct Config in src/lib.rs (5-7)"));
788        assert!(out.contains("pub struct Config"));
789    }
790
791    #[test]
792    fn compact_check_with_diagnostics() {
793        let resp = Response::ok(json!({
794            "diagnostics": [
795                {"severity": "error", "path": "src/lib.rs", "line": 42, "col": 10, "code": "E0308", "message": "mismatched types"},
796                {"severity": "warn", "path": "src/main.rs", "line": 3, "col": 5, "code": "", "message": "unused import"}
797            ],
798            "total": 2,
799            "errors": 1,
800            "warnings": 1,
801        }));
802        let out = format(&resp);
803        assert!(out.contains("error src/lib.rs:42:10 E0308 mismatched types"));
804        assert!(out.contains("warn  src/main.rs:3:5 unused import"));
805        assert!(out.contains("2 diagnostics"));
806        assert!(out.contains("1 error"));
807        assert!(out.contains("1 warning"));
808    }
809
810    #[test]
811    fn compact_check_no_diagnostics() {
812        let resp = Response::ok(json!({
813            "diagnostics": [],
814            "total": 0,
815            "errors": 0,
816            "warnings": 0,
817        }));
818        let out = format(&resp);
819        assert_eq!(out, "No diagnostics");
820    }
821
822    #[test]
823    fn compact_duration_formatting() {
824        assert_eq!(format_duration(30), "30s");
825        assert_eq!(format_duration(300), "5m");
826        assert_eq!(format_duration(3600), "1h");
827        assert_eq!(format_duration(3900), "1h5m");
828    }
829}