1use 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
18pub const PROJECT_CONFIG_FILENAME: &str = ".irisconfig";
20
21#[derive(Deserialize, Serialize, Clone, Debug)]
23pub struct Config {
24 #[serde(default, skip_serializing_if = "String::is_empty")]
26 pub default_provider: String,
27 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
29 pub providers: HashMap<String, ProviderConfig>,
30 #[serde(default = "default_true", skip_serializing_if = "is_true")]
32 pub use_gitmoji: bool,
33 #[serde(default, skip_serializing_if = "String::is_empty")]
35 pub instructions: String,
36 #[serde(default = "default_preset", skip_serializing_if = "is_default_preset")]
38 pub instruction_preset: String,
39 #[serde(default, skip_serializing_if = "String::is_empty")]
41 pub theme: String,
42 #[serde(
44 default = "default_subagent_timeout",
45 skip_serializing_if = "is_default_subagent_timeout"
46 )]
47 pub subagent_timeout_secs: u64,
48 #[serde(skip)]
50 pub temp_instructions: Option<String>,
51 #[serde(skip)]
53 pub temp_preset: Option<String>,
54 #[serde(skip)]
56 pub is_project_config: bool,
57 #[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 }
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 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 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 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 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 pub fn merge_with_project_config(&mut self, project_config: Self) {
195 log_debug!("Merging with project configuration");
196
197 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 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 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 if !project_config.theme.is_empty() {
232 self.theme = project_config.theme;
233 }
234
235 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 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 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 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 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 fn write_config_file(path: &Path, content: &str) -> Result<()> {
371 #[cfg(unix)]
372 {
373 use std::os::unix::fs::PermissionsExt;
374
375 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 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 pub fn get_personal_config_path() -> Result<PathBuf> {
439 let platform_dir = config_dir();
440
441 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 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 pub fn set_temp_instructions(&mut self, instructions: Option<String>) {
477 self.temp_instructions = instructions;
478 }
479
480 pub fn set_temp_preset(&mut self, preset: Option<String>) {
482 self.temp_preset = preset;
483 }
484
485 #[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 #[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 #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
517 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 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 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 #[must_use]
586 pub fn get_provider_config(&self, provider: &str) -> Option<&ProviderConfig> {
587 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 #[must_use]
603 pub fn provider(&self) -> Option<Provider> {
604 self.default_provider.parse().ok()
605 }
606
607 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 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;