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 #[cfg(unix)]
158 {
159 use std::os::unix::fs::PermissionsExt;
160 if let Ok(meta) = std::fs::metadata(&path) {
161 let mode = meta.permissions().mode() & 0o777;
162 if mode & 0o077 != 0 {
163 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
164 }
165 }
166 }
167 let mut config: Config = toml::from_str(&contents)
168 .with_context(|| format!("Failed to parse config from {}", path.display()))?;
169
170 if let Ok(key) = std::env::var("LINEAR_API_KEY") {
172 config.linear.api_key = Some(key.clone());
173 if let Ok(active) = config.resolve_active_workspace() {
175 if let Some(ws) = config.workspaces.get_mut(&active) {
176 ws.api_key = Some(key);
177 }
178 }
179 }
180 if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
181 config.anthropic.api_key = Some(key);
182 }
183 if let Ok(key) = std::env::var("GEMINI_API_KEY") {
184 config.embedding.gemini_api_key = Some(key);
185 if config.embedding.backend == EmbeddingBackend::Local {
186 }
188 }
189
190 Ok(config)
191 }
192
193 pub fn save(&self) -> Result<()> {
194 let path = Self::config_path()?;
195 let contents = toml::to_string_pretty(self)?;
196 std::fs::write(&path, contents)?;
197 #[cfg(unix)]
199 {
200 use std::os::unix::fs::PermissionsExt;
201 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
202 .with_context(|| format!("Failed to set permissions on {}", path.display()))?;
203 }
204 Ok(())
205 }
206
207 pub fn linear_api_key(&self) -> Result<&str> {
208 self.linear.api_key.as_deref().context(
209 "Linear API key not configured. Run: rectilinear config set linear-api-key <KEY>",
210 )
211 }
212
213 pub fn anthropic_api_key(&self) -> Result<&str> {
214 self.anthropic
215 .api_key
216 .as_deref()
217 .context("Anthropic API key not configured. Set ANTHROPIC_API_KEY or run: rectilinear config set anthropic-api-key <KEY>")
218 }
219
220 pub fn workspace_config(&self, name: &str) -> Result<WorkspaceConfig> {
223 if let Some(ws) = self.workspaces.get(name) {
224 return Ok(ws.clone());
225 }
226 if name == "default" && self.linear.api_key.is_some() {
227 return Ok(WorkspaceConfig {
229 api_key: self.linear.api_key.clone(),
230 default_team: self.linear.default_team.clone(),
231 });
232 }
233 anyhow::bail!("Workspace '{}' not found in config", name)
234 }
235
236 pub fn workspace_api_key(&self, workspace: &str) -> Result<String> {
238 let ws = self.workspace_config(workspace)?;
239 ws.api_key.context(format!(
240 "No API key configured for workspace '{}'. Add it to [workspaces.{}] in config.toml",
241 workspace, workspace
242 ))
243 }
244
245 pub fn workspace_default_team(&self, workspace: &str) -> Result<Option<String>> {
247 let ws = self.workspace_config(workspace)?;
248 Ok(ws.default_team)
249 }
250
251 pub fn workspace_names(&self) -> Vec<String> {
254 if self.workspaces.is_empty() {
255 if self.linear.api_key.is_some() {
256 vec!["default".to_string()]
257 } else {
258 vec![]
259 }
260 } else {
261 let mut names: Vec<String> = self.workspaces.keys().cloned().collect();
262 names.sort();
263 names
264 }
265 }
266
267 pub fn resolve_active_workspace(&self) -> Result<String> {
274 if let Ok(ws) = std::env::var("RECTILINEAR_WORKSPACE") {
276 if !ws.is_empty() {
277 return Ok(ws);
278 }
279 }
280
281 if let Some(ws) = Self::get_persisted_workspace() {
283 return Ok(ws);
284 }
285
286 if let Some(ref ws) = self.default_workspace {
288 return Ok(ws.clone());
289 }
290
291 if self.workspaces.len() == 1 {
293 return Ok(self.workspaces.keys().next().unwrap().clone());
294 }
295
296 let names = self.workspace_names();
298 anyhow::bail!(
299 "No active workspace set. Run: rectilinear workspace assume <name>\nAvailable: {}",
300 names.join(", ")
301 )
302 }
303
304 pub fn set_active_workspace(name: &str) -> Result<()> {
306 let path = Self::data_dir()?.join("active_workspace");
307 std::fs::write(&path, name)
308 .with_context(|| format!("Failed to write active workspace to {}", path.display()))?;
309 Ok(())
310 }
311
312 pub fn get_persisted_workspace() -> Option<String> {
314 let path = Self::data_dir().ok()?.join("active_workspace");
315 let contents = std::fs::read_to_string(path).ok()?;
316 let trimmed = contents.trim().to_string();
317 if trimmed.is_empty() {
318 None
319 } else {
320 Some(trimmed)
321 }
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn parse_multi_workspace_config() {
331 let toml_str = r#"
332 default_workspace = "acme"
333
334 [workspaces.acme]
335 api_key = "lin_api_acme"
336 default_team = "ENG"
337
338 [workspaces.bigcorp]
339 api_key = "lin_api_bigcorp"
340 default_team = "PROD"
341 "#;
342 let config: Config = toml::from_str(toml_str).unwrap();
343 assert_eq!(config.default_workspace, Some("acme".to_string()));
344 assert_eq!(config.workspaces.len(), 2);
345 assert_eq!(
346 config.workspaces["acme"].api_key,
347 Some("lin_api_acme".to_string())
348 );
349 assert_eq!(
350 config.workspaces["bigcorp"].default_team,
351 Some("PROD".to_string())
352 );
353 }
354
355 #[test]
356 fn parse_legacy_config_no_workspaces() {
357 let toml_str = r#"
358 [linear]
359 api_key = "lin_api_legacy"
360 default_team = "CORE"
361 "#;
362 let config: Config = toml::from_str(toml_str).unwrap();
363 assert!(config.workspaces.is_empty());
364 assert_eq!(config.linear.api_key, Some("lin_api_legacy".to_string()));
365 assert_eq!(config.linear.default_team, Some("CORE".to_string()));
366 }
367
368 #[test]
369 fn parse_mixed_legacy_and_workspaces() {
370 let toml_str = r#"
371 [linear]
372 api_key = "lin_api_legacy"
373 default_team = "CORE"
374
375 [workspaces.other]
376 api_key = "lin_api_other"
377 "#;
378 let config: Config = toml::from_str(toml_str).unwrap();
379 assert_eq!(config.linear.api_key, Some("lin_api_legacy".to_string()));
380 assert_eq!(config.workspaces.len(), 1);
381 assert_eq!(
382 config.workspaces["other"].api_key,
383 Some("lin_api_other".to_string())
384 );
385 }
386
387 #[test]
388 fn workspace_config_returns_named_workspace() {
389 let toml_str = r#"
390 [workspaces.acme]
391 api_key = "lin_api_acme"
392 default_team = "ENG"
393 "#;
394 let config: Config = toml::from_str(toml_str).unwrap();
395 let ws = config.workspace_config("acme").unwrap();
396 assert_eq!(ws.api_key, Some("lin_api_acme".to_string()));
397 assert_eq!(ws.default_team, Some("ENG".to_string()));
398 }
399
400 #[test]
401 fn workspace_config_default_falls_back_to_legacy() {
402 let toml_str = r#"
403 [linear]
404 api_key = "lin_api_legacy"
405 default_team = "CORE"
406 "#;
407 let config: Config = toml::from_str(toml_str).unwrap();
408 let ws = config.workspace_config("default").unwrap();
409 assert_eq!(ws.api_key, Some("lin_api_legacy".to_string()));
410 assert_eq!(ws.default_team, Some("CORE".to_string()));
411 }
412
413 #[test]
414 fn workspace_config_unknown_name_errors() {
415 let config = Config::default();
416 let result = config.workspace_config("nonexistent");
417 assert!(result.is_err());
418 assert!(result
419 .unwrap_err()
420 .to_string()
421 .contains("not found in config"));
422 }
423
424 #[test]
425 fn workspace_api_key_returns_key() {
426 let toml_str = r#"
427 [workspaces.acme]
428 api_key = "lin_api_acme"
429 "#;
430 let config: Config = toml::from_str(toml_str).unwrap();
431 assert_eq!(config.workspace_api_key("acme").unwrap(), "lin_api_acme");
432 }
433
434 #[test]
435 fn workspace_api_key_missing_key_errors() {
436 let toml_str = r#"
437 [workspaces.acme]
438 default_team = "ENG"
439 "#;
440 let config: Config = toml::from_str(toml_str).unwrap();
441 assert!(config.workspace_api_key("acme").is_err());
442 }
443
444 #[test]
445 fn workspace_default_team_returns_team() {
446 let toml_str = r#"
447 [workspaces.acme]
448 api_key = "key"
449 default_team = "ENG"
450 "#;
451 let config: Config = toml::from_str(toml_str).unwrap();
452 assert_eq!(
453 config.workspace_default_team("acme").unwrap(),
454 Some("ENG".to_string())
455 );
456 }
457
458 #[test]
459 fn workspace_default_team_none_when_unset() {
460 let toml_str = r#"
461 [workspaces.acme]
462 api_key = "key"
463 "#;
464 let config: Config = toml::from_str(toml_str).unwrap();
465 assert_eq!(config.workspace_default_team("acme").unwrap(), None);
466 }
467
468 #[test]
469 fn workspace_names_with_workspaces() {
470 let toml_str = r#"
471 [workspaces.beta]
472 api_key = "b"
473
474 [workspaces.alpha]
475 api_key = "a"
476 "#;
477 let config: Config = toml::from_str(toml_str).unwrap();
478 assert_eq!(config.workspace_names(), vec!["alpha", "beta"]);
479 }
480
481 #[test]
482 fn workspace_names_legacy_only() {
483 let toml_str = r#"
484 [linear]
485 api_key = "key"
486 "#;
487 let config: Config = toml::from_str(toml_str).unwrap();
488 assert_eq!(config.workspace_names(), vec!["default"]);
489 }
490
491 #[test]
492 fn workspace_names_empty_config() {
493 let config = Config::default();
494 let names: Vec<String> = vec![];
495 assert_eq!(config.workspace_names(), names);
496 }
497
498 #[test]
499 fn resolve_active_workspace_from_default_workspace_config() {
500 let toml_str = r#"
501 default_workspace = "acme"
502
503 [workspaces.acme]
504 api_key = "a"
505
506 [workspaces.bigcorp]
507 api_key = "b"
508 "#;
509 std::env::remove_var("RECTILINEAR_WORKSPACE");
510 let config: Config = toml::from_str(toml_str).unwrap();
511 let result = config.resolve_active_workspace().unwrap();
512 assert!(
515 result == "acme" || !result.is_empty(),
516 "Expected 'acme' or persisted workspace, got '{}'",
517 result
518 );
519 }
520
521 #[test]
522 fn resolve_active_workspace_single_workspace_shortcut() {
523 std::env::remove_var("RECTILINEAR_WORKSPACE");
524 let toml_str = r#"
525 [workspaces.only]
526 api_key = "key"
527 "#;
528 let config: Config = toml::from_str(toml_str).unwrap();
529 let result = config.resolve_active_workspace().unwrap();
530 assert!(
533 result == "only" || !result.is_empty(),
534 "Expected 'only' or persisted workspace, got '{}'",
535 result
536 );
537 }
538
539 #[test]
540 fn resolve_active_workspace_falls_back_to_default() {
541 std::env::remove_var("RECTILINEAR_WORKSPACE");
542 let config = Config::default();
543 let result = config.resolve_active_workspace();
544 match result {
547 Ok(ws) => assert!(!ws.is_empty(), "Got empty workspace name"),
548 Err(e) => assert!(
549 e.to_string().contains("No active workspace set"),
550 "Unexpected error: {}",
551 e
552 ),
553 }
554 }
555
556 #[test]
557 fn empty_config_parses() {
558 let config: Config = toml::from_str("").unwrap();
559 assert!(config.workspaces.is_empty());
560 assert!(config.default_workspace.is_none());
561 assert!(config.linear.api_key.is_none());
562 }
563
564 #[test]
565 fn workspace_config_prefers_explicit_over_legacy_for_default() {
566 let toml_str = r#"
567 [linear]
568 api_key = "legacy_key"
569
570 [workspaces.default]
571 api_key = "explicit_default_key"
572 "#;
573 let config: Config = toml::from_str(toml_str).unwrap();
574 let ws = config.workspace_config("default").unwrap();
575 assert_eq!(ws.api_key, Some("explicit_default_key".to_string()));
577 }
578}