Skip to main content

git_iris/
config.rs

1//! Configuration management for Git-Iris.
2//!
3//! Handles personal config (~/.config/git-iris/config.toml) and
4//! per-project config (.irisconfig) with proper layering.
5
6use crate::git::GitRepo;
7use crate::instruction_presets::get_instruction_preset_library;
8use crate::log_debug;
9use crate::providers::{Provider, ProviderConfig};
10
11use anyhow::{Context, Result, anyhow};
12use dirs::{config_dir, home_dir};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18/// Project configuration filename
19pub const PROJECT_CONFIG_FILENAME: &str = ".irisconfig";
20
21/// Main configuration structure
22#[derive(Deserialize, Serialize, Clone, Debug)]
23pub struct Config {
24    /// Default LLM provider
25    #[serde(default, skip_serializing_if = "String::is_empty")]
26    pub default_provider: String,
27    /// Provider-specific configurations (keyed by provider name)
28    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
29    pub providers: HashMap<String, ProviderConfig>,
30    /// Use gitmoji in commit messages
31    #[serde(default = "default_true", skip_serializing_if = "is_true")]
32    pub use_gitmoji: bool,
33    /// Custom instructions for all operations
34    #[serde(default, skip_serializing_if = "String::is_empty")]
35    pub instructions: String,
36    /// Instruction preset name
37    #[serde(default = "default_preset", skip_serializing_if = "is_default_preset")]
38    pub instruction_preset: String,
39    /// Theme name (empty = default `SilkCircuit` Neon)
40    #[serde(default, skip_serializing_if = "String::is_empty")]
41    pub theme: String,
42    /// Timeout in seconds for parallel subagent tasks (default: 120)
43    #[serde(
44        default = "default_subagent_timeout",
45        skip_serializing_if = "is_default_subagent_timeout"
46    )]
47    pub subagent_timeout_secs: u64,
48    /// Runtime-only: temporary instructions override
49    #[serde(skip)]
50    pub temp_instructions: Option<String>,
51    /// Runtime-only: temporary preset override
52    #[serde(skip)]
53    pub temp_preset: Option<String>,
54    /// Runtime-only: flag if loaded from project config
55    #[serde(skip)]
56    pub is_project_config: bool,
57    /// Runtime-only: whether gitmoji was explicitly set via CLI (None = use style detection)
58    #[serde(skip)]
59    pub gitmoji_override: Option<bool>,
60}
61
62fn default_true() -> bool {
63    true
64}
65
66#[allow(clippy::trivially_copy_pass_by_ref)]
67fn is_true(val: &bool) -> bool {
68    *val
69}
70
71fn default_preset() -> String {
72    "default".to_string()
73}
74
75fn is_default_preset(val: &str) -> bool {
76    val.is_empty() || val == "default"
77}
78
79fn default_subagent_timeout() -> u64 {
80    120 // 2 minutes
81}
82
83#[allow(clippy::trivially_copy_pass_by_ref)]
84fn is_default_subagent_timeout(val: &u64) -> bool {
85    *val == 120
86}
87
88impl Default for Config {
89    fn default() -> Self {
90        let mut providers = HashMap::new();
91        for provider in Provider::ALL {
92            providers.insert(
93                provider.name().to_string(),
94                ProviderConfig::with_defaults(*provider),
95            );
96        }
97
98        Self {
99            default_provider: Provider::default().name().to_string(),
100            providers,
101            use_gitmoji: true,
102            instructions: String::new(),
103            instruction_preset: default_preset(),
104            theme: String::new(),
105            subagent_timeout_secs: default_subagent_timeout(),
106            temp_instructions: None,
107            temp_preset: None,
108            is_project_config: false,
109            gitmoji_override: None,
110        }
111    }
112}
113
114impl Config {
115    /// Load configuration (personal + project overlay)
116    ///
117    /// # Errors
118    ///
119    /// Returns an error when personal or project configuration cannot be read or parsed.
120    pub fn load() -> Result<Self> {
121        let config_path = Self::get_personal_config_path()?;
122        let mut config = if config_path.exists() {
123            let content = fs::read_to_string(&config_path)?;
124            let parsed: Self = toml::from_str(&content)?;
125            let (migrated, needs_save) = Self::migrate_if_needed(parsed);
126            if needs_save && let Err(e) = migrated.save() {
127                log_debug!("Failed to save migrated config: {}", e);
128            }
129            migrated
130        } else {
131            Self::default()
132        };
133
134        // Overlay project config if available
135        if let Ok((project_config, project_source)) = Self::load_project_config_with_source() {
136            config.merge_loaded_project_config(project_config, &project_source);
137        }
138
139        log_debug!(
140            "Configuration loaded (provider: {}, gitmoji: {})",
141            config.default_provider,
142            config.use_gitmoji
143        );
144        Ok(config)
145    }
146
147    /// Load project-specific configuration
148    ///
149    /// # Errors
150    ///
151    /// Returns an error when the project configuration file is missing or invalid.
152    pub fn load_project_config() -> Result<Self> {
153        let (config, _) = Self::load_project_config_with_source()?;
154        Ok(config)
155    }
156
157    fn load_project_config_with_source() -> Result<(Self, toml::Value)> {
158        let config_path = Self::get_project_config_path()?;
159        if !config_path.exists() {
160            return Err(anyhow!("Project configuration file not found"));
161        }
162
163        let content = fs::read_to_string(&config_path)
164            .with_context(|| format!("Failed to read {}", config_path.display()))?;
165        let project_source = toml::from_str(&content).with_context(|| {
166            format!(
167                "Invalid {} format. Check for syntax errors.",
168                PROJECT_CONFIG_FILENAME
169            )
170        })?;
171
172        let mut config: Self = toml::from_str(&content).with_context(|| {
173            format!(
174                "Invalid {} format. Check for syntax errors.",
175                PROJECT_CONFIG_FILENAME
176            )
177        })?;
178
179        config.is_project_config = true;
180        Ok((config, project_source))
181    }
182
183    /// Get path to project config file
184    ///
185    /// # Errors
186    ///
187    /// Returns an error when the current repository root cannot be resolved.
188    pub fn get_project_config_path() -> Result<PathBuf> {
189        let repo_root = GitRepo::get_repo_root()?;
190        Ok(repo_root.join(PROJECT_CONFIG_FILENAME))
191    }
192
193    /// Merge project config into this config (project takes precedence, but never API keys)
194    pub fn merge_with_project_config(&mut self, project_config: Self) {
195        log_debug!("Merging with project configuration");
196
197        // Override default provider if set
198        if !project_config.default_provider.is_empty()
199            && project_config.default_provider != Provider::default().name()
200        {
201            self.default_provider = project_config.default_provider;
202        }
203
204        // Merge provider configs (never override API keys from project config)
205        for (provider_name, proj_config) in project_config.providers {
206            let entry = self.providers.entry(provider_name).or_default();
207
208            if !proj_config.model.is_empty() {
209                entry.model = proj_config.model;
210            }
211            if proj_config.fast_model.is_some() {
212                entry.fast_model = proj_config.fast_model;
213            }
214            if proj_config.token_limit.is_some() {
215                entry.token_limit = proj_config.token_limit;
216            }
217            entry
218                .additional_params
219                .extend(proj_config.additional_params);
220        }
221
222        // Override other settings
223        self.use_gitmoji = project_config.use_gitmoji;
224        self.instructions = project_config.instructions;
225
226        if project_config.instruction_preset != default_preset() {
227            self.instruction_preset = project_config.instruction_preset;
228        }
229
230        // Theme override
231        if !project_config.theme.is_empty() {
232            self.theme = project_config.theme;
233        }
234
235        // Subagent timeout override
236        if project_config.subagent_timeout_secs != default_subagent_timeout() {
237            self.subagent_timeout_secs = project_config.subagent_timeout_secs;
238        }
239    }
240
241    fn merge_loaded_project_config(&mut self, project_config: Self, project_source: &toml::Value) {
242        log_debug!("Merging loaded project configuration with explicit field tracking");
243
244        self.merge_project_provider_config(&project_config);
245
246        if Self::project_config_has_key(project_source, "default_provider") {
247            self.default_provider = project_config.default_provider;
248        }
249        if Self::project_config_has_key(project_source, "use_gitmoji") {
250            self.use_gitmoji = project_config.use_gitmoji;
251        }
252        if Self::project_config_has_key(project_source, "instructions") {
253            self.instructions = project_config.instructions;
254        }
255        if Self::project_config_has_key(project_source, "instruction_preset") {
256            self.instruction_preset = project_config.instruction_preset;
257        }
258        if Self::project_config_has_key(project_source, "theme") {
259            self.theme = project_config.theme;
260        }
261        if Self::project_config_has_key(project_source, "subagent_timeout_secs") {
262            self.subagent_timeout_secs = project_config.subagent_timeout_secs;
263        }
264    }
265
266    fn merge_project_provider_config(&mut self, project_config: &Self) {
267        for (provider_name, proj_config) in &project_config.providers {
268            let entry = self.providers.entry(provider_name.clone()).or_default();
269
270            if !proj_config.model.is_empty() {
271                proj_config.model.clone_into(&mut entry.model);
272            }
273            if proj_config.fast_model.is_some() {
274                entry.fast_model.clone_from(&proj_config.fast_model);
275            }
276            if proj_config.token_limit.is_some() {
277                entry.token_limit = proj_config.token_limit;
278            }
279            entry
280                .additional_params
281                .extend(proj_config.additional_params.clone());
282        }
283    }
284
285    fn project_config_has_key(project_source: &toml::Value, key: &str) -> bool {
286        project_source
287            .as_table()
288            .is_some_and(|table| table.contains_key(key))
289    }
290
291    /// Migrate older config formats. Pure — never touches the filesystem.
292    ///
293    /// Returns the (possibly updated) config and a flag indicating whether any
294    /// migration actually happened. Callers that loaded from disk (i.e. `load`)
295    /// are responsible for persisting the migrated form; tests and other
296    /// in-memory users can ignore the flag. Keeping this pure stops test
297    /// fixtures from clobbering the user's real config file.
298    fn migrate_if_needed(mut config: Self) -> (Self, bool) {
299        let mut migrated = false;
300
301        for (legacy, canonical) in [("claude", "anthropic"), ("gemini", "google")] {
302            if let Some(legacy_config) = config.providers.remove(legacy) {
303                log_debug!("Migrating '{legacy}' provider to '{canonical}'");
304
305                if config.providers.contains_key(canonical) {
306                    log_debug!(
307                        "Keeping existing '{canonical}' config and dropping legacy '{legacy}' entry"
308                    );
309                } else {
310                    config
311                        .providers
312                        .insert(canonical.to_string(), legacy_config);
313                }
314
315                migrated = true;
316            }
317
318            if config.default_provider.eq_ignore_ascii_case(legacy) {
319                config.default_provider = canonical.to_string();
320                migrated = true;
321            }
322        }
323
324        (config, migrated)
325    }
326
327    /// Save configuration to personal config file
328    ///
329    /// # Errors
330    ///
331    /// Returns an error when the personal configuration file cannot be serialized or written.
332    pub fn save(&self) -> Result<()> {
333        if self.is_project_config {
334            return Ok(());
335        }
336
337        let config_path = Self::get_personal_config_path()?;
338        let content = toml::to_string_pretty(self)?;
339        Self::write_config_file(&config_path, &content)?;
340        log_debug!("Configuration saved");
341        Ok(())
342    }
343
344    /// Save as project-specific configuration (strips API keys)
345    ///
346    /// # Errors
347    ///
348    /// Returns an error when the project configuration file cannot be serialized or written.
349    pub fn save_as_project_config(&self) -> Result<()> {
350        let config_path = Self::get_project_config_path()?;
351
352        let mut project_config = self.clone();
353        project_config.is_project_config = true;
354
355        // Strip API keys for security
356        for provider_config in project_config.providers.values_mut() {
357            provider_config.api_key.clear();
358        }
359
360        let content = toml::to_string_pretty(&project_config)?;
361        Self::write_config_file(&config_path, &content)?;
362        Ok(())
363    }
364
365    /// Write content to a config file with restricted permissions.
366    ///
367    /// On Unix, creates a temp file with 0o600 permissions first, writes content,
368    /// then renames into place — so the target path is never world-readable.
369    /// Warns (via stderr) if permission hardening fails rather than silently ignoring.
370    fn write_config_file(path: &Path, content: &str) -> Result<()> {
371        #[cfg(unix)]
372        {
373            use std::os::unix::fs::PermissionsExt;
374
375            // Write to a sibling temp file so rename is atomic on the same filesystem
376            let tmp_path = path.with_extension("tmp");
377            fs::write(&tmp_path, content)?;
378            if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)) {
379                eprintln!(
380                    "Warning: Could not restrict config permissions on {}: {e}",
381                    tmp_path.display()
382                );
383            }
384            fs::rename(&tmp_path, path)?;
385        }
386
387        #[cfg(not(unix))]
388        {
389            fs::write(path, content)?;
390        }
391
392        Ok(())
393    }
394
395    /// Resolve the directory that should hold `config.toml`.
396    ///
397    /// Precedence:
398    /// 1. `$XDG_CONFIG_HOME/git-iris` when the env var is set and non-empty.
399    /// 2. `~/Library/Application Support/git-iris` on macOS **only** when a
400    ///    config already exists there — this keeps pre-XDG installs working.
401    /// 3. `$HOME/.config/git-iris` — the XDG-style default that lines up with
402    ///    how `gh`, `neovim`, `bat`, `ripgrep`, `helix`, `starship`, and the
403    ///    rest of the modern CLI ecosystem behave on macOS.
404    /// 4. `dirs::config_dir()/git-iris` as a last-resort fallback when `$HOME`
405    ///    is unreachable (should only happen in exotic sandboxes).
406    ///
407    /// This function is pure — filesystem probing for the legacy macOS path
408    /// happens in `get_personal_config_path` so the resolver stays easy to
409    /// unit-test with synthetic inputs.
410    fn resolve_personal_config_dir(
411        xdg_config_home: Option<PathBuf>,
412        home_dir: Option<PathBuf>,
413        platform_config_dir: Option<PathBuf>,
414        legacy_macos_config_exists: bool,
415    ) -> Result<PathBuf> {
416        if let Some(xdg) = xdg_config_home.filter(|path| !path.as_os_str().is_empty()) {
417            return Ok(xdg.join("git-iris"));
418        }
419
420        if legacy_macos_config_exists && let Some(platform) = platform_config_dir.clone() {
421            return Ok(platform.join("git-iris"));
422        }
423
424        if let Some(home) = home_dir {
425            return Ok(home.join(".config").join("git-iris"));
426        }
427
428        platform_config_dir
429            .map(|p| p.join("git-iris"))
430            .ok_or_else(|| anyhow!("Unable to determine config directory"))
431    }
432
433    /// Get path to personal config file
434    ///
435    /// # Errors
436    ///
437    /// Returns an error when the config directory cannot be resolved or created.
438    pub fn get_personal_config_path() -> Result<PathBuf> {
439        let platform_dir = config_dir();
440
441        // Only probe the legacy macOS location on macOS. On every other
442        // platform `dirs::config_dir()` already maps to `$HOME/.config` (or an
443        // equivalent), so treating the existence check as macOS-only avoids
444        // falsely flagging a Linux user's `~/.config/git-iris` as "legacy".
445        let legacy_macos_config_exists = cfg!(target_os = "macos")
446            && platform_dir
447                .as_ref()
448                .is_some_and(|dir| dir.join("git-iris").join("config.toml").exists());
449
450        let mut path = Self::resolve_personal_config_dir(
451            std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
452            home_dir(),
453            platform_dir,
454            legacy_macos_config_exists,
455        )?;
456        fs::create_dir_all(&path)?;
457        path.push("config.toml");
458        Ok(path)
459    }
460
461    /// Check environment prerequisites
462    ///
463    /// # Errors
464    ///
465    /// Returns an error when the current working directory is not inside a Git repository.
466    pub fn check_environment(&self) -> Result<()> {
467        if !GitRepo::is_inside_work_tree()? {
468            return Err(anyhow!(
469                "Not in a Git repository. Please run this command from within a Git repository."
470            ));
471        }
472        Ok(())
473    }
474
475    /// Set temporary instructions for this session
476    pub fn set_temp_instructions(&mut self, instructions: Option<String>) {
477        self.temp_instructions = instructions;
478    }
479
480    /// Set temporary preset for this session
481    pub fn set_temp_preset(&mut self, preset: Option<String>) {
482        self.temp_preset = preset;
483    }
484
485    /// Get effective preset name (temp overrides saved)
486    #[must_use]
487    pub fn get_effective_preset_name(&self) -> &str {
488        self.temp_preset
489            .as_deref()
490            .unwrap_or(&self.instruction_preset)
491    }
492
493    /// Get effective instructions (combines preset + custom)
494    #[must_use]
495    pub fn get_effective_instructions(&self) -> String {
496        let preset_library = get_instruction_preset_library();
497        let preset_instructions = self
498            .temp_preset
499            .as_ref()
500            .or(Some(&self.instruction_preset))
501            .and_then(|p| preset_library.get_preset(p))
502            .map(|p| p.instructions.clone())
503            .unwrap_or_default();
504
505        let custom = self
506            .temp_instructions
507            .as_ref()
508            .unwrap_or(&self.instructions);
509
510        format!("{preset_instructions}\n\n{custom}")
511            .trim()
512            .to_string()
513    }
514
515    /// Update configuration with new values
516    #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
517    ///
518    /// # Errors
519    ///
520    /// Returns an error when the provider is invalid or the provider config cannot be updated.
521    pub fn update(
522        &mut self,
523        provider: Option<String>,
524        api_key: Option<String>,
525        model: Option<String>,
526        fast_model: Option<String>,
527        additional_params: Option<HashMap<String, String>>,
528        use_gitmoji: Option<bool>,
529        instructions: Option<String>,
530        token_limit: Option<usize>,
531    ) -> Result<()> {
532        if let Some(ref provider_name) = provider {
533            // Validate provider
534            let parsed: Provider = provider_name.parse().with_context(|| {
535                format!(
536                    "Unknown provider '{}'. Supported: {}",
537                    provider_name,
538                    Provider::all_names().join(", ")
539                )
540            })?;
541
542            self.default_provider = parsed.name().to_string();
543
544            // Ensure provider config exists
545            if !self.providers.contains_key(parsed.name()) {
546                self.providers.insert(
547                    parsed.name().to_string(),
548                    ProviderConfig::with_defaults(parsed),
549                );
550            }
551        }
552
553        let provider_config = self
554            .providers
555            .get_mut(&self.default_provider)
556            .context("Could not get default provider config")?;
557
558        if let Some(key) = api_key {
559            provider_config.api_key = key;
560        }
561        if let Some(m) = model {
562            provider_config.model = m;
563        }
564        if let Some(fm) = fast_model {
565            provider_config.fast_model = Some(fm);
566        }
567        if let Some(params) = additional_params {
568            provider_config.additional_params.extend(params);
569        }
570        if let Some(gitmoji) = use_gitmoji {
571            self.use_gitmoji = gitmoji;
572        }
573        if let Some(instr) = instructions {
574            self.instructions = instr;
575        }
576        if let Some(limit) = token_limit {
577            provider_config.token_limit = Some(limit);
578        }
579
580        log_debug!("Configuration updated");
581        Ok(())
582    }
583
584    /// Get the provider configuration for a specific provider
585    #[must_use]
586    pub fn get_provider_config(&self, provider: &str) -> Option<&ProviderConfig> {
587        // Handle legacy/common aliases
588        let name = if provider.eq_ignore_ascii_case("claude") {
589            "anthropic"
590        } else if provider.eq_ignore_ascii_case("gemini") {
591            "google"
592        } else {
593            provider
594        };
595
596        self.providers
597            .get(name)
598            .or_else(|| self.providers.get(&name.to_lowercase()))
599    }
600
601    /// Get the current provider as `Provider` enum
602    #[must_use]
603    pub fn provider(&self) -> Option<Provider> {
604        self.default_provider.parse().ok()
605    }
606
607    /// Validate that the current provider is properly configured
608    ///
609    /// # Errors
610    ///
611    /// Returns an error when the provider is invalid or no API key is configured.
612    pub fn validate(&self) -> Result<()> {
613        let provider: Provider = self
614            .default_provider
615            .parse()
616            .with_context(|| format!("Invalid provider: {}", self.default_provider))?;
617
618        let config = self
619            .get_provider_config(provider.name())
620            .ok_or_else(|| anyhow!("No configuration found for provider: {}", provider.name()))?;
621
622        if !config.has_api_key() {
623            // Check environment variable as fallback
624            if std::env::var(provider.api_key_env()).is_err() {
625                return Err(anyhow!(
626                    "API key required for {}. Set {} or configure in ~/.config/git-iris/config.toml",
627                    provider.name(),
628                    provider.api_key_env()
629                ));
630            }
631        }
632
633        Ok(())
634    }
635}
636
637#[cfg(test)]
638mod tests;