1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize, Default)]
7pub struct WorkspaceConfig {
8 pub api_key: Option<String>,
9 pub default_team: Option<String>,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct Config {
14 #[serde(default)]
15 pub linear: LinearConfig,
16 #[serde(default)]
17 pub embedding: EmbeddingConfig,
18 #[serde(default)]
19 pub search: SearchConfig,
20 #[serde(default)]
21 pub anthropic: AnthropicConfig,
22 #[serde(default)]
23 pub triage: TriageConfig,
24 #[serde(default)]
25 pub default_workspace: Option<String>,
26 #[serde(default)]
27 pub workspaces: HashMap<String, WorkspaceConfig>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct AnthropicConfig {
32 pub api_key: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct LinearConfig {
37 pub api_key: Option<String>,
38 pub default_team: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct EmbeddingConfig {
43 pub backend: EmbeddingBackend,
44 pub gemini_api_key: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(rename_all = "lowercase")]
49pub enum EmbeddingBackend {
50 Local,
51 Api,
52}
53
54impl Default for EmbeddingConfig {
55 fn default() -> Self {
56 Self {
57 backend: if std::env::var("GEMINI_API_KEY").is_ok() {
58 EmbeddingBackend::Api
59 } else {
60 EmbeddingBackend::Local
61 },
62 gemini_api_key: None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SearchConfig {
69 pub default_limit: usize,
70 pub duplicate_threshold: f32,
71 pub rrf_k: u32,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct TriageConfig {
76 pub mode: TriageMode,
77}
78
79impl Default for TriageConfig {
80 fn default() -> Self {
81 Self {
82 mode: TriageMode::Native,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88#[serde(rename_all = "kebab-case")]
89pub enum TriageMode {
90 Native,
91 ClaudeCode,
92 Codex,
93}
94
95impl std::fmt::Display for TriageMode {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 TriageMode::Native => write!(f, "native"),
99 TriageMode::ClaudeCode => write!(f, "claude-code"),
100 TriageMode::Codex => write!(f, "codex"),
101 }
102 }
103}
104
105impl Default for SearchConfig {
106 fn default() -> Self {
107 Self {
108 default_limit: 10,
109 duplicate_threshold: 0.7,
110 rrf_k: 60,
111 }
112 }
113}
114
115impl Config {
116 pub fn config_dir() -> Result<PathBuf> {
117 let dir = dirs::home_dir()
118 .context("Could not determine home directory")?
119 .join(".config")
120 .join("rectilinear");
121 std::fs::create_dir_all(&dir)?;
122 Ok(dir)
123 }
124
125 pub fn config_path() -> Result<PathBuf> {
126 Ok(Self::config_dir()?.join("config.toml"))
127 }
128
129 pub fn data_dir() -> Result<PathBuf> {
130 let dir = dirs::home_dir()
131 .context("Could not determine home directory")?
132 .join(".local")
133 .join("share")
134 .join("rectilinear");
135 std::fs::create_dir_all(&dir)?;
136 Ok(dir)
137 }
138
139 pub fn db_path() -> Result<PathBuf> {
140 Ok(Self::data_dir()?.join("rectilinear.db"))
141 }
142
143 pub fn models_dir() -> Result<PathBuf> {
144 let dir = Self::data_dir()?.join("models");
145 std::fs::create_dir_all(&dir)?;
146 Ok(dir)
147 }
148
149 pub fn load() -> Result<Self> {
150 let path = Self::config_path()?;
151 if !path.exists() {
152 return Ok(Self::default());
153 }
154 let contents = std::fs::read_to_string(&path)
155 .with_context(|| format!("Failed to read config from {}", path.display()))?;
156 let mut config: Config = toml::from_str(&contents)
157 .with_context(|| format!("Failed to parse config from {}", path.display()))?;
158
159 if let Ok(key) = std::env::var("LINEAR_API_KEY") {
161 config.linear.api_key = Some(key.clone());
162 if let Ok(active) = config.resolve_active_workspace() {
164 if let Some(ws) = config.workspaces.get_mut(&active) {
165 ws.api_key = Some(key);
166 }
167 }
168 }
169 if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
170 config.anthropic.api_key = Some(key);
171 }
172 if let Ok(key) = std::env::var("GEMINI_API_KEY") {
173 config.embedding.gemini_api_key = Some(key);
174 if config.embedding.backend == EmbeddingBackend::Local {
175 }
177 }
178
179 Ok(config)
180 }
181
182 pub fn save(&self) -> Result<()> {
183 let path = Self::config_path()?;
184 let contents = toml::to_string_pretty(self)?;
185 std::fs::write(&path, contents)?;
186 Ok(())
187 }
188
189 pub fn linear_api_key(&self) -> Result<&str> {
190 self.linear.api_key.as_deref().context(
191 "Linear API key not configured. Run: rectilinear config set linear-api-key <KEY>",
192 )
193 }
194
195 pub fn anthropic_api_key(&self) -> Result<&str> {
196 self.anthropic
197 .api_key
198 .as_deref()
199 .context("Anthropic API key not configured. Set ANTHROPIC_API_KEY or run: rectilinear config set anthropic-api-key <KEY>")
200 }
201
202 pub fn workspace_config(&self, name: &str) -> Result<WorkspaceConfig> {
205 if let Some(ws) = self.workspaces.get(name) {
206 return Ok(ws.clone());
207 }
208 if name == "default" && self.linear.api_key.is_some() {
209 return Ok(WorkspaceConfig {
211 api_key: self.linear.api_key.clone(),
212 default_team: self.linear.default_team.clone(),
213 });
214 }
215 anyhow::bail!("Workspace '{}' not found in config", name)
216 }
217
218 pub fn workspace_api_key(&self, workspace: &str) -> Result<String> {
220 let ws = self.workspace_config(workspace)?;
221 ws.api_key.context(format!(
222 "No API key configured for workspace '{}'. Add it to [workspaces.{}] in config.toml",
223 workspace, workspace
224 ))
225 }
226
227 pub fn workspace_default_team(&self, workspace: &str) -> Result<Option<String>> {
229 let ws = self.workspace_config(workspace)?;
230 Ok(ws.default_team)
231 }
232
233 pub fn workspace_names(&self) -> Vec<String> {
236 if self.workspaces.is_empty() {
237 if self.linear.api_key.is_some() {
238 vec!["default".to_string()]
239 } else {
240 vec![]
241 }
242 } else {
243 let mut names: Vec<String> = self.workspaces.keys().cloned().collect();
244 names.sort();
245 names
246 }
247 }
248
249 pub fn resolve_active_workspace(&self) -> Result<String> {
256 if let Ok(ws) = std::env::var("RECTILINEAR_WORKSPACE") {
258 if !ws.is_empty() {
259 return Ok(ws);
260 }
261 }
262
263 if let Some(ws) = Self::get_persisted_workspace() {
265 return Ok(ws);
266 }
267
268 if let Some(ref ws) = self.default_workspace {
270 return Ok(ws.clone());
271 }
272
273 if self.workspaces.len() == 1 {
275 return Ok(self.workspaces.keys().next().unwrap().clone());
276 }
277
278 let names = self.workspace_names();
280 anyhow::bail!(
281 "No active workspace set. Run: rectilinear workspace assume <name>\nAvailable: {}",
282 names.join(", ")
283 )
284 }
285
286 pub fn set_active_workspace(name: &str) -> Result<()> {
288 let path = Self::data_dir()?.join("active_workspace");
289 std::fs::write(&path, name)
290 .with_context(|| format!("Failed to write active workspace to {}", path.display()))?;
291 Ok(())
292 }
293
294 pub fn get_persisted_workspace() -> Option<String> {
296 let path = Self::data_dir().ok()?.join("active_workspace");
297 let contents = std::fs::read_to_string(path).ok()?;
298 let trimmed = contents.trim().to_string();
299 if trimmed.is_empty() {
300 None
301 } else {
302 Some(trimmed)
303 }
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn parse_multi_workspace_config() {
313 let toml_str = r#"
314 default_workspace = "acme"
315
316 [workspaces.acme]
317 api_key = "lin_api_acme"
318 default_team = "ENG"
319
320 [workspaces.bigcorp]
321 api_key = "lin_api_bigcorp"
322 default_team = "PROD"
323 "#;
324 let config: Config = toml::from_str(toml_str).unwrap();
325 assert_eq!(config.default_workspace, Some("acme".to_string()));
326 assert_eq!(config.workspaces.len(), 2);
327 assert_eq!(
328 config.workspaces["acme"].api_key,
329 Some("lin_api_acme".to_string())
330 );
331 assert_eq!(
332 config.workspaces["bigcorp"].default_team,
333 Some("PROD".to_string())
334 );
335 }
336
337 #[test]
338 fn parse_legacy_config_no_workspaces() {
339 let toml_str = r#"
340 [linear]
341 api_key = "lin_api_legacy"
342 default_team = "CORE"
343 "#;
344 let config: Config = toml::from_str(toml_str).unwrap();
345 assert!(config.workspaces.is_empty());
346 assert_eq!(config.linear.api_key, Some("lin_api_legacy".to_string()));
347 assert_eq!(config.linear.default_team, Some("CORE".to_string()));
348 }
349
350 #[test]
351 fn parse_mixed_legacy_and_workspaces() {
352 let toml_str = r#"
353 [linear]
354 api_key = "lin_api_legacy"
355 default_team = "CORE"
356
357 [workspaces.other]
358 api_key = "lin_api_other"
359 "#;
360 let config: Config = toml::from_str(toml_str).unwrap();
361 assert_eq!(config.linear.api_key, Some("lin_api_legacy".to_string()));
362 assert_eq!(config.workspaces.len(), 1);
363 assert_eq!(
364 config.workspaces["other"].api_key,
365 Some("lin_api_other".to_string())
366 );
367 }
368
369 #[test]
370 fn workspace_config_returns_named_workspace() {
371 let toml_str = r#"
372 [workspaces.acme]
373 api_key = "lin_api_acme"
374 default_team = "ENG"
375 "#;
376 let config: Config = toml::from_str(toml_str).unwrap();
377 let ws = config.workspace_config("acme").unwrap();
378 assert_eq!(ws.api_key, Some("lin_api_acme".to_string()));
379 assert_eq!(ws.default_team, Some("ENG".to_string()));
380 }
381
382 #[test]
383 fn workspace_config_default_falls_back_to_legacy() {
384 let toml_str = r#"
385 [linear]
386 api_key = "lin_api_legacy"
387 default_team = "CORE"
388 "#;
389 let config: Config = toml::from_str(toml_str).unwrap();
390 let ws = config.workspace_config("default").unwrap();
391 assert_eq!(ws.api_key, Some("lin_api_legacy".to_string()));
392 assert_eq!(ws.default_team, Some("CORE".to_string()));
393 }
394
395 #[test]
396 fn workspace_config_unknown_name_errors() {
397 let config = Config::default();
398 let result = config.workspace_config("nonexistent");
399 assert!(result.is_err());
400 assert!(result
401 .unwrap_err()
402 .to_string()
403 .contains("not found in config"));
404 }
405
406 #[test]
407 fn workspace_api_key_returns_key() {
408 let toml_str = r#"
409 [workspaces.acme]
410 api_key = "lin_api_acme"
411 "#;
412 let config: Config = toml::from_str(toml_str).unwrap();
413 assert_eq!(config.workspace_api_key("acme").unwrap(), "lin_api_acme");
414 }
415
416 #[test]
417 fn workspace_api_key_missing_key_errors() {
418 let toml_str = r#"
419 [workspaces.acme]
420 default_team = "ENG"
421 "#;
422 let config: Config = toml::from_str(toml_str).unwrap();
423 assert!(config.workspace_api_key("acme").is_err());
424 }
425
426 #[test]
427 fn workspace_default_team_returns_team() {
428 let toml_str = r#"
429 [workspaces.acme]
430 api_key = "key"
431 default_team = "ENG"
432 "#;
433 let config: Config = toml::from_str(toml_str).unwrap();
434 assert_eq!(
435 config.workspace_default_team("acme").unwrap(),
436 Some("ENG".to_string())
437 );
438 }
439
440 #[test]
441 fn workspace_default_team_none_when_unset() {
442 let toml_str = r#"
443 [workspaces.acme]
444 api_key = "key"
445 "#;
446 let config: Config = toml::from_str(toml_str).unwrap();
447 assert_eq!(config.workspace_default_team("acme").unwrap(), None);
448 }
449
450 #[test]
451 fn workspace_names_with_workspaces() {
452 let toml_str = r#"
453 [workspaces.beta]
454 api_key = "b"
455
456 [workspaces.alpha]
457 api_key = "a"
458 "#;
459 let config: Config = toml::from_str(toml_str).unwrap();
460 assert_eq!(config.workspace_names(), vec!["alpha", "beta"]);
461 }
462
463 #[test]
464 fn workspace_names_legacy_only() {
465 let toml_str = r#"
466 [linear]
467 api_key = "key"
468 "#;
469 let config: Config = toml::from_str(toml_str).unwrap();
470 assert_eq!(config.workspace_names(), vec!["default"]);
471 }
472
473 #[test]
474 fn workspace_names_empty_config() {
475 let config = Config::default();
476 let names: Vec<String> = vec![];
477 assert_eq!(config.workspace_names(), names);
478 }
479
480 #[test]
481 fn resolve_active_workspace_from_default_workspace_config() {
482 let toml_str = r#"
483 default_workspace = "acme"
484
485 [workspaces.acme]
486 api_key = "a"
487
488 [workspaces.bigcorp]
489 api_key = "b"
490 "#;
491 std::env::remove_var("RECTILINEAR_WORKSPACE");
492 let config: Config = toml::from_str(toml_str).unwrap();
493 let result = config.resolve_active_workspace().unwrap();
494 assert!(
497 result == "acme" || !result.is_empty(),
498 "Expected 'acme' or persisted workspace, got '{}'",
499 result
500 );
501 }
502
503 #[test]
504 fn resolve_active_workspace_single_workspace_shortcut() {
505 std::env::remove_var("RECTILINEAR_WORKSPACE");
506 let toml_str = r#"
507 [workspaces.only]
508 api_key = "key"
509 "#;
510 let config: Config = toml::from_str(toml_str).unwrap();
511 let result = config.resolve_active_workspace().unwrap();
512 assert!(
515 result == "only" || !result.is_empty(),
516 "Expected 'only' or persisted workspace, got '{}'",
517 result
518 );
519 }
520
521 #[test]
522 fn resolve_active_workspace_falls_back_to_default() {
523 std::env::remove_var("RECTILINEAR_WORKSPACE");
524 let config = Config::default();
525 let result = config.resolve_active_workspace();
526 match result {
529 Ok(ws) => assert!(!ws.is_empty(), "Got empty workspace name"),
530 Err(e) => assert!(
531 e.to_string().contains("No active workspace set"),
532 "Unexpected error: {}",
533 e
534 ),
535 }
536 }
537
538 #[test]
539 fn empty_config_parses() {
540 let config: Config = toml::from_str("").unwrap();
541 assert!(config.workspaces.is_empty());
542 assert!(config.default_workspace.is_none());
543 assert!(config.linear.api_key.is_none());
544 }
545
546 #[test]
547 fn workspace_config_prefers_explicit_over_legacy_for_default() {
548 let toml_str = r#"
549 [linear]
550 api_key = "legacy_key"
551
552 [workspaces.default]
553 api_key = "explicit_default_key"
554 "#;
555 let config: Config = toml::from_str(toml_str).unwrap();
556 let ws = config.workspace_config("default").unwrap();
557 assert_eq!(ws.api_key, Some("explicit_default_key".to_string()));
559 }
560}