Skip to main content

rectilinear_core/
config.rs

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        // Env vars override config file
160        if let Ok(key) = std::env::var("LINEAR_API_KEY") {
161            config.linear.api_key = Some(key.clone());
162            // Also apply to the active workspace if using multi-workspace config
163            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                // Don't override explicit local choice, but set key available
176            }
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    /// Returns the workspace config by name. For "default", falls back to the
203    /// legacy `[linear]` section if no explicit workspace is defined.
204    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            // Fall back to legacy [linear] config only when api_key is present
210            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    /// Gets the API key for a workspace.
219    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    /// Gets the default team for a workspace.
228    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    /// Lists all configured workspace names. Falls back to vec!["default"]
234    /// if only legacy config is present.
235    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    /// Resolves the active workspace. Checks in order:
250    /// 1. `RECTILINEAR_WORKSPACE` env var
251    /// 2. Persisted state file at `data_dir/active_workspace`
252    /// 3. `default_workspace` from config
253    /// 4. Single workspace shortcut (if exactly one workspace is configured)
254    /// 5. Errors with guidance if multiple workspaces exist and none is selected
255    pub fn resolve_active_workspace(&self) -> Result<String> {
256        // 1. Environment variable
257        if let Ok(ws) = std::env::var("RECTILINEAR_WORKSPACE") {
258            if !ws.is_empty() {
259                return Ok(ws);
260            }
261        }
262
263        // 2. Persisted state file
264        if let Some(ws) = Self::get_persisted_workspace() {
265            return Ok(ws);
266        }
267
268        // 3. Config default_workspace
269        if let Some(ref ws) = self.default_workspace {
270            return Ok(ws.clone());
271        }
272
273        // 4. Single workspace shortcut
274        if self.workspaces.len() == 1 {
275            return Ok(self.workspaces.keys().next().unwrap().clone());
276        }
277
278        // 5. Error — multiple workspaces exist but none selected
279        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    /// Writes the active workspace name to `data_dir/active_workspace`.
287    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    /// Reads the persisted workspace from `data_dir/active_workspace`.
295    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        // Persisted state (step 2) may override, but both "acme" and a
495        // persisted workspace name are valid outcomes here.
496        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        // Persisted state (step 2) may override, but both "only" and a
513        // persisted workspace name are valid outcomes.
514        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        // With no workspaces and no legacy api_key, this should either error
527        // (no active workspace) or return a persisted workspace from disk.
528        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        // Explicit [workspaces.default] should win over [linear]
558        assert_eq!(ws.api_key, Some("explicit_default_key".to_string()));
559    }
560}