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