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
10const CONFIG_FILE: &str = ".krait/krait.toml";
12
13const LEGACY_CONFIG_FILE: &str = "krait.toml";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ProjectConfig {
19 pub root: Option<String>,
21
22 #[serde(default)]
24 pub workspace: Vec<WorkspaceEntry>,
25
26 #[serde(default)]
28 pub servers: HashMap<String, ServerOverride>,
29
30 #[serde(default)]
33 pub primary_workspaces: Vec<String>,
34
35 pub max_active_sessions: Option<usize>,
37
38 pub max_language_servers: Option<usize>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct WorkspaceEntry {
46 pub path: String,
48
49 pub language: String,
51
52 pub server: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ServerOverride {
59 pub binary: Option<String>,
61
62 pub args: Option<Vec<String>>,
64}
65
66#[derive(Debug, Clone)]
68pub enum ConfigSource {
69 KraitToml,
71 LegacyKraitToml,
73 AutoDetected,
75}
76
77impl ConfigSource {
78 #[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#[derive(Debug, Clone)]
91pub struct LoadedConfig {
92 pub config: Option<ProjectConfig>,
93 pub source: ConfigSource,
94}
95
96#[must_use]
100pub fn load(project_root: &Path) -> LoadedConfig {
101 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 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#[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#[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#[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 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 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 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
233pub 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#[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 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}