Skip to main content

krait/
config.rs

1use std::collections::HashMap;
2use std::fmt::Write;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use tracing::{debug, warn};
7
8use crate::detect::Language;
9
10/// Primary config file name (inside .krait/ directory).
11const CONFIG_FILE: &str = ".krait/krait.toml";
12
13/// Legacy config location (project root, for backwards compat).
14const LEGACY_CONFIG_FILE: &str = "krait.toml";
15
16/// Parsed project configuration from `krait.toml`.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ProjectConfig {
19    /// Project root override (relative to config file location).
20    pub root: Option<String>,
21
22    /// Workspace entries — each gets its own LSP server.
23    #[serde(default)]
24    pub workspace: Vec<WorkspaceEntry>,
25
26    /// Per-language server overrides.
27    #[serde(default)]
28    pub servers: HashMap<String, ServerOverride>,
29
30    /// Workspaces to pre-warm and exempt from LRU eviction.
31    /// Paths are relative to the project root.
32    #[serde(default)]
33    pub primary_workspaces: Vec<String>,
34
35    /// Maximum concurrent LSP sessions for LRU fallback (default: 10).
36    pub max_active_sessions: Option<usize>,
37
38    /// Maximum concurrent language server processes across all languages (default: unlimited).
39    /// When exceeded, the least-recently-used language server is shut down.
40    pub max_language_servers: Option<usize>,
41}
42
43/// One workspace scope for LSP indexing.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct WorkspaceEntry {
46    /// Relative path to workspace root (e.g., "packages/api").
47    pub path: String,
48
49    /// Language identifier (e.g., "typescript", "rust").
50    pub language: String,
51
52    /// Server binary override (e.g., "vtsls").
53    pub server: Option<String>,
54}
55
56/// Override default server binary/args for a language.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ServerOverride {
59    /// Binary name or path.
60    pub binary: Option<String>,
61
62    /// Command-line arguments.
63    pub args: Option<Vec<String>>,
64}
65
66/// Where the config was loaded from.
67#[derive(Debug, Clone)]
68pub enum ConfigSource {
69    /// Loaded from `.krait/krait.toml`.
70    KraitToml,
71    /// Loaded from legacy `krait.toml` at project root.
72    LegacyKraitToml,
73    /// No config file found — using auto-detection.
74    AutoDetected,
75}
76
77impl ConfigSource {
78    /// Human-readable label for status output.
79    #[must_use]
80    pub fn label(&self) -> &'static str {
81        match self {
82            Self::KraitToml => ".krait/krait.toml",
83            Self::LegacyKraitToml => "krait.toml",
84            Self::AutoDetected => "auto-detected",
85        }
86    }
87}
88
89/// Result of loading config: the config (if any) and its source.
90#[derive(Debug, Clone)]
91pub struct LoadedConfig {
92    pub config: Option<ProjectConfig>,
93    pub source: ConfigSource,
94}
95
96/// Try to load `.krait/krait.toml` (or legacy `krait.toml`) from the project root.
97///
98/// Returns `None` config with `AutoDetected` source if no config file exists.
99#[must_use]
100pub fn load(project_root: &Path) -> LoadedConfig {
101    // Try .krait/krait.toml first
102    let primary = project_root.join(CONFIG_FILE);
103    if primary.is_file() {
104        match load_from_file(&primary) {
105            Ok(config) => {
106                debug!("loaded config from {}", primary.display());
107                return LoadedConfig {
108                    config: Some(config),
109                    source: ConfigSource::KraitToml,
110                };
111            }
112            Err(e) => {
113                warn!("failed to parse {}: {e}", primary.display());
114            }
115        }
116    }
117
118    // Try legacy krait.toml at project root
119    let legacy = project_root.join(LEGACY_CONFIG_FILE);
120    if legacy.is_file() {
121        match load_from_file(&legacy) {
122            Ok(config) => {
123                debug!("loaded config from {} (legacy location)", legacy.display());
124                return LoadedConfig {
125                    config: Some(config),
126                    source: ConfigSource::LegacyKraitToml,
127                };
128            }
129            Err(e) => {
130                warn!("failed to parse {}: {e}", legacy.display());
131            }
132        }
133    }
134
135    debug!("no config found, using auto-detection");
136    LoadedConfig {
137        config: None,
138        source: ConfigSource::AutoDetected,
139    }
140}
141
142fn load_from_file(path: &Path) -> anyhow::Result<ProjectConfig> {
143    let content = std::fs::read_to_string(path)?;
144    let config: ProjectConfig = toml::from_str(&content)?;
145    Ok(config)
146}
147
148/// Convert a loaded config into `(Language, PathBuf)` pairs for the multiplexer.
149///
150/// Validates each entry: skips entries with unknown languages or missing directories.
151#[must_use]
152pub fn config_to_package_roots(
153    config: &ProjectConfig,
154    project_root: &Path,
155) -> Vec<(Language, PathBuf)> {
156    let mut roots = Vec::new();
157
158    for entry in &config.workspace {
159        let Some(lang) = parse_language(&entry.language) else {
160            warn!(
161                "unknown language '{}' in krait.toml, skipping workspace '{}'",
162                entry.language, entry.path
163            );
164            continue;
165        };
166
167        let abs_path = project_root.join(&entry.path);
168        if !abs_path.is_dir() {
169            warn!("workspace path '{}' does not exist, skipping", entry.path);
170            continue;
171        }
172
173        roots.push((lang, abs_path));
174    }
175
176    roots
177}
178
179/// Parse a language name string into a `Language` enum.
180#[must_use]
181pub fn parse_language(name: &str) -> Option<Language> {
182    match name.to_lowercase().as_str() {
183        "rust" => Some(Language::Rust),
184        "typescript" | "ts" => Some(Language::TypeScript),
185        "javascript" | "js" => Some(Language::JavaScript),
186        "go" | "golang" => Some(Language::Go),
187        "cpp" | "c++" | "cxx" | "c" => Some(Language::Cpp),
188        _ => None,
189    }
190}
191
192/// Generate a `krait.toml` config string from detected workspace roots.
193#[must_use]
194pub fn generate(package_roots: &[(Language, PathBuf)], project_root: &Path) -> String {
195    let mut out = String::from("# krait.toml — generated by `krait init`\n");
196    out.push_str("# Edit this file to customize which workspaces to index.\n");
197    out.push_str("# Remove entries you don't need. Run `krait daemon stop` after changes.\n\n");
198
199    for (lang, abs_path) in package_roots {
200        let rel = abs_path
201            .strip_prefix(project_root)
202            .unwrap_or(abs_path)
203            .to_string_lossy();
204        let path_str = if rel.is_empty() { "." } else { &rel };
205
206        let _ = writeln!(out, "[[workspace]]");
207        let _ = writeln!(out, "path = \"{path_str}\"");
208        let _ = writeln!(out, "language = \"{}\"", lang.name());
209        out.push('\n');
210    }
211
212    // Add commented primary_workspaces section
213    out.push_str("# Priority workspaces — always warm, exempt from LRU eviction\n");
214    out.push_str("# primary_workspaces = [\"packages/core\", \"packages/api\"]\n\n");
215
216    // Add commented max_active_sessions
217    out.push_str("# Maximum concurrent LSP sessions for non-multi-root servers (default: 10)\n");
218    out.push_str("# max_active_sessions = 10\n\n");
219
220    out.push_str("# Maximum concurrent language server processes across all languages (default: unlimited)\n");
221    out.push_str("# When exceeded, the least-recently-used language server is shut down.\n");
222    out.push_str("# max_language_servers = 10\n\n");
223
224    // Add commented server overrides section
225    out.push_str("# Server overrides (uncomment to customize)\n");
226    out.push_str("# [servers.typescript]\n");
227    out.push_str("# binary = \"vtsls\"\n");
228    out.push_str("# args = [\"--stdio\"]\n");
229
230    out
231}
232
233/// Write config to `.krait/krait.toml`, creating `.krait/` if needed.
234///
235/// # Errors
236/// Returns an error if the directory or file cannot be written.
237pub fn write_config(project_root: &Path, content: &str) -> anyhow::Result<()> {
238    let krait_dir = project_root.join(".krait");
239    std::fs::create_dir_all(&krait_dir)?;
240    let path = project_root.join(CONFIG_FILE);
241    std::fs::write(&path, content)?;
242    Ok(())
243}
244
245/// Check if a config file already exists.
246#[must_use]
247pub fn config_exists(project_root: &Path) -> bool {
248    project_root.join(CONFIG_FILE).is_file() || project_root.join(LEGACY_CONFIG_FILE).is_file()
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn parse_language_variants() {
257        assert_eq!(parse_language("rust"), Some(Language::Rust));
258        assert_eq!(parse_language("typescript"), Some(Language::TypeScript));
259        assert_eq!(parse_language("ts"), Some(Language::TypeScript));
260        assert_eq!(parse_language("javascript"), Some(Language::JavaScript));
261        assert_eq!(parse_language("js"), Some(Language::JavaScript));
262        assert_eq!(parse_language("go"), Some(Language::Go));
263        assert_eq!(parse_language("golang"), Some(Language::Go));
264        assert_eq!(parse_language("c++"), Some(Language::Cpp));
265        assert_eq!(parse_language("cpp"), Some(Language::Cpp));
266        assert_eq!(parse_language("unknown"), None);
267    }
268
269    #[test]
270    fn parse_language_case_insensitive() {
271        assert_eq!(parse_language("Rust"), Some(Language::Rust));
272        assert_eq!(parse_language("TYPESCRIPT"), Some(Language::TypeScript));
273    }
274
275    #[test]
276    fn load_returns_auto_detected_when_no_config() {
277        let dir = tempfile::tempdir().unwrap();
278        let loaded = load(dir.path());
279        assert!(loaded.config.is_none());
280        assert!(matches!(loaded.source, ConfigSource::AutoDetected));
281    }
282
283    #[test]
284    fn load_reads_krait_toml() {
285        let dir = tempfile::tempdir().unwrap();
286        std::fs::create_dir(dir.path().join(".krait")).unwrap();
287        let config_content = r#"
288[[workspace]]
289path = "."
290language = "rust"
291"#;
292        std::fs::write(dir.path().join(".krait/krait.toml"), config_content).unwrap();
293
294        let loaded = load(dir.path());
295        assert!(loaded.config.is_some());
296        assert!(matches!(loaded.source, ConfigSource::KraitToml));
297
298        let config = loaded.config.unwrap();
299        assert_eq!(config.workspace.len(), 1);
300        assert_eq!(config.workspace[0].path, ".");
301        assert_eq!(config.workspace[0].language, "rust");
302    }
303
304    #[test]
305    fn load_reads_legacy_config() {
306        let dir = tempfile::tempdir().unwrap();
307        let config_content = r#"
308[[workspace]]
309path = "."
310language = "go"
311"#;
312        std::fs::write(dir.path().join("krait.toml"), config_content).unwrap();
313
314        let loaded = load(dir.path());
315        assert!(loaded.config.is_some());
316        assert!(matches!(loaded.source, ConfigSource::LegacyKraitToml));
317    }
318
319    #[test]
320    fn krait_toml_takes_priority_over_legacy() {
321        let dir = tempfile::tempdir().unwrap();
322        std::fs::create_dir(dir.path().join(".krait")).unwrap();
323        std::fs::write(
324            dir.path().join(".krait/krait.toml"),
325            "[[workspace]]\npath = \".\"\nlanguage = \"rust\"\n",
326        )
327        .unwrap();
328        std::fs::write(
329            dir.path().join("krait.toml"),
330            "[[workspace]]\npath = \".\"\nlanguage = \"go\"\n",
331        )
332        .unwrap();
333
334        let loaded = load(dir.path());
335        let config = loaded.config.unwrap();
336        assert_eq!(config.workspace[0].language, "rust");
337        assert!(matches!(loaded.source, ConfigSource::KraitToml));
338    }
339
340    #[test]
341    fn config_to_package_roots_validates() {
342        let dir = tempfile::tempdir().unwrap();
343        std::fs::create_dir(dir.path().join("src")).unwrap();
344
345        let config = ProjectConfig {
346            root: None,
347            workspace: vec![
348                WorkspaceEntry {
349                    path: "src".to_string(),
350                    language: "rust".to_string(),
351                    server: None,
352                },
353                WorkspaceEntry {
354                    path: "nonexistent".to_string(),
355                    language: "rust".to_string(),
356                    server: None,
357                },
358                WorkspaceEntry {
359                    path: "src".to_string(),
360                    language: "fakeLang".to_string(),
361                    server: None,
362                },
363            ],
364            servers: HashMap::new(),
365            primary_workspaces: vec![],
366            max_active_sessions: None,
367            max_language_servers: None,
368        };
369
370        let roots = config_to_package_roots(&config, dir.path());
371        assert_eq!(roots.len(), 1, "only valid entries should be returned");
372        assert_eq!(roots[0].0, Language::Rust);
373    }
374
375    #[test]
376    fn generate_produces_valid_toml() {
377        let dir = tempfile::tempdir().unwrap();
378        let roots = vec![
379            (Language::TypeScript, dir.path().join("packages/api")),
380            (Language::TypeScript, dir.path().join("packages/web")),
381        ];
382
383        let content = generate(&roots, dir.path());
384        assert!(content.contains("[[workspace]]"));
385        assert!(content.contains("packages/api"));
386        assert!(content.contains("packages/web"));
387        assert!(content.contains("language = \"typescript\""));
388
389        // Verify it parses back
390        let parsed: ProjectConfig = toml::from_str(&content).unwrap();
391        assert_eq!(parsed.workspace.len(), 2);
392    }
393
394    #[test]
395    fn generate_dot_for_root_workspace() {
396        let dir = tempfile::tempdir().unwrap();
397        let roots = vec![(Language::Rust, dir.path().to_path_buf())];
398
399        let content = generate(&roots, dir.path());
400        assert!(content.contains("path = \".\""));
401    }
402
403    #[test]
404    fn config_exists_detects_files() {
405        let dir = tempfile::tempdir().unwrap();
406        assert!(!config_exists(dir.path()));
407
408        std::fs::create_dir(dir.path().join(".krait")).unwrap();
409        std::fs::write(dir.path().join(".krait/krait.toml"), "").unwrap();
410        assert!(config_exists(dir.path()));
411    }
412
413    #[test]
414    fn config_with_primary_workspaces() {
415        let content = r#"
416primary_workspaces = ["packages/core", "packages/api"]
417max_active_sessions = 5
418
419[[workspace]]
420path = "."
421language = "typescript"
422"#;
423        let config: ProjectConfig = toml::from_str(content).unwrap();
424        assert_eq!(
425            config.primary_workspaces,
426            vec!["packages/core", "packages/api"]
427        );
428        assert_eq!(config.max_active_sessions, Some(5));
429    }
430
431    #[test]
432    fn config_defaults_for_optional_fields() {
433        let content = r#"
434[[workspace]]
435path = "."
436language = "rust"
437"#;
438        let config: ProjectConfig = toml::from_str(content).unwrap();
439        assert!(config.primary_workspaces.is_empty());
440        assert!(config.max_active_sessions.is_none());
441    }
442
443    #[test]
444    fn generate_includes_priority_and_sessions_comments() {
445        let dir = tempfile::tempdir().unwrap();
446        let roots = vec![(Language::Rust, dir.path().to_path_buf())];
447        let content = generate(&roots, dir.path());
448        assert!(content.contains("primary_workspaces"));
449        assert!(content.contains("max_active_sessions"));
450    }
451
452    #[test]
453    fn config_with_server_overrides() {
454        let content = r#"
455[[workspace]]
456path = "."
457language = "typescript"
458
459[servers.typescript]
460binary = "vtsls"
461args = ["--stdio"]
462"#;
463        let config: ProjectConfig = toml::from_str(content).unwrap();
464        assert_eq!(config.workspace.len(), 1);
465        let ts_server = config.servers.get("typescript").unwrap();
466        assert_eq!(ts_server.binary.as_deref(), Some("vtsls"));
467        assert_eq!(
468            ts_server.args.as_deref(),
469            Some(&["--stdio".to_string()][..])
470        );
471    }
472}