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(
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        // Env vars override config file
174        if let Ok(key) = std::env::var("LINEAR_API_KEY") {
175            config.linear.api_key = Some(key.clone());
176            // Also apply to the active workspace if using multi-workspace config
177            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                // Don't override explicit local choice, but set key available
190            }
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        // The config file holds API keys; restrict to owner read/write.
201        #[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    /// Returns the workspace config by name. For "default", falls back to the
224    /// legacy `[linear]` section if no explicit workspace is defined.
225    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            // Fall back to legacy [linear] config only when api_key is present
231            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    /// Gets the API key for a workspace.
240    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    /// Gets the default team for a workspace.
249    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    /// Lists all configured workspace names. Falls back to vec!["default"]
255    /// if only legacy config is present.
256    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    /// Resolves the active workspace. Checks in order:
271    /// 1. `RECTILINEAR_WORKSPACE` env var
272    /// 2. Persisted state file at `data_dir/active_workspace`
273    /// 3. `default_workspace` from config
274    /// 4. Single workspace shortcut (if exactly one workspace is configured)
275    /// 5. Errors with guidance if multiple workspaces exist and none is selected
276    pub fn resolve_active_workspace(&self) -> Result<String> {
277        // 1. Environment variable
278        if let Ok(ws) = std::env::var("RECTILINEAR_WORKSPACE") {
279            if !ws.is_empty() {
280                return Ok(ws);
281            }
282        }
283
284        // 2. Persisted state file
285        if let Some(ws) = Self::get_persisted_workspace() {
286            return Ok(ws);
287        }
288
289        // 3. Config default_workspace
290        if let Some(ref ws) = self.default_workspace {
291            return Ok(ws.clone());
292        }
293
294        // 4. Single workspace shortcut
295        if self.workspaces.len() == 1 {
296            return Ok(self.workspaces.keys().next().unwrap().clone());
297        }
298
299        // 5. Error — multiple workspaces exist but none selected
300        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    /// Writes the active workspace name to `data_dir/active_workspace`.
308    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    /// Reads the persisted workspace from `data_dir/active_workspace`.
316    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        // Persisted state (step 2) may override, but both "acme" and a
516        // persisted workspace name are valid outcomes here.
517        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        // Persisted state (step 2) may override, but both "only" and a
534        // persisted workspace name are valid outcomes.
535        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        // With no workspaces and no legacy api_key, this should either error
548        // (no active workspace) or return a persisted workspace from disk.
549        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        // Explicit [workspaces.default] should win over [linear]
579        assert_eq!(ws.api_key, Some("explicit_default_key".to_string()));
580    }
581}