1use std::fmt::Write;
2
3use serde_json::Value;
4
5use crate::commands::search::SearchOutput;
6use crate::protocol::Response;
7
8#[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 if data.get("daemon").is_some() {
26 return format_status(data);
27 }
28
29 if data.get("files_indexed").is_some() {
31 return format_init(data);
32 }
33
34 if data.get("content").is_some() && data.get("path").is_some() {
36 return format_file_content(data);
37 }
38
39 if data.get("dir").and_then(Value::as_bool).unwrap_or(false) {
41 return format_dir_symbols(data);
42 }
43
44 if let Some(diags) = data.get("diagnostics").and_then(|v| v.as_array()) {
46 return format_check(data, diags);
47 }
48
49 if data.get("lines_before").is_some() {
51 return crate::commands::edit::format_replace(data);
52 }
53
54 if data.get("hover_content").is_some() {
56 return format_hover(data);
57 }
58
59 if data.get("edits_applied").is_some() {
61 return format_format(data);
62 }
63
64 if data.get("files_changed").is_some() {
66 return format_rename(data);
67 }
68
69 if data.get("fixes_applied").is_some() {
71 return format_fix(data);
72 }
73
74 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 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 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 if let Some(servers) = data.get("servers").and_then(Value::as_array) {
106 return format_daemon_server_status(servers);
107 }
108
109 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 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 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 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 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 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 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 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 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 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 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
383fn 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#[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 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 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 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}