Skip to main content

rusty_commit/
config.rs

1pub mod accounts;
2pub mod format;
3pub mod migrations;
4pub mod secure_storage;
5
6use anyhow::{Context, Result};
7use colored::Colorize;
8use dirs::home_dir;
9use serde::{Deserialize, Serialize};
10use std::env;
11use std::path::PathBuf;
12
13#[derive(Debug, Serialize, Deserialize, Clone)]
14pub struct Config {
15    // API Configuration
16    pub api_key: Option<String>,
17    pub api_url: Option<String>,
18    pub ai_provider: Option<String>,
19    pub model: Option<String>,
20
21    // Token limits
22    pub tokens_max_input: Option<usize>,
23    pub tokens_max_output: Option<u32>,
24
25    // Commit message configuration
26    pub commit_type: Option<String>,
27    pub emoji: Option<bool>,
28    pub description: Option<bool>,
29    pub description_capitalize: Option<bool>,
30    pub description_add_period: Option<bool>,
31    pub description_max_length: Option<usize>,
32
33    // Language and customization
34    pub language: Option<String>,
35    pub message_template_placeholder: Option<String>,
36    pub prompt_module: Option<String>,
37
38    // Behavior
39    pub gitpush: Option<bool>,
40    pub remote: Option<String>,
41    pub one_line_commit: Option<bool>,
42    pub why: Option<bool>,
43    pub omit_scope: Option<bool>,
44    pub generate_count: Option<u8>,
45    pub clipboard_on_timeout: Option<bool>,
46
47    // GitHub Actions
48    pub action_enabled: Option<bool>,
49
50    // Testing
51    pub test_mock_type: Option<String>,
52
53    // Hooks
54    pub hook_auto_uncomment: Option<bool>,
55    pub pre_gen_hook: Option<Vec<String>>,
56    pub pre_commit_hook: Option<Vec<String>>,
57    pub post_commit_hook: Option<Vec<String>>,
58    pub hook_strict: Option<bool>,
59    pub hook_timeout_ms: Option<u64>,
60
61    // Global commitlint configuration
62    pub commitlint_config: Option<String>,
63    pub custom_prompt: Option<String>,
64    pub prompt_file: Option<String>,
65
66    // Commit style learning from history
67    pub learn_from_history: Option<bool>,
68    pub history_commits_count: Option<usize>,
69    pub style_profile: Option<String>,
70
71    // Commit body support
72    pub enable_commit_body: Option<bool>,
73}
74
75impl Default for Config {
76    fn default() -> Self {
77        Self {
78            api_key: None,
79            api_url: None,
80            ai_provider: Some("openai".to_string()),
81            model: Some("gpt-3.5-turbo".to_string()),
82            tokens_max_input: Some(4096),
83            tokens_max_output: Some(500),
84            commit_type: Some("conventional".to_string()),
85            emoji: Some(false),
86            description: Some(false),
87            description_capitalize: Some(true),
88            description_add_period: Some(false),
89            description_max_length: Some(100),
90            language: Some("en".to_string()),
91            message_template_placeholder: Some("$msg".to_string()),
92            prompt_module: Some("conventional-commit".to_string()),
93            gitpush: Some(false),
94            remote: None,
95            one_line_commit: Some(false),
96            why: Some(false),
97            omit_scope: Some(false),
98            generate_count: Some(1),
99            clipboard_on_timeout: Some(true),
100            action_enabled: Some(false),
101            test_mock_type: None,
102            hook_auto_uncomment: Some(false),
103            pre_gen_hook: None,
104            pre_commit_hook: None,
105            post_commit_hook: None,
106            hook_strict: Some(true),
107            hook_timeout_ms: Some(30000),
108            commitlint_config: None,
109            custom_prompt: None,
110            prompt_file: None,
111            learn_from_history: Some(false),
112            history_commits_count: Some(50),
113            style_profile: None,
114            enable_commit_body: Some(false),
115        }
116    }
117}
118
119impl Config {
120    /// Get the new global config path
121    #[allow(dead_code)]
122    pub fn global_config_path() -> Result<PathBuf> {
123        if let Ok(config_home) = env::var("RCO_CONFIG_HOME") {
124            Ok(PathBuf::from(config_home).join("config.toml"))
125        } else {
126            let home = home_dir().context("Could not find home directory")?;
127            Ok(home.join(".config").join("rustycommit").join("config.toml"))
128        }
129    }
130
131    /// Load configuration with proper priority handling
132    pub fn load() -> Result<Self> {
133        // Use the new format system to load with priority
134        format::ConfigLocations::load_merged()
135    }
136
137    pub fn save(&self) -> Result<()> {
138        // Save to global config by default
139        self.save_to(format::ConfigLocation::Global)
140    }
141
142    /// Save configuration to a specific location
143    pub fn save_to(&self, location: format::ConfigLocation) -> Result<()> {
144        // Create a copy for saving (without sensitive data)
145        let mut save_config = self.clone();
146
147        // If we have an API key and secure storage is available, store it securely
148        if let Some(ref api_key) = self.api_key {
149            if secure_storage::is_available() {
150                match secure_storage::store_secret("RCO_API_KEY", api_key) {
151                    Ok(_) => {
152                        // Don't save API key to file if stored securely
153                        save_config.api_key = None;
154                    }
155                    Err(e) => {
156                        // Fall back to file storage; keep api_key in file
157                        eprintln!("Warning: Secure storage unavailable, falling back to file: {e}");
158                    }
159                }
160            }
161        }
162
163        format::ConfigLocations::save(&save_config, location)
164    }
165
166    /// Helper function to get environment variable with RCO_ prefix
167    fn get_env_var(base_name: &str) -> Option<String> {
168        let rco_key = format!("RCO_{}", base_name);
169
170        // Check RCO_ prefix
171        env::var(&rco_key).ok()
172    }
173
174    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
175        // Handle undefined/null values
176        if value == "undefined" || value == "null" {
177            return Ok(());
178        }
179
180        match key {
181            // Support RCO_ prefix
182            "RCO_API_KEY" => {
183                self.api_key = Some(value.to_string());
184                // Also try to store in secure storage (use RCO_ key)
185                if secure_storage::is_available() {
186                    let _ = secure_storage::store_secret("RCO_API_KEY", value);
187                }
188            }
189            "RCO_API_URL" => self.api_url = Some(value.to_string()),
190            "RCO_AI_PROVIDER" => self.ai_provider = Some(value.to_string()),
191            "RCO_MODEL" => self.model = Some(value.to_string()),
192            "RCO_TOKENS_MAX_INPUT" => {
193                self.tokens_max_input = Some(
194                    value
195                        .parse()
196                        .context("Invalid number for TOKENS_MAX_INPUT")?,
197                );
198            }
199            "RCO_TOKENS_MAX_OUTPUT" => {
200                self.tokens_max_output = Some(
201                    value
202                        .parse()
203                        .context("Invalid number for TOKENS_MAX_OUTPUT")?,
204                );
205            }
206            "RCO_COMMIT_TYPE" => {
207                self.commit_type = Some(value.to_string());
208            }
209            "RCO_PROMPT_MODULE" => {
210                // Map legacy prompt module to commit type
211                let commit_type = match value {
212                    "conventional-commit" => "conventional",
213                    _ => value,
214                };
215                self.commit_type = Some(commit_type.to_string());
216                self.prompt_module = Some(value.to_string());
217            }
218            "RCO_EMOJI" => {
219                self.emoji = Some(value.parse().context("Invalid boolean for EMOJI")?);
220            }
221            "RCO_DESCRIPTION_CAPITALIZE" => {
222                self.description_capitalize = Some(
223                    value
224                        .parse()
225                        .context("Invalid boolean for DESCRIPTION_CAPITALIZE")?,
226                );
227            }
228            "RCO_DESCRIPTION_ADD_PERIOD" => {
229                self.description_add_period = Some(
230                    value
231                        .parse()
232                        .context("Invalid boolean for DESCRIPTION_ADD_PERIOD")?,
233                );
234            }
235            "RCO_DESCRIPTION_MAX_LENGTH" => {
236                self.description_max_length = Some(
237                    value
238                        .parse()
239                        .context("Invalid number for DESCRIPTION_MAX_LENGTH")?,
240                );
241            }
242            "RCO_LANGUAGE" => self.language = Some(value.to_string()),
243            "RCO_MESSAGE_TEMPLATE_PLACEHOLDER" => {
244                self.message_template_placeholder = Some(value.to_string());
245            }
246            "RCO_GITPUSH" => {
247                self.gitpush = Some(value.parse().context("Invalid boolean for GITPUSH")?);
248            }
249            "RCO_REMOTE" => self.remote = Some(value.to_string()),
250            "RCO_ONE_LINE_COMMIT" => {
251                self.one_line_commit = Some(
252                    value
253                        .parse()
254                        .context("Invalid boolean for ONE_LINE_COMMIT")?,
255                );
256            }
257            "RCO_ACTION_ENABLED" => {
258                self.action_enabled = Some(
259                    value
260                        .parse()
261                        .context("Invalid boolean for ACTION_ENABLED")?,
262                );
263            }
264            "RCO_DESCRIPTION" => {
265                self.description = Some(value.parse().context("Invalid boolean for DESCRIPTION")?);
266            }
267            "RCO_WHY" => {
268                self.why = Some(value.parse().context("Invalid boolean for WHY")?);
269            }
270            "RCO_OMIT_SCOPE" => {
271                self.omit_scope = Some(value.parse().context("Invalid boolean for OMIT_SCOPE")?);
272            }
273            "RCO_TEST_MOCK_TYPE" => {
274                self.test_mock_type = Some(value.to_string());
275            }
276            "RCO_HOOK_AUTO_UNCOMMENT" => {
277                self.hook_auto_uncomment = Some(
278                    value
279                        .parse()
280                        .context("Invalid boolean for HOOK_AUTO_UNCOMMENT")?,
281                );
282            }
283            "RCO_PRE_GEN_HOOK" => {
284                let items = value
285                    .split(';')
286                    .map(|s| s.trim().to_string())
287                    .filter(|s| !s.is_empty())
288                    .collect();
289                self.pre_gen_hook = Some(items);
290            }
291            "RCO_PRE_COMMIT_HOOK" => {
292                let items = value
293                    .split(';')
294                    .map(|s| s.trim().to_string())
295                    .filter(|s| !s.is_empty())
296                    .collect();
297                self.pre_commit_hook = Some(items);
298            }
299            "RCO_POST_COMMIT_HOOK" => {
300                let items = value
301                    .split(';')
302                    .map(|s| s.trim().to_string())
303                    .filter(|s| !s.is_empty())
304                    .collect();
305                self.post_commit_hook = Some(items);
306            }
307            "RCO_HOOK_STRICT" => {
308                self.hook_strict = Some(value.parse().context("Invalid boolean for HOOK_STRICT")?);
309            }
310            "RCO_HOOK_TIMEOUT_MS" => {
311                self.hook_timeout_ms = Some(
312                    value
313                        .parse()
314                        .context("Invalid number for HOOK_TIMEOUT_MS")?,
315                );
316            }
317            "RCO_COMMITLINT_CONFIG" => {
318                self.commitlint_config = Some(value.to_string());
319            }
320            "RCO_CUSTOM_PROMPT" => {
321                self.custom_prompt = Some(value.to_string());
322            }
323            "RCO_PROMPT_FILE" => {
324                self.prompt_file = Some(value.to_string());
325            }
326            "RCO_GENERATE_COUNT" => {
327                self.generate_count = Some(
328                    value
329                        .parse()
330                        .context("Invalid number for GENERATE_COUNT (1-5)")?,
331                );
332            }
333            "RCO_CLIPBOARD_ON_TIMEOUT" => {
334                self.clipboard_on_timeout = Some(
335                    value
336                        .parse()
337                        .context("Invalid boolean for CLIPBOARD_ON_TIMEOUT")?,
338                );
339            }
340            "RCO_LEARN_FROM_HISTORY" => {
341                self.learn_from_history = Some(
342                    value
343                        .parse()
344                        .context("Invalid boolean for LEARN_FROM_HISTORY")?,
345                );
346            }
347            "RCO_HISTORY_COMMITS_COUNT" => {
348                self.history_commits_count = Some(
349                    value
350                        .parse()
351                        .context("Invalid number for HISTORY_COMMITS_COUNT")?,
352                );
353            }
354            "RCO_STYLE_PROFILE" => {
355                self.style_profile = Some(value.to_string());
356            }
357            "RCO_ENABLE_COMMIT_BODY" => {
358                self.enable_commit_body = Some(
359                    value
360                        .parse()
361                        .context("Invalid boolean for ENABLE_COMMIT_BODY")?,
362                );
363            }
364            // Ignore unsupported keys
365            "RCO_API_CUSTOM_HEADERS" => {
366                // Silently ignore these legacy keys
367                return Ok(());
368            }
369            _ => anyhow::bail!("Unknown configuration key: {}", key),
370        }
371
372        self.save()?;
373        Ok(())
374    }
375
376    pub fn get(&self, key: &str) -> Result<String> {
377        let value = match key {
378            "RCO_API_KEY" => {
379                // Try to get from memory first, then from secure storage
380                self.api_key
381                    .as_ref()
382                    .map(|s| s.to_string())
383                    .or_else(|| secure_storage::get_secret("RCO_API_KEY").ok().flatten())
384            }
385            "RCO_API_URL" => self.api_url.as_ref().map(|s| s.to_string()),
386            "RCO_AI_PROVIDER" => self.ai_provider.as_ref().map(|s| s.to_string()),
387            "RCO_MODEL" => self.model.as_ref().map(|s| s.to_string()),
388            "RCO_TOKENS_MAX_INPUT" => self.tokens_max_input.map(|v| v.to_string()),
389            "RCO_TOKENS_MAX_OUTPUT" => self.tokens_max_output.map(|v| v.to_string()),
390            "RCO_COMMIT_TYPE" => self.commit_type.as_ref().map(|s| s.to_string()),
391            "RCO_EMOJI" => self.emoji.map(|v| v.to_string()),
392            "RCO_DESCRIPTION_CAPITALIZE" => self.description_capitalize.map(|v| v.to_string()),
393            "RCO_DESCRIPTION_ADD_PERIOD" => self.description_add_period.map(|v| v.to_string()),
394            "RCO_DESCRIPTION_MAX_LENGTH" => self.description_max_length.map(|v| v.to_string()),
395            "RCO_LANGUAGE" => self.language.as_ref().map(|s| s.to_string()),
396            "RCO_MESSAGE_TEMPLATE_PLACEHOLDER" => self
397                .message_template_placeholder
398                .as_ref()
399                .map(|s| s.to_string()),
400            "RCO_GITPUSH" => self.gitpush.map(|v| v.to_string()),
401            "RCO_REMOTE" => self.remote.as_ref().map(|s| s.to_string()),
402            "RCO_ONE_LINE_COMMIT" => self.one_line_commit.map(|v| v.to_string()),
403            "RCO_ACTION_ENABLED" => self.action_enabled.map(|v| v.to_string()),
404            "RCO_COMMITLINT_CONFIG" => self.commitlint_config.as_ref().map(|s| s.to_string()),
405            "RCO_CUSTOM_PROMPT" => self.custom_prompt.as_ref().map(|s| s.to_string()),
406            "RCO_PROMPT_FILE" => self.prompt_file.as_ref().map(|s| s.to_string()),
407            "RCO_GENERATE_COUNT" => self.generate_count.map(|v| v.to_string()),
408            "RCO_CLIPBOARD_ON_TIMEOUT" => self.clipboard_on_timeout.map(|v| v.to_string()),
409            _ => None,
410        };
411
412        value.ok_or_else(|| anyhow::anyhow!("Configuration key '{}' not found or not set", key))
413    }
414
415    pub fn reset(&mut self, keys: Option<&[String]>) -> Result<()> {
416        if let Some(key_list) = keys {
417            let default = Self::default();
418            for key in key_list {
419                match key.as_str() {
420                    "RCO_API_KEY" => {
421                        self.api_key = default.api_key.clone();
422                        // Also clear from secure storage
423                        let _ = secure_storage::delete_secret("RCO_API_KEY");
424                    }
425                    "RCO_API_URL" => self.api_url = default.api_url.clone(),
426                    "RCO_AI_PROVIDER" => self.ai_provider = default.ai_provider.clone(),
427                    "RCO_MODEL" => self.model = default.model.clone(),
428                    "RCO_TOKENS_MAX_INPUT" => self.tokens_max_input = default.tokens_max_input,
429                    "RCO_TOKENS_MAX_OUTPUT" => self.tokens_max_output = default.tokens_max_output,
430                    "RCO_COMMIT_TYPE" => self.commit_type = default.commit_type.clone(),
431                    "RCO_EMOJI" => self.emoji = default.emoji,
432                    "RCO_DESCRIPTION_CAPITALIZE" => {
433                        self.description_capitalize = default.description_capitalize
434                    }
435                    "RCO_DESCRIPTION_ADD_PERIOD" => {
436                        self.description_add_period = default.description_add_period
437                    }
438                    "RCO_DESCRIPTION_MAX_LENGTH" => {
439                        self.description_max_length = default.description_max_length
440                    }
441                    "RCO_LANGUAGE" => self.language = default.language.clone(),
442                    "RCO_MESSAGE_TEMPLATE_PLACEHOLDER" => {
443                        self.message_template_placeholder =
444                            default.message_template_placeholder.clone()
445                    }
446                    "RCO_GITPUSH" => self.gitpush = default.gitpush,
447                    "RCO_REMOTE" => self.remote = default.remote.clone(),
448                    "RCO_ONE_LINE_COMMIT" => self.one_line_commit = default.one_line_commit,
449                    "RCO_ACTION_ENABLED" => self.action_enabled = default.action_enabled,
450                    "RCO_PRE_GEN_HOOK" => self.pre_gen_hook = default.pre_gen_hook.clone(),
451                    "RCO_PRE_COMMIT_HOOK" => self.pre_commit_hook = default.pre_commit_hook.clone(),
452                    "RCO_POST_COMMIT_HOOK" => {
453                        self.post_commit_hook = default.post_commit_hook.clone()
454                    }
455                    "RCO_HOOK_STRICT" => self.hook_strict = default.hook_strict,
456                    "RCO_HOOK_TIMEOUT_MS" => self.hook_timeout_ms = default.hook_timeout_ms,
457                    "RCO_GENERATE_COUNT" => self.generate_count = default.generate_count,
458                    "RCO_CLIPBOARD_ON_TIMEOUT" => {
459                        self.clipboard_on_timeout = default.clipboard_on_timeout
460                    }
461                    _ => anyhow::bail!("Unknown configuration key: {}", key),
462                }
463            }
464        } else {
465            *self = Self::default();
466        }
467
468        self.save()?;
469        Ok(())
470    }
471
472    /// Load and merge global commitlint configuration
473    pub fn load_with_commitlint(&mut self) -> Result<()> {
474        // First check for COMMITLINT_CONFIG environment variable
475        if let Ok(commitlint_path) = env::var("COMMITLINT_CONFIG") {
476            self.commitlint_config = Some(commitlint_path);
477        }
478
479        // If no explicit config path, check default locations
480        if self.commitlint_config.is_none() {
481            let home = home_dir().context("Could not find home directory")?;
482
483            // Check for global commitlint configs in priority order
484            let possible_paths = [
485                home.join(".commitlintrc.js"),
486                home.join(".commitlintrc.json"),
487                home.join(".commitlintrc.yml"),
488                home.join(".commitlintrc.yaml"),
489                home.join("commitlint.config.js"),
490            ];
491
492            for path in &possible_paths {
493                if path.exists() {
494                    self.commitlint_config = Some(path.to_string_lossy().to_string());
495                    break;
496                }
497            }
498        }
499
500        Ok(())
501    }
502
503    /// Load commitlint rules and modify commit type accordingly
504    pub fn apply_commitlint_rules(&mut self) -> Result<()> {
505        if let Some(ref config_path) = self.commitlint_config.clone() {
506            let path = PathBuf::from(config_path);
507            if path.exists() {
508                // For now, just set to conventional commits if commitlint config exists
509                // Full commitlint parsing would require a JS engine or specific parsing
510                if self.commit_type.is_none() {
511                    self.commit_type = Some("conventional".to_string());
512                }
513
514                // In a full implementation, we would parse the commitlint config
515                // and extract rules, but for now we'll use conventional commits
516                println!("📋 Found commitlint config at: {}", config_path);
517                println!("🔧 Using conventional commit format for consistency");
518            }
519        }
520        Ok(())
521    }
522
523    /// Get the effective prompt (custom or generated)
524    pub fn get_effective_prompt(
525        &self,
526        diff: &str,
527        context: Option<&str>,
528        full_gitmoji: bool,
529    ) -> String {
530        // Try to load prompt from file first, then fall back to inline custom_prompt
531        let custom_prompt_template = if let Some(ref prompt_file) = self.prompt_file {
532            match Self::load_prompt_file(prompt_file) {
533                Ok(content) => {
534                    tracing::info!("Loaded custom prompt from file: {}", prompt_file);
535                    Some(content)
536                }
537                Err(e) => {
538                    eprintln!(
539                        "{}",
540                        format!(
541                            "Warning: Failed to load prompt file '{}': {}. Using fallback.",
542                            prompt_file, e
543                        )
544                        .yellow()
545                    );
546                    self.custom_prompt.clone()
547                }
548            }
549        } else {
550            self.custom_prompt.clone()
551        };
552
553        if let Some(template) = custom_prompt_template {
554            // Security warning: custom prompts receive diff content
555            tracing::warn!(
556                "SECURITY: Using custom prompt template - full diff content will be included in the prompt. \
557                Only use custom prompts from trusted sources. Malicious prompts could exfiltrate code."
558            );
559            eprintln!(
560                "{}",
561                "⚠️  SECURITY WARNING: Using custom prompt template."
562                    .yellow()
563                    .bold()
564            );
565            eprintln!(
566                "{}",
567                "   Your diff content (potentially including sensitive code) will be sent to the AI provider."
568                    .yellow()
569            );
570            eprintln!(
571                "{}",
572                "   Only use custom prompts from trusted sources.".yellow()
573            );
574
575            // Replace placeholders in custom prompt
576            Self::replace_placeholders(&template, diff, context, self)
577        } else {
578            // Use the standard prompt generation
579            super::providers::prompt::build_prompt(diff, context, self, full_gitmoji)
580        }
581    }
582
583    /// Load prompt content from a file, expanding ~ to home directory
584    fn load_prompt_file(path: &str) -> Result<String> {
585        let expanded_path = if path.starts_with("~") {
586            if let Some(home) = home_dir() {
587                home.join(path.strip_prefix("~/").unwrap_or(path))
588            } else {
589                PathBuf::from(path)
590            }
591        } else {
592            PathBuf::from(path)
593        };
594
595        std::fs::read_to_string(&expanded_path)
596            .with_context(|| format!("Failed to read prompt file: {}", expanded_path.display()))
597    }
598
599    /// Replace placeholders in a prompt template
600    /// Supports both {var} and $var syntax
601    fn replace_placeholders(
602        template: &str,
603        diff: &str,
604        context: Option<&str>,
605        config: &Config,
606    ) -> String {
607        let mut result = template.to_string();
608
609        // Get values from config with defaults
610        let language = config.language.as_deref().unwrap_or("en");
611        let commit_type = config.commit_type.as_deref().unwrap_or("conventional");
612        let max_length = config.description_max_length.unwrap_or(100).to_string();
613        let emoji = config.emoji.unwrap_or(false).to_string();
614        let description = config.description.unwrap_or(false).to_string();
615
616        // Context value (empty string if None)
617        let context_str = context.unwrap_or("");
618
619        // Replace {var} style placeholders
620        result = result.replace("{diff}", diff);
621        result = result.replace("{context}", context_str);
622        result = result.replace("{language}", language);
623        result = result.replace("{commit_type}", commit_type);
624        result = result.replace("{max_length}", &max_length);
625        result = result.replace("{emoji}", &emoji);
626        result = result.replace("{description}", &description);
627
628        // Replace $var style placeholders (legacy support)
629        result = result.replace("$diff", diff);
630        result = result.replace("$context", context_str);
631        result = result.replace("$language", language);
632        result = result.replace("$commit_type", commit_type);
633        result = result.replace("$max_length", &max_length);
634        result = result.replace("$emoji", &emoji);
635        result = result.replace("$description", &description);
636
637        result
638    }
639
640    /// Set the prompt file path (for CLI override)
641    pub fn set_prompt_file(&mut self, path: Option<String>) {
642        self.prompt_file = path;
643    }
644
645    /// Merge another config into this one (other takes priority over self)
646    pub fn merge(&mut self, other: Config) {
647        macro_rules! merge_field {
648            ($field:ident) => {
649                if other.$field.is_some() {
650                    self.$field = other.$field;
651                }
652            };
653        }
654
655        merge_field!(api_key);
656        merge_field!(api_url);
657        merge_field!(ai_provider);
658        merge_field!(model);
659        merge_field!(tokens_max_input);
660        merge_field!(tokens_max_output);
661        merge_field!(commit_type);
662        merge_field!(emoji);
663        merge_field!(description);
664        merge_field!(description_capitalize);
665        merge_field!(description_add_period);
666        merge_field!(description_max_length);
667        merge_field!(language);
668        merge_field!(message_template_placeholder);
669        merge_field!(prompt_module);
670        merge_field!(gitpush);
671        merge_field!(remote);
672        merge_field!(one_line_commit);
673        merge_field!(why);
674        merge_field!(omit_scope);
675        merge_field!(action_enabled);
676        merge_field!(test_mock_type);
677        merge_field!(hook_auto_uncomment);
678        merge_field!(pre_gen_hook);
679        merge_field!(pre_commit_hook);
680        merge_field!(post_commit_hook);
681        merge_field!(hook_strict);
682        merge_field!(hook_timeout_ms);
683        merge_field!(commitlint_config);
684        merge_field!(custom_prompt);
685        merge_field!(prompt_file);
686        merge_field!(generate_count);
687        merge_field!(clipboard_on_timeout);
688        merge_field!(learn_from_history);
689        merge_field!(history_commits_count);
690        merge_field!(style_profile);
691    }
692
693    /// Load configuration values from environment variables
694    /// Uses RCO_ environment variables
695    pub fn load_from_environment(&mut self) {
696        // Macro to reduce code duplication
697        macro_rules! load_env_var {
698            ($field:ident, $base_name:expr) => {
699                if let Some(value) = Self::get_env_var($base_name) {
700                    self.$field = Some(value);
701                }
702            };
703        }
704
705        macro_rules! load_env_var_parse {
706            ($field:ident, $base_name:expr, $type:ty) => {
707                if let Some(value) = Self::get_env_var($base_name) {
708                    if let Ok(parsed) = value.parse::<$type>() {
709                        self.$field = Some(parsed);
710                    }
711                }
712            };
713        }
714
715        load_env_var!(api_key, "API_KEY");
716        load_env_var!(api_url, "API_URL");
717        load_env_var!(ai_provider, "AI_PROVIDER");
718        load_env_var!(model, "MODEL");
719        load_env_var_parse!(tokens_max_input, "TOKENS_MAX_INPUT", usize);
720        load_env_var_parse!(tokens_max_output, "TOKENS_MAX_OUTPUT", u32);
721        load_env_var!(commit_type, "COMMIT_TYPE");
722        load_env_var_parse!(emoji, "EMOJI", bool);
723        load_env_var_parse!(description, "DESCRIPTION", bool);
724        load_env_var_parse!(description_capitalize, "DESCRIPTION_CAPITALIZE", bool);
725        load_env_var_parse!(description_add_period, "DESCRIPTION_ADD_PERIOD", bool);
726        load_env_var_parse!(description_max_length, "DESCRIPTION_MAX_LENGTH", usize);
727        load_env_var!(language, "LANGUAGE");
728        load_env_var!(message_template_placeholder, "MESSAGE_TEMPLATE_PLACEHOLDER");
729        load_env_var!(prompt_module, "PROMPT_MODULE");
730        load_env_var_parse!(gitpush, "GITPUSH", bool);
731        load_env_var!(remote, "REMOTE");
732        load_env_var_parse!(one_line_commit, "ONE_LINE_COMMIT", bool);
733        load_env_var_parse!(why, "WHY", bool);
734        load_env_var_parse!(omit_scope, "OMIT_SCOPE", bool);
735        load_env_var_parse!(action_enabled, "ACTION_ENABLED", bool);
736        load_env_var!(test_mock_type, "TEST_MOCK_TYPE");
737        load_env_var_parse!(hook_auto_uncomment, "HOOK_AUTO_UNCOMMENT", bool);
738        load_env_var!(commitlint_config, "COMMITLINT_CONFIG");
739        load_env_var!(custom_prompt, "CUSTOM_PROMPT");
740        load_env_var!(prompt_file, "PROMPT_FILE");
741        load_env_var_parse!(generate_count, "GENERATE_COUNT", u8);
742        load_env_var_parse!(clipboard_on_timeout, "CLIPBOARD_ON_TIMEOUT", bool);
743        load_env_var_parse!(learn_from_history, "LEARN_FROM_HISTORY", bool);
744        load_env_var_parse!(history_commits_count, "HISTORY_COMMITS_COUNT", usize);
745        load_env_var!(style_profile, "STYLE_PROFILE");
746        load_env_var_parse!(enable_commit_body, "ENABLE_COMMIT_BODY", bool);
747    }
748}
749
750// ============================================
751// Multi-account support methods
752// ============================================
753
754#[allow(dead_code)]
755impl Config {
756    /// Get the active account config, if available
757    pub fn get_active_account(&self) -> Result<Option<accounts::AccountConfig>> {
758        if let Some(accounts_config) = accounts::AccountsConfig::load()? {
759            if let Some(account) = accounts_config.get_active_account() {
760                return Ok(Some(account.clone()));
761            }
762        }
763        Ok(None)
764    }
765
766    /// Check if we have any accounts configured
767    pub fn has_accounts(&self) -> bool {
768        accounts::AccountsConfig::load()
769            .map(|c| c.map(|ac| !ac.accounts.is_empty()).unwrap_or(false))
770            .unwrap_or(false)
771    }
772
773    /// Get a specific account by alias
774    pub fn get_account(&self, alias: &str) -> Result<Option<accounts::AccountConfig>> {
775        if let Some(accounts_config) = accounts::AccountsConfig::load()? {
776            if let Some(account) = accounts_config.get_account(alias) {
777                return Ok(Some(account.clone()));
778            }
779        }
780        Ok(None)
781    }
782
783    /// List all accounts
784    pub fn list_accounts(&self) -> Result<Vec<accounts::AccountConfig>> {
785        if let Some(accounts_config) = accounts::AccountsConfig::load()? {
786            Ok(accounts_config
787                .list_accounts()
788                .into_iter()
789                .cloned()
790                .collect())
791        } else {
792            Ok(Vec::new())
793        }
794    }
795
796    /// Set an account as the default (active) account
797    pub fn set_default_account(&mut self, alias: &str) -> Result<()> {
798        let mut accounts_config = accounts::AccountsConfig::load()?.unwrap_or_default();
799        accounts_config.set_active_account(alias)?;
800        accounts_config.save()?;
801        Ok(())
802    }
803
804    /// Remove an account
805    pub fn remove_account(&mut self, alias: &str) -> Result<()> {
806        let mut accounts_config = accounts::AccountsConfig::load()?.unwrap_or_default();
807        if accounts_config.remove_account(alias) {
808            accounts_config.save()?;
809        }
810        Ok(())
811    }
812}