Skip to main content

krait/commands/
server.rs

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/// Row in the server list table.
12#[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/// Build the list of all servers and their install status.
22#[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        // Skip JavaScript if it shares the same binary as TypeScript (already shown).
34        if seen_binaries.contains(preferred.binary_name) {
35            // Still emit a row but mark it as shared.
36            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
67/// Handle `krait server list`.
68///
69/// # Errors
70/// Returns an error if JSON serialization fails.
71pub 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
84/// Handle `krait server install [lang] [--reinstall]`.
85///
86/// # Errors
87/// Returns an error if the language is unknown or download fails.
88pub 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        // Skip if already installed (unless --reinstall).
110        if !reinstall {
111            if let Some(path) = find_server(&preferred) {
112                if lang.is_some() {
113                    // Explicit single-lang install: report already installed.
114                    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 and managed, delete managed binary first.
139        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    // When installing all, summarise if nothing was missing.
173    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
183/// Handle `krait server clean`.
184///
185/// # Errors
186/// Returns an error if the clean operation or JSON serialization fails.
187pub 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/// Compact text formatter for server list (used by output/compact.rs too).
215#[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    // Column widths
222    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    // Advice for not-installed entries
257    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/// Format a JSON server list value (array of objects) for compact output.
272/// Used by compact.rs when the daemon response is a server list.
273#[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        // Should have one row per Language::ALL entry
302        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}