1use std::fmt::Write as _;
2
3use serde_json::{json, Value};
4
5use crate::cli::OutputFormat;
6use crate::config::parse_language;
7use crate::detect::Language;
8use crate::lsp::install;
9use crate::lsp::registry::{find_server, get_entries, servers_dir};
10
11#[derive(serde::Serialize)]
13pub struct ServerListEntry {
14 pub language: String,
15 pub server_name: String,
16 pub status: String,
17 pub path: String,
18 pub install_advice: String,
19}
20
21#[must_use]
23pub fn build_server_list() -> Vec<ServerListEntry> {
24 let mut seen_binaries = std::collections::HashSet::new();
25 let mut rows = Vec::new();
26
27 for &lang in Language::ALL {
28 let entries = get_entries(lang);
29 let Some(preferred) = entries.first() else {
30 continue;
31 };
32
33 if seen_binaries.contains(preferred.binary_name) {
35 rows.push(ServerListEntry {
37 language: lang.name().to_string(),
38 server_name: preferred.binary_name.to_string(),
39 status: "shared".to_string(),
40 path: "(shared with typescript)".to_string(),
41 install_advice: preferred.install_advice.to_string(),
42 });
43 continue;
44 }
45
46 let (status, path) = match find_server(preferred) {
47 Some(p) => ("installed".to_string(), p.to_string_lossy().to_string()),
48 None => (
49 "not installed".to_string(),
50 format!("run: krait server install {}", lang.name()),
51 ),
52 };
53
54 seen_binaries.insert(preferred.binary_name);
55 rows.push(ServerListEntry {
56 language: lang.name().to_string(),
57 server_name: preferred.binary_name.to_string(),
58 status,
59 path,
60 install_advice: preferred.install_advice.to_string(),
61 });
62 }
63
64 rows
65}
66
67pub fn handle_list(format: OutputFormat) -> anyhow::Result<()> {
72 let rows = build_server_list();
73 match format {
74 OutputFormat::Json => {
75 println!("{}", serde_json::to_string_pretty(&rows)?);
76 }
77 _ => {
78 println!("{}", format_server_list(&rows));
79 }
80 }
81 Ok(())
82}
83
84pub async fn handle_install(
89 lang: Option<&str>,
90 reinstall: bool,
91 format: OutputFormat,
92) -> anyhow::Result<()> {
93 let languages: Vec<Language> = if let Some(name) = lang {
94 let l = parse_language(name).ok_or_else(|| anyhow::anyhow!("unknown language: {name}"))?;
95 vec![l]
96 } else {
97 Language::ALL.to_vec()
98 };
99
100 let mut any_installed = false;
101
102 for language in languages {
103 let entries = get_entries(language);
104 let preferred = match entries.first() {
105 Some(e) => e.clone(),
106 None => continue,
107 };
108
109 if !reinstall {
111 if let Some(path) = find_server(&preferred) {
112 if lang.is_some() {
113 let msg = format!(
115 "{} already installed at {}",
116 preferred.binary_name,
117 path.display()
118 );
119 match format {
120 OutputFormat::Json => {
121 println!(
122 "{}",
123 serde_json::to_string(&json!({
124 "language": language.name(),
125 "server_name": preferred.binary_name,
126 "status": "already_installed",
127 "path": path.to_string_lossy()
128 }))?
129 );
130 }
131 _ => println!("{msg}"),
132 }
133 }
134 continue;
135 }
136 }
137
138 if reinstall {
140 let managed_dir = servers_dir();
141 let managed = managed_dir.join(preferred.binary_name);
142 if managed.exists() {
143 std::fs::remove_file(&managed).unwrap_or_else(|e| {
144 tracing::warn!("could not remove {}: {e}", managed.display());
145 });
146 }
147 }
148
149 match install::download_server(&preferred).await {
150 Ok(path) => {
151 any_installed = true;
152 match format {
153 OutputFormat::Json => {
154 println!(
155 "{}",
156 serde_json::to_string(&json!({
157 "installed": preferred.binary_name,
158 "language": language.name(),
159 "path": path.to_string_lossy()
160 }))?
161 );
162 }
163 _ => println!("installed {} → {}", preferred.binary_name, path.display()),
164 }
165 }
166 Err(e) => {
167 eprintln!("error: failed to install {}: {e}", preferred.binary_name);
168 }
169 }
170 }
171
172 if lang.is_none() && !any_installed {
174 match format {
175 OutputFormat::Json => {}
176 _ => println!("all servers already installed"),
177 }
178 }
179
180 Ok(())
181}
182
183pub fn handle_clean(format: OutputFormat) -> anyhow::Result<()> {
188 let bytes = install::clean_servers()?;
189 #[allow(clippy::cast_precision_loss)]
190 let mb = bytes as f64 / 1_048_576.0;
191
192 match format {
193 OutputFormat::Json => {
194 println!(
195 "{}",
196 serde_json::to_string(&json!({
197 "cleaned": true,
198 "path": servers_dir().to_string_lossy(),
199 "bytes_freed": bytes
200 }))?
201 );
202 }
203 _ => {
204 if bytes == 0 {
205 println!("nothing to clean (~/.krait/servers/ was empty or missing)");
206 } else {
207 println!("cleaned ~/.krait/servers/ ({mb:.1} MB freed)");
208 }
209 }
210 }
211 Ok(())
212}
213
214#[must_use]
216pub fn format_server_list(rows: &[ServerListEntry]) -> String {
217 if rows.is_empty() {
218 return "no servers configured".to_string();
219 }
220
221 let lang_w = rows
223 .iter()
224 .map(|r| r.language.len())
225 .max()
226 .unwrap_or(0)
227 .max(8);
228 let name_w = rows
229 .iter()
230 .map(|r| r.server_name.len())
231 .max()
232 .unwrap_or(0)
233 .max(11);
234 let stat_w = rows
235 .iter()
236 .map(|r| r.status.len())
237 .max()
238 .unwrap_or(0)
239 .max(13);
240
241 let mut out = String::new();
242 for row in rows {
243 let _ = writeln!(
244 out,
245 "{:<lang_w$} {:<name_w$} {:<stat_w$} {}",
246 row.language,
247 row.server_name,
248 row.status,
249 row.path,
250 lang_w = lang_w,
251 name_w = name_w,
252 stat_w = stat_w,
253 );
254 }
255
256 let missing: Vec<&ServerListEntry> = rows
258 .iter()
259 .filter(|r| r.status == "not installed")
260 .collect();
261 if !missing.is_empty() {
262 out.push('\n');
263 for row in missing {
264 let _ = writeln!(out, " {}", row.install_advice);
265 }
266 }
267
268 out.trim_end().to_string()
269}
270
271#[must_use]
274pub fn format_server_list_json(items: &[Value]) -> String {
275 let rows: Vec<ServerListEntry> = items
276 .iter()
277 .filter_map(|v| {
278 Some(ServerListEntry {
279 language: v.get("language")?.as_str()?.to_string(),
280 server_name: v.get("server_name")?.as_str()?.to_string(),
281 status: v.get("status")?.as_str()?.to_string(),
282 path: v.get("path")?.as_str()?.to_string(),
283 install_advice: v
284 .get("install_advice")
285 .and_then(|x| x.as_str())
286 .unwrap_or("")
287 .to_string(),
288 })
289 })
290 .collect();
291 format_server_list(&rows)
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn build_server_list_covers_all_languages() {
300 let rows = build_server_list();
301 assert_eq!(rows.len(), Language::ALL.len());
303 }
304
305 #[test]
306 fn build_server_list_has_rust_analyzer() {
307 let rows = build_server_list();
308 let rust = rows.iter().find(|r| r.language == "rust").unwrap();
309 assert_eq!(rust.server_name, "rust-analyzer");
310 }
311
312 #[test]
313 fn format_server_list_empty() {
314 assert_eq!(format_server_list(&[]), "no servers configured");
315 }
316
317 #[test]
318 fn format_server_list_installed() {
319 let rows = vec![ServerListEntry {
320 language: "rust".to_string(),
321 server_name: "rust-analyzer".to_string(),
322 status: "installed".to_string(),
323 path: "/usr/local/bin/rust-analyzer".to_string(),
324 install_advice: String::new(),
325 }];
326 let out = format_server_list(&rows);
327 assert!(out.contains("rust"));
328 assert!(out.contains("rust-analyzer"));
329 assert!(out.contains("installed"));
330 }
331}