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 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 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 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 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 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
372fn 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#[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 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 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 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}