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    let mut out = 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    if let Some(warnings) = data.get("warnings").and_then(|v| v.as_array()) {
209        for w in warnings {
210            if let Some(msg) = w.as_str() {
211                out.push_str("\nwarn  ");
212                out.push_str(msg);
213            }
214        }
215    }
216
217    out
218}
219
220fn format_file_content(data: &Value) -> String {
221    let path = data.get("path").and_then(Value::as_str).unwrap_or("?");
222    let from = data.get("from").and_then(Value::as_u64).unwrap_or(0);
223    let to = data.get("to").and_then(Value::as_u64).unwrap_or(0);
224    let total = data.get("total").and_then(Value::as_u64);
225    let truncated = data
226        .get("truncated")
227        .and_then(Value::as_bool)
228        .unwrap_or(false);
229    let content = data.get("content").and_then(Value::as_str).unwrap_or("");
230
231    let mut header = String::new();
232
233    // Symbol read: has "symbol" + "kind"
234    if let Some(symbol) = data.get("symbol").and_then(Value::as_str) {
235        let kind = data.get("kind").and_then(Value::as_str).unwrap_or("?");
236        let _ = write!(header, "{kind} {symbol} in {path} ({from}-{to})");
237    } else {
238        // File read
239        let _ = write!(header, "{path} ({from}-{to}");
240        if let Some(t) = total {
241            let _ = write!(header, "/{t}");
242        }
243        header.push(')');
244    }
245
246    if truncated {
247        header.push_str(" [truncated]");
248    }
249
250    format!("{header}\n{}", content.trim_end())
251}
252
253fn format_status(data: &Value) -> String {
254    let daemon = &data["daemon"];
255    let pid = daemon.get("pid").and_then(Value::as_u64).unwrap_or(0);
256    let uptime = daemon
257        .get("uptime_secs")
258        .and_then(Value::as_u64)
259        .unwrap_or(0);
260    let mut out = format!("daemon: pid={pid} uptime={}", format_duration(uptime));
261
262    // Show config source (only if not auto-detected)
263    if let Some(config) = data.get("config").and_then(|v| v.as_str()) {
264        if config != "auto-detected" {
265            let workspace_count = data
266                .get("project")
267                .and_then(|p| p.get("workspaces"))
268                .and_then(serde_json::Value::as_u64)
269                .unwrap_or(0);
270            let _ = write!(out, "\nconfig: {config} ({workspace_count} workspaces)");
271        }
272    }
273
274    if let Some(lsp) = data.get("lsp") {
275        if !lsp.is_null() {
276            format_lsp_status(lsp, data, &mut out);
277        }
278    }
279
280    if let Some(project) = data.get("project") {
281        let discovered = project
282            .get("workspaces_discovered")
283            .and_then(Value::as_u64)
284            .unwrap_or(0);
285        let attached = project
286            .get("workspaces_attached")
287            .and_then(Value::as_u64)
288            .unwrap_or(0);
289        if discovered > 0 {
290            let _ = write!(
291                out,
292                "\nworkspaces: {discovered} discovered, {attached} attached"
293            );
294        }
295
296        if let Some(langs) = project.get("languages").and_then(|v| v.as_array()) {
297            let names: Vec<&str> = langs.iter().filter_map(|v| v.as_str()).collect();
298            if !names.is_empty() {
299                let _ = write!(out, "\nproject: languages=[{}]", names.join(","));
300            }
301        }
302    }
303
304    // Index / watcher status
305    if let Some(index) = data.get("index") {
306        let watcher = index
307            .get("watcher_active")
308            .and_then(Value::as_bool)
309            .unwrap_or(false);
310        let dirty = index
311            .get("dirty_files")
312            .and_then(Value::as_u64)
313            .unwrap_or(0);
314        if watcher {
315            let _ = write!(out, "\nindex: watcher active, {dirty} dirty files");
316        } else {
317            let _ = write!(out, "\nindex: watcher inactive (BLAKE3 fallback)");
318        }
319    }
320
321    out
322}
323
324fn format_lsp_status(lsp: &Value, _data: &Value, out: &mut String) {
325    let lsp_status = lsp.get("status").and_then(|v| v.as_str()).unwrap_or("?");
326    let progress = lsp.get("progress").and_then(|v| v.as_str()).unwrap_or("");
327
328    if let Some(servers) = lsp.get("servers").and_then(|v| v.as_array()) {
329        let sessions = lsp.get("sessions").and_then(Value::as_u64).unwrap_or(0);
330        let status_tag = if lsp_status != "ready" && !progress.is_empty() {
331            format!(" [{lsp_status} {progress}]")
332        } else {
333            String::new()
334        };
335        let _ = write!(out, "\nlsp: {sessions} servers{status_tag}");
336
337        for s in servers {
338            let lang = s.get("language").and_then(|v| v.as_str()).unwrap_or("?");
339            let server = s.get("server").and_then(|v| v.as_str()).unwrap_or("?");
340            let s_status = s.get("status").and_then(|v| v.as_str()).unwrap_or("?");
341            let attached = s
342                .get("attached_folders")
343                .and_then(Value::as_u64)
344                .unwrap_or(0);
345            let total = s.get("total_folders").and_then(Value::as_u64).unwrap_or(0);
346            let state_tag = if s_status == "ready" {
347                String::new()
348            } else {
349                format!(" [{s_status}]")
350            };
351            let folders = format!("{attached}/{total} folders");
352            let _ = write!(out, "\n  {lang} ({server}) — {folders}{state_tag}");
353        }
354    } else if lsp_status == "pending" && !progress.is_empty() {
355        let _ = write!(out, "\nlsp: pending ({progress})");
356    } else {
357        let lang = lsp.get("language").and_then(|v| v.as_str()).unwrap_or("?");
358        let server = lsp.get("server").and_then(|v| v.as_str()).unwrap_or("?");
359        let _ = write!(out, "\nlsp: {lang} {lsp_status} ({server})");
360    }
361}
362
363fn format_dir_symbols(data: &Value) -> String {
364    let files = match data.get("files").and_then(Value::as_array) {
365        Some(f) if !f.is_empty() => f,
366        _ => return "no results".to_string(),
367    };
368
369    let mut out = String::new();
370    for (i, entry) in files.iter().enumerate() {
371        let file = entry.get("file").and_then(Value::as_str).unwrap_or("?");
372        let _ = writeln!(out, "{file}");
373        if let Some(symbols) = entry.get("symbols").and_then(Value::as_array) {
374            format_symbol_tree(symbols, &mut out, 1);
375        }
376        if i + 1 < files.len() {
377            out.push('\n');
378        }
379    }
380    out.trim_end().to_string()
381}
382
383/// Format references enriched with `--with-symbol`.
384///
385/// Each reference is printed as:
386///   `path:line  [in containingFn (kind:N)]  preview`
387/// Definition sites are printed as:
388///   `path:line  [definition]  preview`
389fn format_enriched_refs(items: &[Value], out: &mut String) {
390    for item in items {
391        let path = item.get("path").and_then(Value::as_str).unwrap_or("?");
392        let line = item.get("line").and_then(Value::as_u64).unwrap_or(0);
393        let preview = item
394            .get("preview")
395            .and_then(Value::as_str)
396            .unwrap_or("")
397            .trim();
398        let is_def = item
399            .get("is_definition")
400            .and_then(Value::as_bool)
401            .unwrap_or(false);
402
403        if is_def {
404            let _ = writeln!(out, "{path}:{line}  [definition]  {preview}");
405            continue;
406        }
407
408        let tag = if let Some(cs) = item.get("containing_symbol") {
409            let sym_name = cs.get("name").and_then(Value::as_str).unwrap_or("?");
410            let sym_kind = cs.get("kind").and_then(Value::as_str).unwrap_or("?");
411            let sym_line = cs.get("line").and_then(Value::as_u64).unwrap_or(0);
412            format!("  [in {sym_name} ({sym_kind}:{sym_line})]")
413        } else {
414            String::new()
415        };
416
417        let _ = writeln!(out, "{path}:{line}{tag}  {preview}");
418    }
419}
420
421fn format_symbol_tree(items: &[Value], out: &mut String, indent: usize) {
422    for item in items {
423        let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
424        let kind = item.get("kind").and_then(Value::as_str).unwrap_or("?");
425        let prefix = "  ".repeat(indent);
426        let _ = writeln!(out, "{prefix}{kind} {name}");
427        if let Some(children) = item.get("children").and_then(Value::as_array) {
428            format_symbol_tree(children, out, indent + 1);
429        }
430    }
431}
432
433fn format_check(data: &Value, diags: &[Value]) -> String {
434    if diags.is_empty() {
435        return "No diagnostics".to_string();
436    }
437
438    let mut out = String::new();
439    for d in diags {
440        let sev = d.get("severity").and_then(Value::as_str).unwrap_or("?");
441        let path = d.get("path").and_then(Value::as_str).unwrap_or("?");
442        let line = d.get("line").and_then(Value::as_u64).unwrap_or(0);
443        let col = d.get("col").and_then(Value::as_u64).unwrap_or(0);
444        let code = d
445            .get("code")
446            .and_then(Value::as_str)
447            .filter(|s| !s.is_empty())
448            .unwrap_or("");
449        let msg = d.get("message").and_then(Value::as_str).unwrap_or("");
450
451        if code.is_empty() {
452            let _ = writeln!(out, "{sev:<5} {path}:{line}:{col} {msg}");
453        } else {
454            let _ = writeln!(out, "{sev:<5} {path}:{line}:{col} {code} {msg}");
455        }
456    }
457
458    let total = data.get("total").and_then(Value::as_u64).unwrap_or(0);
459    let errors = data.get("errors").and_then(Value::as_u64).unwrap_or(0);
460    let warnings = data.get("warnings").and_then(Value::as_u64).unwrap_or(0);
461
462    let mut summary = format!("{total} diagnostic");
463    if total != 1 {
464        summary.push('s');
465    }
466
467    let mut parts: Vec<String> = vec![];
468    if errors > 0 {
469        parts.push(format!(
470            "{errors} error{}",
471            if errors == 1 { "" } else { "s" }
472        ));
473    }
474    if warnings > 0 {
475        parts.push(format!(
476            "{warnings} warning{}",
477            if warnings == 1 { "" } else { "s" }
478        ));
479    }
480    if !parts.is_empty() {
481        let joined = parts.join(", ");
482        summary.push_str(" (");
483        summary.push_str(&joined);
484        summary.push(')');
485    }
486
487    out.push_str(&summary);
488    out
489}
490
491fn format_hover(data: &Value) -> String {
492    let content = data
493        .get("hover_content")
494        .and_then(Value::as_str)
495        .unwrap_or("")
496        .trim();
497    let path = data.get("path").and_then(Value::as_str).unwrap_or("?");
498    let line = data.get("line").and_then(Value::as_u64).unwrap_or(0);
499
500    if content.is_empty() {
501        return format!("No hover information available ({path}:{line})");
502    }
503
504    format!("{content}\n{path}:{line}")
505}
506
507fn format_format(data: &Value) -> String {
508    let path = data.get("path").and_then(Value::as_str).unwrap_or("?");
509    let n = data
510        .get("edits_applied")
511        .and_then(Value::as_u64)
512        .unwrap_or(0);
513    if n == 0 {
514        format!("No changes needed ({path})")
515    } else {
516        format!("Formatted {path} ({n} edits)")
517    }
518}
519
520fn format_rename(data: &Value) -> String {
521    let files = data
522        .get("files_changed")
523        .and_then(Value::as_u64)
524        .unwrap_or(0);
525    let refs = data
526        .get("refs_changed")
527        .and_then(Value::as_u64)
528        .unwrap_or(0);
529    if files == 0 {
530        "No references renamed".to_string()
531    } else {
532        format!("Renamed {refs} refs across {files} files")
533    }
534}
535
536fn format_fix(data: &Value) -> String {
537    let n = data
538        .get("fixes_applied")
539        .and_then(Value::as_u64)
540        .unwrap_or(0);
541    if n == 0 {
542        return "No fixes available".to_string();
543    }
544
545    let files: Vec<&str> = data
546        .get("files")
547        .and_then(Value::as_array)
548        .map(|arr| arr.iter().filter_map(Value::as_str).collect())
549        .unwrap_or_default();
550
551    let file_list = files.join(", ");
552    format!("Applied {n} fix(es) in {file_list}")
553}
554
555/// Format search results as compact output.
556#[must_use]
557pub fn format_search(output: &SearchOutput, with_context: bool, files_only: bool) -> String {
558    let mut out = String::new();
559
560    if files_only {
561        let mut seen = std::collections::BTreeSet::new();
562        for m in &output.matches {
563            seen.insert(m.path.as_str());
564        }
565        for path in &seen {
566            let _ = writeln!(out, "{path}");
567        }
568        let n = seen.len();
569        let _ = write!(out, "{n} {}", if n == 1 { "file" } else { "files" });
570        return out;
571    }
572
573    if with_context {
574        format_search_with_context(output, &mut out);
575    } else {
576        format_search_flat(output, &mut out);
577    }
578
579    out
580}
581
582fn format_search_flat(output: &SearchOutput, out: &mut String) {
583    // Compute max width of "path:line:col" prefix for alignment
584    let max_loc_len = output
585        .matches
586        .iter()
587        .map(|m| format!("{}:{}:{}", m.path, m.line, m.column).len())
588        .max()
589        .unwrap_or(0);
590
591    for m in &output.matches {
592        let loc = format!("{}:{}:{}", m.path, m.line, m.column);
593        let _ = writeln!(
594            out,
595            "{loc:<width$}  {preview}",
596            width = max_loc_len,
597            preview = m.preview.trim()
598        );
599    }
600
601    let n = output.total_matches;
602    let f = output.files_with_matches;
603    let trunc = if output.truncated { " [truncated]" } else { "" };
604    let _ = write!(
605        out,
606        "{n} {} in {f} {}{}",
607        if n == 1 { "match" } else { "matches" },
608        if f == 1 { "file" } else { "files" },
609        trunc,
610    );
611}
612
613fn format_search_with_context(output: &SearchOutput, out: &mut String) {
614    // Group matches by file, preserving order
615    let mut current_file: Option<&str> = None;
616
617    for m in &output.matches {
618        if current_file != Some(m.path.as_str()) {
619            if current_file.is_some() {
620                out.push_str("──\n");
621            }
622            let _ = writeln!(out, "{}", m.path);
623            current_file = Some(m.path.as_str());
624        }
625
626        // Compute line number width for this block
627        let max_line = m.line as usize + m.context_after.len();
628        let width = max_line.to_string().len();
629
630        let start_line = m.line as usize - m.context_before.len();
631        for (i, ctx) in m.context_before.iter().enumerate() {
632            let lno = start_line + i;
633            let _ = writeln!(out, "  {lno:>width$}  {ctx}");
634        }
635        let _ = writeln!(out, "> {:>width$}  {}", m.line, m.preview.trim());
636        for (i, ctx) in m.context_after.iter().enumerate() {
637            let lno = m.line as usize + 1 + i;
638            let _ = writeln!(out, "  {lno:>width$}  {ctx}");
639        }
640    }
641
642    if current_file.is_some() {
643        out.push_str("──\n");
644    }
645
646    let n = output.total_matches;
647    let f = output.files_with_matches;
648    let trunc = if output.truncated { " [truncated]" } else { "" };
649    let _ = write!(
650        out,
651        "{n} {} in {f} {}{}",
652        if n == 1 { "match" } else { "matches" },
653        if f == 1 { "file" } else { "files" },
654        trunc,
655    );
656}
657
658fn format_daemon_server_status(servers: &[Value]) -> String {
659    if servers.is_empty() {
660        return "no servers running".to_string();
661    }
662    let mut out = String::new();
663    for s in servers {
664        let lang = s.get("language").and_then(Value::as_str).unwrap_or("?");
665        let server = s.get("server").and_then(Value::as_str).unwrap_or("?");
666        let status = s.get("status").and_then(Value::as_str).unwrap_or("?");
667        let attached = s
668            .get("attached_folders")
669            .and_then(Value::as_u64)
670            .unwrap_or(0);
671        let total = s.get("total_folders").and_then(Value::as_u64).unwrap_or(0);
672        let uptime = s.get("uptime_secs").and_then(Value::as_u64).unwrap_or(0);
673        let uptime_str = if uptime > 0 {
674            format!(" uptime={}", format_duration(uptime))
675        } else {
676            String::new()
677        };
678        let state_tag = if status == "ready" {
679            String::new()
680        } else {
681            format!(" [{status}]")
682        };
683        let _ = writeln!(
684            out,
685            "{lang:<12}  {server:<24}  {attached}/{total} folders{state_tag}{uptime_str}"
686        );
687    }
688    out.trim_end().to_string()
689}
690
691fn format_duration(secs: u64) -> String {
692    if secs < 60 {
693        format!("{secs}s")
694    } else if secs < 3600 {
695        format!("{}m", secs / 60)
696    } else {
697        let h = secs / 3600;
698        let m = (secs % 3600) / 60;
699        if m == 0 {
700            format!("{h}h")
701        } else {
702            format!("{h}h{m}m")
703        }
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use serde_json::json;
710
711    use super::*;
712
713    #[test]
714    fn compact_status_output() {
715        let resp = Response::ok(json!({"daemon": {"pid": 12345, "uptime_secs": 300}}));
716        let out = format(&resp);
717        assert_eq!(out, "daemon: pid=12345 uptime=5m");
718    }
719
720    #[test]
721    fn compact_error_output() {
722        let resp = Response::err_with_advice("lsp_not_found", "LSP not detected", "Install it");
723        let out = format(&resp);
724        assert!(out.contains("error: LSP not detected"));
725        assert!(out.contains("advice: Install it"));
726    }
727
728    #[test]
729    fn compact_symbol_results() {
730        let resp = Response::ok(json!([
731            {"path": "src/lib.rs", "line": 5, "kind": "function", "preview": "fn greet(name: &str) -> String"},
732            {"path": "src/lib.rs", "line": 15, "kind": "struct", "preview": "struct Config"}
733        ]));
734        let out = format(&resp);
735        assert!(out.contains("src/lib.rs:5 function fn greet"));
736        assert!(out.contains("src/lib.rs:15 struct struct Config"));
737    }
738
739    #[test]
740    fn compact_reference_results() {
741        let resp = Response::ok(json!([
742            {"path": "src/lib.rs", "line": 5, "preview": "pub fn greet()", "is_definition": true},
743            {"path": "src/main.rs", "line": 8, "preview": "let msg = greet(\"world\");", "is_definition": false}
744        ]));
745        let out = format(&resp);
746        assert!(out.contains("[definition]"));
747        assert!(out.contains("src/main.rs:8"));
748    }
749
750    #[test]
751    fn compact_empty_results() {
752        let resp = Response::ok(json!([]));
753        let out = format(&resp);
754        assert_eq!(out, "no results");
755    }
756
757    #[test]
758    fn compact_file_content_output() {
759        let resp = Response::ok(json!({
760            "path": "src/main.rs",
761            "content": "   1\tfn main() {\n   2\t    println!(\"hello\");\n   3\t}\n",
762            "from": 1,
763            "to": 3,
764            "total": 3,
765            "truncated": false,
766        }));
767        let out = format(&resp);
768        assert!(out.starts_with("src/main.rs (1-3/3)"));
769        assert!(out.contains("fn main()"));
770    }
771
772    #[test]
773    fn compact_file_content_truncated() {
774        let resp = Response::ok(json!({
775            "path": "big.rs",
776            "content": "   1\tline1\n",
777            "from": 1,
778            "to": 200,
779            "total": 500,
780            "truncated": true,
781        }));
782        let out = format(&resp);
783        assert!(out.contains("[truncated]"));
784    }
785
786    #[test]
787    fn compact_symbol_content_output() {
788        let resp = Response::ok(json!({
789            "path": "src/lib.rs",
790            "symbol": "Config",
791            "kind": "struct",
792            "content": "   5\tpub struct Config {\n   6\t    name: String,\n   7\t}\n",
793            "from": 5,
794            "to": 7,
795            "truncated": false,
796        }));
797        let out = format(&resp);
798        assert!(out.starts_with("struct Config in src/lib.rs (5-7)"));
799        assert!(out.contains("pub struct Config"));
800    }
801
802    #[test]
803    fn compact_check_with_diagnostics() {
804        let resp = Response::ok(json!({
805            "diagnostics": [
806                {"severity": "error", "path": "src/lib.rs", "line": 42, "col": 10, "code": "E0308", "message": "mismatched types"},
807                {"severity": "warn", "path": "src/main.rs", "line": 3, "col": 5, "code": "", "message": "unused import"}
808            ],
809            "total": 2,
810            "errors": 1,
811            "warnings": 1,
812        }));
813        let out = format(&resp);
814        assert!(out.contains("error src/lib.rs:42:10 E0308 mismatched types"));
815        assert!(out.contains("warn  src/main.rs:3:5 unused import"));
816        assert!(out.contains("2 diagnostics"));
817        assert!(out.contains("1 error"));
818        assert!(out.contains("1 warning"));
819    }
820
821    #[test]
822    fn compact_check_no_diagnostics() {
823        let resp = Response::ok(json!({
824            "diagnostics": [],
825            "total": 0,
826            "errors": 0,
827            "warnings": 0,
828        }));
829        let out = format(&resp);
830        assert_eq!(out, "No diagnostics");
831    }
832
833    #[test]
834    fn compact_duration_formatting() {
835        assert_eq!(format_duration(30), "30s");
836        assert_eq!(format_duration(300), "5m");
837        assert_eq!(format_duration(3600), "1h");
838        assert_eq!(format_duration(3900), "1h5m");
839    }
840}