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        // Self-heal loose permissions on configs predating chmod-on-save.
157        #[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        // Env vars override config file
171        if let Ok(key) = std::env::var("LINEAR_API_KEY") {
172            config.linear.api_key = Some(key.clone());
173            // Also apply to the active workspace if using multi-workspace config
174            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                // Don't override explicit local choice, but set key available
187            }
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        // The config file holds API keys; restrict to owner read/write.
198        #[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    /// Returns the workspace config by name. For "default", falls back to the
221    /// legacy `[linear]` section if no explicit workspace is defined.
222    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            // Fall back to legacy [linear] config only when api_key is present
228            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    /// Gets the API key for a workspace.
237    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    /// Gets the default team for a workspace.
246    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    /// Lists all configured workspace names. Falls back to vec!["default"]
252    /// if only legacy config is present.
253    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    /// Resolves the active workspace. Checks in order:
268    /// 1. `RECTILINEAR_WORKSPACE` env var
269    /// 2. Persisted state file at `data_dir/active_workspace`
270    /// 3. `default_workspace` from config
271    /// 4. Single workspace shortcut (if exactly one workspace is configured)
272    /// 5. Errors with guidance if multiple workspaces exist and none is selected
273    pub fn resolve_active_workspace(&self) -> Result<String> {
274        // 1. Environment variable
275        if let Ok(ws) = std::env::var("RECTILINEAR_WORKSPACE") {
276            if !ws.is_empty() {
277                return Ok(ws);
278            }
279        }
280
281        // 2. Persisted state file
282        if let Some(ws) = Self::get_persisted_workspace() {
283            return Ok(ws);
284        }
285
286        // 3. Config default_workspace
287        if let Some(ref ws) = self.default_workspace {
288            return Ok(ws.clone());
289        }
290
291        // 4. Single workspace shortcut
292        if self.workspaces.len() == 1 {
293            return Ok(self.workspaces.keys().next().unwrap().clone());
294        }
295
296        // 5. Error — multiple workspaces exist but none selected
297        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    /// Writes the active workspace name to `data_dir/active_workspace`.
305    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    /// Reads the persisted workspace from `data_dir/active_workspace`.
313    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        // Persisted state (step 2) may override, but both "acme" and a
513        // persisted workspace name are valid outcomes here.
514        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        // Persisted state (step 2) may override, but both "only" and a
531        // persisted workspace name are valid outcomes.
532        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        // With no workspaces and no legacy api_key, this should either error
545        // (no active workspace) or return a persisted workspace from disk.
546        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        // Explicit [workspaces.default] should win over [linear]
576        assert_eq!(ws.api_key, Some("explicit_default_key".to_string()));
577    }
578}