Skip to main content

auto_commit_rs/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6pub struct FieldSubgroup {
7    pub name: &'static str,
8    pub fields: Vec<(&'static str, &'static str, String)>,
9}
10
11pub struct FieldGroup {
12    pub name: &'static str,
13    pub fields: Vec<(&'static str, &'static str, String)>,
14    pub subgroups: Vec<FieldSubgroup>,
15}
16
17const DEFAULT_SYSTEM_PROMPT: &str = "You are to act as an author of a commit message in git.
18Your mission is to create clean and comprehensive commit messages as per
19the Conventional Commit specification and explain WHAT were the changes and mainly WHY the changes were done.
20I'll send you an output of 'git diff --staged' command, and you are to convert
21it into a commit message. Use the present tense. Use english for the commit message.";
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct AppConfig {
25    #[serde(default = "default_provider")]
26    pub provider: String,
27    #[serde(default = "default_model")]
28    pub model: String,
29    #[serde(default)]
30    pub api_key: String,
31    #[serde(default)]
32    pub api_url: String,
33    #[serde(default)]
34    pub api_headers: String,
35    #[serde(default = "default_locale")]
36    pub locale: String,
37    #[serde(default = "default_true")]
38    pub one_liner: bool,
39    #[serde(default = "default_commit_template")]
40    pub commit_template: String,
41    #[serde(default = "default_system_prompt")]
42    pub llm_system_prompt: String,
43    #[serde(default)]
44    pub use_gitmoji: bool,
45    #[serde(default = "default_gitmoji_format")]
46    pub gitmoji_format: String,
47    #[serde(default)]
48    pub review_commit: bool,
49    #[serde(default = "default_post_commit_push")]
50    pub post_commit_push: String,
51    #[serde(default)]
52    pub suppress_tool_output: bool,
53    #[serde(default = "default_true")]
54    pub warn_staged_files_enabled: bool,
55    #[serde(default = "default_warn_staged_files_threshold")]
56    pub warn_staged_files_threshold: usize,
57    #[serde(default = "default_true")]
58    pub confirm_new_version: bool,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub auto_update: Option<bool>,
61    #[serde(default = "default_true")]
62    pub fallback_enabled: bool,
63    #[serde(default = "default_true")]
64    pub track_generated_commits: bool,
65    #[serde(default = "default_diff_exclude_globs")]
66    pub diff_exclude_globs: Vec<String>,
67}
68
69fn default_provider() -> String {
70    "groq".into()
71}
72fn default_model() -> String {
73    "llama-3.3-70b-versatile".into()
74}
75fn default_locale() -> String {
76    "en".into()
77}
78pub fn default_true() -> bool {
79    true
80}
81fn default_post_commit_push() -> String {
82    "ask".into()
83}
84fn default_commit_template() -> String {
85    "$msg".into()
86}
87fn default_system_prompt() -> String {
88    DEFAULT_SYSTEM_PROMPT.into()
89}
90fn default_gitmoji_format() -> String {
91    "unicode".into()
92}
93fn default_warn_staged_files_threshold() -> usize {
94    20
95}
96fn default_diff_exclude_globs() -> Vec<String> {
97    vec![
98        "*.json",
99        "*.xml",
100        "*.csv",
101        "*.pdf",
102        "*.lock",
103        "*.svg",
104        "*.png",
105        "*.jpg",
106        "*.jpeg",
107        "*.gif",
108        "*.ico",
109        "*.woff",
110        "*.woff2",
111        "*.ttf",
112        "*.eot",
113        "*.min.js",
114        "*.min.css",
115    ]
116    .into_iter()
117    .map(String::from)
118    .collect()
119}
120
121impl Default for AppConfig {
122    fn default() -> Self {
123        Self {
124            provider: default_provider(),
125            model: default_model(),
126            api_key: String::new(),
127            api_url: String::new(),
128            api_headers: String::new(),
129            locale: default_locale(),
130            one_liner: true,
131            commit_template: default_commit_template(),
132            llm_system_prompt: default_system_prompt(),
133            use_gitmoji: false,
134            gitmoji_format: default_gitmoji_format(),
135            review_commit: true,
136            post_commit_push: default_post_commit_push(),
137            suppress_tool_output: false,
138            warn_staged_files_enabled: true,
139            warn_staged_files_threshold: default_warn_staged_files_threshold(),
140            confirm_new_version: true,
141            auto_update: None,
142            fallback_enabled: true,
143            track_generated_commits: true,
144            diff_exclude_globs: default_diff_exclude_globs(),
145        }
146    }
147}
148
149/// Map of ACR_ env var suffix → struct field name
150const ENV_FIELD_MAP: &[(&str, &str)] = &[
151    ("PROVIDER", "provider"),
152    ("MODEL", "model"),
153    ("API_KEY", "api_key"),
154    ("API_URL", "api_url"),
155    ("API_HEADERS", "api_headers"),
156    ("LOCALE", "locale"),
157    ("ONE_LINER", "one_liner"),
158    ("COMMIT_TEMPLATE", "commit_template"),
159    ("LLM_SYSTEM_PROMPT", "llm_system_prompt"),
160    ("USE_GITMOJI", "use_gitmoji"),
161    ("GITMOJI_FORMAT", "gitmoji_format"),
162    ("REVIEW_COMMIT", "review_commit"),
163    ("POST_COMMIT_PUSH", "post_commit_push"),
164    ("SUPPRESS_TOOL_OUTPUT", "suppress_tool_output"),
165    ("WARN_STAGED_FILES_ENABLED", "warn_staged_files_enabled"),
166    ("WARN_STAGED_FILES_THRESHOLD", "warn_staged_files_threshold"),
167    ("CONFIRM_NEW_VERSION", "confirm_new_version"),
168    ("AUTO_UPDATE", "auto_update"),
169    ("FALLBACK_ENABLED", "fallback_enabled"),
170    ("TRACK_GENERATED_COMMITS", "track_generated_commits"),
171    ("DIFF_EXCLUDE_GLOBS", "diff_exclude_globs"),
172];
173
174impl AppConfig {
175    /// Load config with layered resolution: defaults → global TOML → local .env → env vars
176    pub fn load() -> Result<Self> {
177        let mut cfg = Self::default();
178
179        // Layer 1: Global TOML
180        if let Some(path) = global_config_path() {
181            if path.exists() {
182                let content = std::fs::read_to_string(&path)
183                    .with_context(|| format!("Failed to read {}", path.display()))?;
184                let file_cfg: AppConfig = toml::from_str(&content)
185                    .with_context(|| format!("Failed to parse {}", path.display()))?;
186                cfg.merge_from(&file_cfg);
187            }
188        }
189
190        // Layer 2: Local .env (in git repo root)
191        if let Ok(root) = crate::git::find_repo_root() {
192            let env_path = PathBuf::from(&root).join(".env");
193            if env_path.exists() {
194                let env_map = parse_dotenv(&env_path)?;
195                cfg.apply_env_map(&env_map, true);
196            }
197        }
198
199        // Layer 3: Actual environment variables
200        let mut env_map = HashMap::new();
201        for (suffix, _) in ENV_FIELD_MAP {
202            let key = format!("ACR_{suffix}");
203            if let Ok(val) = std::env::var(&key) {
204                env_map.insert(key, val);
205            }
206        }
207        cfg.apply_env_map(&env_map, false);
208        cfg.ensure_valid_locale()?;
209
210        Ok(cfg)
211    }
212
213    fn merge_from(&mut self, other: &AppConfig) {
214        if !other.provider.is_empty() {
215            self.provider = other.provider.clone();
216        }
217        if !other.model.is_empty() {
218            self.model = other.model.clone();
219        }
220        if !other.api_key.is_empty() {
221            self.api_key = other.api_key.clone();
222        }
223        if !other.api_url.is_empty() {
224            self.api_url = other.api_url.clone();
225        }
226        if !other.api_headers.is_empty() {
227            self.api_headers = other.api_headers.clone();
228        }
229        if !other.locale.is_empty() {
230            self.locale = other.locale.clone();
231        }
232        self.one_liner = other.one_liner;
233        if !other.commit_template.is_empty() {
234            self.commit_template = other.commit_template.clone();
235        }
236        if !other.llm_system_prompt.is_empty() {
237            self.llm_system_prompt = other.llm_system_prompt.clone();
238        }
239        self.use_gitmoji = other.use_gitmoji;
240        if !other.gitmoji_format.is_empty() {
241            self.gitmoji_format = other.gitmoji_format.clone();
242        }
243        self.review_commit = other.review_commit;
244        if !other.post_commit_push.is_empty() {
245            self.post_commit_push = normalize_post_commit_push(&other.post_commit_push);
246        }
247        self.suppress_tool_output = other.suppress_tool_output;
248        self.warn_staged_files_enabled = other.warn_staged_files_enabled;
249        self.warn_staged_files_threshold = other.warn_staged_files_threshold;
250        self.confirm_new_version = other.confirm_new_version;
251        if other.auto_update.is_some() {
252            self.auto_update = other.auto_update;
253        }
254        self.fallback_enabled = other.fallback_enabled;
255        self.track_generated_commits = other.track_generated_commits;
256        if !other.diff_exclude_globs.is_empty() {
257            self.diff_exclude_globs = other.diff_exclude_globs.clone();
258        }
259    }
260
261    fn apply_env_map(&mut self, map: &HashMap<String, String>, from_local: bool) {
262        for (suffix, _field) in ENV_FIELD_MAP {
263            let key = format!("ACR_{suffix}");
264            if let Some(val) = map.get(&key) {
265                match *suffix {
266                    "PROVIDER" => self.provider = val.clone(),
267                    "MODEL" => self.model = val.clone(),
268                    "API_KEY" => self.api_key = val.clone(),
269                    "API_URL" => self.api_url = val.clone(),
270                    "API_HEADERS" => self.api_headers = val.clone(),
271                    "LOCALE" => self.locale = val.clone(),
272                    "ONE_LINER" => self.one_liner = val == "1" || val.eq_ignore_ascii_case("true"),
273                    "COMMIT_TEMPLATE" => self.commit_template = val.clone(),
274                    "LLM_SYSTEM_PROMPT" => self.llm_system_prompt = val.clone(),
275                    "USE_GITMOJI" => {
276                        self.use_gitmoji = val == "1" || val.eq_ignore_ascii_case("true")
277                    }
278                    "GITMOJI_FORMAT" => self.gitmoji_format = val.clone(),
279                    "REVIEW_COMMIT" => {
280                        self.review_commit = val == "1" || val.eq_ignore_ascii_case("true")
281                    }
282                    "POST_COMMIT_PUSH" => self.post_commit_push = normalize_post_commit_push(val),
283                    "SUPPRESS_TOOL_OUTPUT" => {
284                        self.suppress_tool_output = val == "1" || val.eq_ignore_ascii_case("true")
285                    }
286                    "WARN_STAGED_FILES_ENABLED" => {
287                        self.warn_staged_files_enabled =
288                            val == "1" || val.eq_ignore_ascii_case("true")
289                    }
290                    "WARN_STAGED_FILES_THRESHOLD" => {
291                        self.warn_staged_files_threshold =
292                            parse_usize_or_default(val, default_warn_staged_files_threshold());
293                    }
294                    "CONFIRM_NEW_VERSION" => {
295                        self.confirm_new_version = val == "1" || val.eq_ignore_ascii_case("true")
296                    }
297                    "AUTO_UPDATE" => {
298                        // auto_update is global-only; skip when reading from local .env
299                        if !from_local {
300                            self.auto_update = Some(val == "1" || val.eq_ignore_ascii_case("true"));
301                        }
302                    }
303                    "FALLBACK_ENABLED" => {
304                        self.fallback_enabled = val == "1" || val.eq_ignore_ascii_case("true");
305                    }
306                    "TRACK_GENERATED_COMMITS" => {
307                        self.track_generated_commits =
308                            val == "1" || val.eq_ignore_ascii_case("true");
309                    }
310                    "DIFF_EXCLUDE_GLOBS" => {
311                        self.diff_exclude_globs = val
312                            .split(',')
313                            .map(|s| s.trim().to_string())
314                            .filter(|s| !s.is_empty())
315                            .collect();
316                    }
317                    _ => {}
318                }
319            }
320        }
321    }
322
323    /// Save to global TOML config file
324    pub fn save_global(&self) -> Result<()> {
325        let path = global_config_path().context("Could not determine global config directory")?;
326        if let Some(parent) = path.parent() {
327            std::fs::create_dir_all(parent)
328                .with_context(|| format!("Failed to create {}", parent.display()))?;
329        }
330        let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
331        std::fs::write(&path, content)
332            .with_context(|| format!("Failed to write {}", path.display()))?;
333        Ok(())
334    }
335
336    /// Save to local .env file in the git repo root
337    pub fn save_local(&self) -> Result<()> {
338        let root = crate::git::find_repo_root().context("Not in a git repository")?;
339        let env_path = PathBuf::from(&root).join(".env");
340
341        let mut lines = Vec::new();
342        lines.push(format!("ACR_PROVIDER={}", self.provider));
343        lines.push(format!("ACR_MODEL={}", self.model));
344        if !self.api_key.is_empty() {
345            lines.push(format!("ACR_API_KEY={}", self.api_key));
346        }
347        if !self.api_url.is_empty() {
348            lines.push(format!("ACR_API_URL={}", self.api_url));
349        }
350        if !self.api_headers.is_empty() {
351            lines.push(format!("ACR_API_HEADERS={}", self.api_headers));
352        }
353        lines.push(format!("ACR_LOCALE={}", self.locale));
354        lines.push(format!(
355            "ACR_ONE_LINER={}",
356            if self.one_liner { "1" } else { "0" }
357        ));
358        if self.commit_template != "$msg" {
359            lines.push(format!("ACR_COMMIT_TEMPLATE={}", self.commit_template));
360        }
361        if self.llm_system_prompt != DEFAULT_SYSTEM_PROMPT {
362            lines.push(format!("ACR_LLM_SYSTEM_PROMPT={}", self.llm_system_prompt));
363        }
364        lines.push(format!(
365            "ACR_USE_GITMOJI={}",
366            if self.use_gitmoji { "1" } else { "0" }
367        ));
368        lines.push(format!("ACR_GITMOJI_FORMAT={}", self.gitmoji_format));
369        lines.push(format!(
370            "ACR_REVIEW_COMMIT={}",
371            if self.review_commit { "1" } else { "0" }
372        ));
373        lines.push(format!(
374            "ACR_POST_COMMIT_PUSH={}",
375            normalize_post_commit_push(&self.post_commit_push)
376        ));
377        lines.push(format!(
378            "ACR_SUPPRESS_TOOL_OUTPUT={}",
379            if self.suppress_tool_output { "1" } else { "0" }
380        ));
381        lines.push(format!(
382            "ACR_WARN_STAGED_FILES_ENABLED={}",
383            if self.warn_staged_files_enabled {
384                "1"
385            } else {
386                "0"
387            }
388        ));
389        lines.push(format!(
390            "ACR_WARN_STAGED_FILES_THRESHOLD={}",
391            self.warn_staged_files_threshold
392        ));
393        lines.push(format!(
394            "ACR_CONFIRM_NEW_VERSION={}",
395            if self.confirm_new_version { "1" } else { "0" }
396        ));
397        // auto_update is global-only, not written to local .env
398        lines.push(format!(
399            "ACR_FALLBACK_ENABLED={}",
400            if self.fallback_enabled { "1" } else { "0" }
401        ));
402        lines.push(format!(
403            "ACR_TRACK_GENERATED_COMMITS={}",
404            if self.track_generated_commits {
405                "1"
406            } else {
407                "0"
408            }
409        ));
410        if !self.diff_exclude_globs.is_empty() {
411            lines.push(format!(
412                "ACR_DIFF_EXCLUDE_GLOBS={}",
413                self.diff_exclude_globs.join(",")
414            ));
415        }
416
417        std::fs::write(&env_path, lines.join("\n") + "\n")
418            .with_context(|| format!("Failed to write {}", env_path.display()))?;
419        Ok(())
420    }
421
422    /// Get all fields as (display_name, env_suffix, current_value) tuples
423    pub fn fields_display(&self) -> Vec<(&'static str, &'static str, String)> {
424        vec![
425            ("Provider", "PROVIDER", self.provider.clone()),
426            ("Model", "MODEL", self.model.clone()),
427            (
428                "API Key",
429                "API_KEY",
430                if self.api_key.is_empty() {
431                    "(not set)".into()
432                } else {
433                    mask_key(&self.api_key)
434                },
435            ),
436            (
437                "API URL",
438                "API_URL",
439                if self.api_url.is_empty() {
440                    "(auto from provider)".into()
441                } else {
442                    self.api_url.clone()
443                },
444            ),
445            (
446                "API Headers",
447                "API_HEADERS",
448                if self.api_headers.is_empty() {
449                    "(auto from provider)".into()
450                } else {
451                    self.api_headers.clone()
452                },
453            ),
454            ("Locale", "LOCALE", self.locale.clone()),
455            (
456                "One-liner",
457                "ONE_LINER",
458                if self.one_liner {
459                    "enabled".into()
460                } else {
461                    "disabled".into()
462                },
463            ),
464            (
465                "Commit Template",
466                "COMMIT_TEMPLATE",
467                self.commit_template.clone(),
468            ),
469            (
470                "System Prompt",
471                "LLM_SYSTEM_PROMPT",
472                truncate(&self.llm_system_prompt, 60),
473            ),
474            (
475                "Use Gitmoji",
476                "USE_GITMOJI",
477                if self.use_gitmoji {
478                    "enabled".into()
479                } else {
480                    "disabled".into()
481                },
482            ),
483            (
484                "Gitmoji Format",
485                "GITMOJI_FORMAT",
486                self.gitmoji_format.clone(),
487            ),
488            (
489                "Review Commit",
490                "REVIEW_COMMIT",
491                if self.review_commit {
492                    "enabled".into()
493                } else {
494                    "disabled".into()
495                },
496            ),
497            (
498                "Post Commit Push",
499                "POST_COMMIT_PUSH",
500                normalize_post_commit_push(&self.post_commit_push),
501            ),
502            (
503                "Suppress Tool Output",
504                "SUPPRESS_TOOL_OUTPUT",
505                if self.suppress_tool_output {
506                    "enabled".into()
507                } else {
508                    "disabled".into()
509                },
510            ),
511            (
512                "Warn Staged Files",
513                "WARN_STAGED_FILES_ENABLED",
514                if self.warn_staged_files_enabled {
515                    "enabled".into()
516                } else {
517                    "disabled".into()
518                },
519            ),
520            (
521                "Staged Warn Threshold",
522                "WARN_STAGED_FILES_THRESHOLD",
523                self.warn_staged_files_threshold.to_string(),
524            ),
525            (
526                "Confirm New Version",
527                "CONFIRM_NEW_VERSION",
528                if self.confirm_new_version {
529                    "enabled".into()
530                } else {
531                    "disabled".into()
532                },
533            ),
534            (
535                "Auto Update",
536                "AUTO_UPDATE",
537                match self.auto_update {
538                    Some(true) => "enabled".into(),
539                    Some(false) => "disabled".into(),
540                    None => "(not set)".into(),
541                },
542            ),
543            (
544                "Fallback Enabled",
545                "FALLBACK_ENABLED",
546                if self.fallback_enabled {
547                    "enabled".into()
548                } else {
549                    "disabled".into()
550                },
551            ),
552            (
553                "Track Generated Commits",
554                "TRACK_GENERATED_COMMITS",
555                if self.track_generated_commits {
556                    "enabled".into()
557                } else {
558                    "disabled".into()
559                },
560            ),
561            (
562                "Diff Exclude Globs",
563                "DIFF_EXCLUDE_GLOBS",
564                if self.diff_exclude_globs.is_empty() {
565                    "(none)".into()
566                } else {
567                    self.diff_exclude_globs.join(", ")
568                },
569            ),
570        ]
571    }
572
573    /// Field groups for the interactive config UI
574    pub fn grouped_fields(&self) -> Vec<FieldGroup> {
575        let fields = self.fields_display();
576        let field_map: std::collections::HashMap<&str, (&'static str, String)> = fields
577            .iter()
578            .map(|(name, suffix, val)| (*suffix, (*name, val.clone())))
579            .collect();
580
581        let basic_keys: &[&'static str] = &["PROVIDER", "MODEL", "API_KEY", "API_URL"];
582        let llm_keys: &[&'static str] = &[
583            "API_HEADERS",
584            "LOCALE",
585            "LLM_SYSTEM_PROMPT",
586            "COMMIT_TEMPLATE",
587            "FALLBACK_ENABLED",
588            "DIFF_EXCLUDE_GLOBS",
589        ];
590        let commit_keys: &[&'static str] = &[
591            "ONE_LINER",
592            "USE_GITMOJI",
593            "GITMOJI_FORMAT",
594            "REVIEW_COMMIT",
595            "TRACK_GENERATED_COMMITS",
596        ];
597        let post_commit_keys: &[&'static str] = &["POST_COMMIT_PUSH", "SUPPRESS_TOOL_OUTPUT"];
598        let warnings_keys: &[&'static str] = &[
599            "WARN_STAGED_FILES_ENABLED",
600            "WARN_STAGED_FILES_THRESHOLD",
601            "CONFIRM_NEW_VERSION",
602            "AUTO_UPDATE",
603        ];
604
605        let collect = |keys: &[&'static str]| -> Vec<(&'static str, &'static str, String)> {
606            keys.iter()
607                .filter_map(|k| field_map.get(k).map(|(name, val)| (*name, *k, val.clone())))
608                .collect()
609        };
610
611        vec![
612            FieldGroup {
613                name: "Basic",
614                fields: collect(basic_keys),
615                subgroups: vec![],
616            },
617            FieldGroup {
618                name: "Advanced",
619                fields: vec![],
620                subgroups: vec![
621                    FieldSubgroup {
622                        name: "LLM Settings",
623                        fields: collect(llm_keys),
624                    },
625                    FieldSubgroup {
626                        name: "Commit Behavior",
627                        fields: collect(commit_keys),
628                    },
629                    FieldSubgroup {
630                        name: "Post-Commit",
631                        fields: collect(post_commit_keys),
632                    },
633                    FieldSubgroup {
634                        name: "Warnings & Updates",
635                        fields: collect(warnings_keys),
636                    },
637                ],
638            },
639        ]
640    }
641
642    /// Set a field by its env suffix
643    pub fn set_field(&mut self, suffix: &str, value: &str) -> Result<()> {
644        match suffix {
645            "PROVIDER" => self.provider = value.into(),
646            "MODEL" => self.model = value.into(),
647            "API_KEY" => self.api_key = value.into(),
648            "API_URL" => self.api_url = value.into(),
649            "API_HEADERS" => self.api_headers = value.into(),
650            "LOCALE" => {
651                let locale = normalize_locale(value);
652                validate_locale(&locale)?;
653                self.locale = locale;
654            }
655            "ONE_LINER" => self.one_liner = value == "1" || value.eq_ignore_ascii_case("true"),
656            "COMMIT_TEMPLATE" => self.commit_template = value.into(),
657            "LLM_SYSTEM_PROMPT" => self.llm_system_prompt = value.into(),
658            "USE_GITMOJI" => self.use_gitmoji = value == "1" || value.eq_ignore_ascii_case("true"),
659            "GITMOJI_FORMAT" => self.gitmoji_format = value.into(),
660            "REVIEW_COMMIT" => {
661                self.review_commit = value == "1" || value.eq_ignore_ascii_case("true")
662            }
663            "POST_COMMIT_PUSH" => self.post_commit_push = normalize_post_commit_push(value),
664            "SUPPRESS_TOOL_OUTPUT" => {
665                self.suppress_tool_output = value == "1" || value.eq_ignore_ascii_case("true")
666            }
667            "WARN_STAGED_FILES_ENABLED" => {
668                self.warn_staged_files_enabled = value == "1" || value.eq_ignore_ascii_case("true");
669            }
670            "WARN_STAGED_FILES_THRESHOLD" => {
671                self.warn_staged_files_threshold =
672                    parse_usize_or_default(value, default_warn_staged_files_threshold());
673            }
674            "CONFIRM_NEW_VERSION" => {
675                self.confirm_new_version = value == "1" || value.eq_ignore_ascii_case("true");
676            }
677            "AUTO_UPDATE" => {
678                self.auto_update = Some(value == "1" || value.eq_ignore_ascii_case("true"));
679            }
680            "FALLBACK_ENABLED" => {
681                self.fallback_enabled = value == "1" || value.eq_ignore_ascii_case("true");
682            }
683            "TRACK_GENERATED_COMMITS" => {
684                self.track_generated_commits = value == "1" || value.eq_ignore_ascii_case("true");
685            }
686            "DIFF_EXCLUDE_GLOBS" => {
687                self.diff_exclude_globs = value
688                    .split(',')
689                    .map(|s| s.trim().to_string())
690                    .filter(|s| !s.is_empty())
691                    .collect();
692            }
693            _ => {}
694        }
695        Ok(())
696    }
697
698    fn ensure_valid_locale(&mut self) -> Result<()> {
699        self.locale = normalize_locale(&self.locale);
700        validate_locale(&self.locale)
701    }
702}
703
704/// Global config file path
705pub fn global_config_path() -> Option<PathBuf> {
706    if let Some(override_dir) = std::env::var_os("ACR_CONFIG_HOME") {
707        let override_path = PathBuf::from(override_dir);
708        if !override_path.as_os_str().is_empty() {
709            return Some(override_path.join("cgen").join("config.toml"));
710        }
711    }
712    dirs::config_dir().map(|d| d.join("cgen").join("config.toml"))
713}
714
715/// Save only the auto_update preference to global config without overwriting other fields
716pub fn save_auto_update_preference(value: bool) -> Result<()> {
717    let path = global_config_path().context("Could not determine global config directory")?;
718
719    let mut table: toml::Table = if path.exists() {
720        let content = std::fs::read_to_string(&path)
721            .with_context(|| format!("Failed to read {}", path.display()))?;
722        content.parse().unwrap_or_default()
723    } else {
724        toml::Table::new()
725    };
726
727    table.insert("auto_update".to_string(), toml::Value::Boolean(value));
728
729    if let Some(parent) = path.parent() {
730        std::fs::create_dir_all(parent)
731            .with_context(|| format!("Failed to create {}", parent.display()))?;
732    }
733
734    let content = toml::to_string_pretty(&table).context("Failed to serialize config")?;
735    std::fs::write(&path, content)
736        .with_context(|| format!("Failed to write {}", path.display()))?;
737    Ok(())
738}
739
740fn mask_key(key: &str) -> String {
741    if key.len() <= 8 {
742        "*".repeat(key.len())
743    } else {
744        format!("{}...{}", &key[..4], &key[key.len() - 4..])
745    }
746}
747
748fn truncate(s: &str, max: usize) -> String {
749    if s.len() <= max {
750        s.to_string()
751    } else {
752        format!("{}...", &s[..max])
753    }
754}
755
756fn normalize_post_commit_push(value: &str) -> String {
757    match value.trim().to_ascii_lowercase().as_str() {
758        "never" => "never".into(),
759        "always" => "always".into(),
760        _ => "ask".into(),
761    }
762}
763
764fn parse_usize_or_default(value: &str, default: usize) -> usize {
765    value.trim().parse::<usize>().unwrap_or(default)
766}
767
768fn normalize_locale(value: &str) -> String {
769    let normalized = value.trim();
770    if normalized.is_empty() {
771        default_locale()
772    } else {
773        normalized.to_ascii_lowercase()
774    }
775}
776
777fn validate_locale(locale: &str) -> Result<()> {
778    if locale == "en" || locale_has_i18n(locale) {
779        return Ok(());
780    }
781    anyhow::bail!(
782        "Unsupported locale '{}'. Only 'en' is available unless matching i18n resources exist. Set locale with `cgen config` or add i18n files first.",
783        locale
784    );
785}
786
787fn locale_has_i18n(locale: &str) -> bool {
788    locale_i18n_dirs()
789        .iter()
790        .any(|dir| locale_exists_in_i18n_dir(dir, locale))
791}
792
793fn locale_i18n_dirs() -> Vec<PathBuf> {
794    let mut dirs = Vec::new();
795    if let Ok(repo_root) = crate::git::find_repo_root() {
796        dirs.push(PathBuf::from(repo_root).join("i18n"));
797    }
798    if let Ok(current_dir) = std::env::current_dir() {
799        let i18n_dir = current_dir.join("i18n");
800        if !dirs.contains(&i18n_dir) {
801            dirs.push(i18n_dir);
802        }
803    }
804    dirs
805}
806
807fn locale_exists_in_i18n_dir(i18n_dir: &PathBuf, locale: &str) -> bool {
808    if !i18n_dir.exists() {
809        return false;
810    }
811    if i18n_dir.join(locale).is_dir() {
812        return true;
813    }
814
815    let entries = match std::fs::read_dir(i18n_dir) {
816        Ok(entries) => entries,
817        Err(_) => return false,
818    };
819
820    entries.filter_map(|entry| entry.ok()).any(|entry| {
821        let path = entry.path();
822        if path.is_file() {
823            return path
824                .file_stem()
825                .and_then(|stem| stem.to_str())
826                .map(|stem| stem.eq_ignore_ascii_case(locale))
827                .unwrap_or(false);
828        }
829        false
830    })
831}
832
833/// Get description for a field by its env suffix
834pub fn field_description(suffix: &str) -> &'static str {
835    match suffix {
836        "PROVIDER" => "LLM provider (gemini, openai, anthropic, groq, grok, deepseek, openrouter, mistral, together, fireworks, perplexity, or custom)",
837        "MODEL" => "Model identifier for the selected provider",
838        "API_KEY" => "API key for authenticating with the LLM provider",
839        "API_URL" => "Custom API endpoint URL (leave empty to use provider default)",
840        "API_HEADERS" => "Additional HTTP headers for API requests (JSON format)",
841        "LOCALE" => "Language locale for commit messages (e.g., en, pt-br)",
842        "ONE_LINER" => "Generate single-line commit messages when enabled",
843        "COMMIT_TEMPLATE" => "Template for commit message ($msg is replaced with generated text)",
844        "LLM_SYSTEM_PROMPT" => "System prompt sent to the LLM for context",
845        "USE_GITMOJI" => "Prepend gitmoji to commit messages when enabled",
846        "GITMOJI_FORMAT" => "Gitmoji style: unicode (🎨) or shortcode (:art:)",
847        "REVIEW_COMMIT" => "Review and approve commit message before creating commit",
848        "POST_COMMIT_PUSH" => "Push behavior after commit: ask, always, or never",
849        "SUPPRESS_TOOL_OUTPUT" => "Hide git command output when enabled",
850        "WARN_STAGED_FILES_ENABLED" => "Warn when staged file count exceeds threshold",
851        "WARN_STAGED_FILES_THRESHOLD" => "Number of staged files before warning is shown",
852        "CONFIRM_NEW_VERSION" => "Ask for confirmation before creating version tags",
853        "AUTO_UPDATE" => "Automatically update cgen when new versions are available",
854        "FALLBACK_ENABLED" => "Try fallback presets if primary LLM call fails",
855        "TRACK_GENERATED_COMMITS" => "Track commits generated by cgen for history view",
856        "DIFF_EXCLUDE_GLOBS" => "Comma-separated glob patterns for files to exclude from LLM diff analysis (e.g., *.json,*.lock)",
857        _ => "",
858    }
859}
860
861fn parse_dotenv(path: &PathBuf) -> Result<HashMap<String, String>> {
862    let content = std::fs::read_to_string(path)
863        .with_context(|| format!("Failed to read {}", path.display()))?;
864    let mut map = HashMap::new();
865    for line in content.lines() {
866        let line = line.trim();
867        if line.is_empty() || line.starts_with('#') {
868            continue;
869        }
870        if let Some((key, val)) = line.split_once('=') {
871            let key = key.trim().to_string();
872            let val = val.trim().trim_matches('"').trim_matches('\'').to_string();
873            map.insert(key, val);
874        }
875    }
876    Ok(map)
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use std::io::Write;
883    use tempfile::NamedTempFile;
884
885    #[test]
886    fn test_mask_key_short() {
887        assert_eq!(mask_key("abc"), "***");
888        assert_eq!(mask_key("12345678"), "********");
889    }
890
891    #[test]
892    fn test_mask_key_long() {
893        assert_eq!(mask_key("abcdefghij"), "abcd...ghij");
894        assert_eq!(mask_key("sk-1234567890abcdef"), "sk-1...cdef");
895    }
896
897    #[test]
898    fn test_truncate_short() {
899        assert_eq!(truncate("hello", 10), "hello");
900        assert_eq!(truncate("exact", 5), "exact");
901    }
902
903    #[test]
904    fn test_truncate_long() {
905        assert_eq!(truncate("hello world", 5), "hello...");
906        assert_eq!(truncate("abcdefghij", 3), "abc...");
907    }
908
909    #[test]
910    fn test_normalize_post_commit_push() {
911        assert_eq!(normalize_post_commit_push("never"), "never");
912        assert_eq!(normalize_post_commit_push("NEVER"), "never");
913        assert_eq!(normalize_post_commit_push("  Never  "), "never");
914        assert_eq!(normalize_post_commit_push("always"), "always");
915        assert_eq!(normalize_post_commit_push("ALWAYS"), "always");
916        assert_eq!(normalize_post_commit_push("ask"), "ask");
917        assert_eq!(normalize_post_commit_push("unknown"), "ask");
918        assert_eq!(normalize_post_commit_push(""), "ask");
919    }
920
921    #[test]
922    fn test_parse_usize_or_default() {
923        assert_eq!(parse_usize_or_default("10", 5), 10);
924        assert_eq!(parse_usize_or_default("  20  ", 5), 20);
925        assert_eq!(parse_usize_or_default("invalid", 5), 5);
926        assert_eq!(parse_usize_or_default("", 5), 5);
927        assert_eq!(parse_usize_or_default("-1", 5), 5); // negative not valid for usize
928    }
929
930    #[test]
931    fn test_normalize_locale() {
932        assert_eq!(normalize_locale("EN"), "en");
933        assert_eq!(normalize_locale("  pt-BR  "), "pt-br");
934        assert_eq!(normalize_locale(""), "en");
935        assert_eq!(normalize_locale("   "), "en");
936    }
937
938    #[test]
939    fn test_default_functions() {
940        assert_eq!(default_provider(), "groq");
941        assert_eq!(default_model(), "llama-3.3-70b-versatile");
942        assert_eq!(default_locale(), "en");
943        assert!(default_true());
944        assert_eq!(default_post_commit_push(), "ask");
945        assert_eq!(default_commit_template(), "$msg");
946        assert_eq!(default_gitmoji_format(), "unicode");
947        assert_eq!(default_warn_staged_files_threshold(), 20);
948    }
949
950    #[test]
951    fn test_default_diff_exclude_globs() {
952        let globs = default_diff_exclude_globs();
953        assert!(globs.contains(&"*.json".to_string()));
954        assert!(globs.contains(&"*.lock".to_string()));
955        assert!(globs.contains(&"*.png".to_string()));
956    }
957
958    #[test]
959    fn test_parse_dotenv_basic() {
960        let mut file = NamedTempFile::new().unwrap();
961        writeln!(file, "FOO=bar").unwrap();
962        writeln!(file, "BAZ=qux").unwrap();
963        let map = parse_dotenv(&file.path().to_path_buf()).unwrap();
964        assert_eq!(map.get("FOO"), Some(&"bar".to_string()));
965        assert_eq!(map.get("BAZ"), Some(&"qux".to_string()));
966    }
967
968    #[test]
969    fn test_parse_dotenv_with_quotes() {
970        let mut file = NamedTempFile::new().unwrap();
971        writeln!(file, "DOUBLE=\"value with spaces\"").unwrap();
972        writeln!(file, "SINGLE='another value'").unwrap();
973        let map = parse_dotenv(&file.path().to_path_buf()).unwrap();
974        assert_eq!(map.get("DOUBLE"), Some(&"value with spaces".to_string()));
975        assert_eq!(map.get("SINGLE"), Some(&"another value".to_string()));
976    }
977
978    #[test]
979    fn test_parse_dotenv_skips_comments() {
980        let mut file = NamedTempFile::new().unwrap();
981        writeln!(file, "# This is a comment").unwrap();
982        writeln!(file, "KEY=value").unwrap();
983        writeln!(file, "# Another comment").unwrap();
984        let map = parse_dotenv(&file.path().to_path_buf()).unwrap();
985        assert_eq!(map.len(), 1);
986        assert_eq!(map.get("KEY"), Some(&"value".to_string()));
987    }
988
989    #[test]
990    fn test_parse_dotenv_skips_empty_lines() {
991        let mut file = NamedTempFile::new().unwrap();
992        writeln!(file, "").unwrap();
993        writeln!(file, "KEY=value").unwrap();
994        writeln!(file, "   ").unwrap();
995        let map = parse_dotenv(&file.path().to_path_buf()).unwrap();
996        assert_eq!(map.len(), 1);
997    }
998
999    #[test]
1000    fn test_parse_dotenv_trims_whitespace() {
1001        let mut file = NamedTempFile::new().unwrap();
1002        writeln!(file, "  KEY  =  value  ").unwrap();
1003        let map = parse_dotenv(&file.path().to_path_buf()).unwrap();
1004        assert_eq!(map.get("KEY"), Some(&"value".to_string()));
1005    }
1006
1007    #[test]
1008    fn test_field_description_known() {
1009        assert!(!field_description("PROVIDER").is_empty());
1010        assert!(!field_description("MODEL").is_empty());
1011        assert!(!field_description("API_KEY").is_empty());
1012        assert!(!field_description("DIFF_EXCLUDE_GLOBS").is_empty());
1013    }
1014
1015    #[test]
1016    fn test_field_description_unknown() {
1017        assert_eq!(field_description("UNKNOWN_FIELD"), "");
1018    }
1019
1020    #[test]
1021    fn test_app_config_default() {
1022        let cfg = AppConfig::default();
1023        assert_eq!(cfg.provider, "groq");
1024        assert_eq!(cfg.model, "llama-3.3-70b-versatile");
1025        assert!(cfg.api_key.is_empty());
1026        assert!(cfg.one_liner);
1027        assert!(!cfg.use_gitmoji);
1028        assert!(cfg.fallback_enabled);
1029    }
1030
1031    #[test]
1032    fn test_app_config_fields_display() {
1033        let cfg = AppConfig::default();
1034        let fields = cfg.fields_display();
1035        assert!(!fields.is_empty());
1036
1037        // Check some expected fields
1038        let provider_field = fields.iter().find(|(name, _, _)| *name == "Provider");
1039        assert!(provider_field.is_some());
1040        assert_eq!(provider_field.unwrap().2, "groq");
1041    }
1042
1043    #[test]
1044    fn test_app_config_grouped_fields() {
1045        let cfg = AppConfig::default();
1046        let groups = cfg.grouped_fields();
1047
1048        assert_eq!(groups.len(), 2);
1049        assert_eq!(groups[0].name, "Basic");
1050        assert_eq!(groups[1].name, "Advanced");
1051
1052        // Basic group should have direct fields
1053        assert!(!groups[0].fields.is_empty());
1054
1055        // Advanced group should have subgroups
1056        assert!(!groups[1].subgroups.is_empty());
1057    }
1058
1059    #[test]
1060    fn test_app_config_set_field_string() {
1061        let mut cfg = AppConfig::default();
1062        cfg.set_field("PROVIDER", "openai").unwrap();
1063        assert_eq!(cfg.provider, "openai");
1064
1065        cfg.set_field("MODEL", "gpt-4").unwrap();
1066        assert_eq!(cfg.model, "gpt-4");
1067    }
1068
1069    #[test]
1070    fn test_app_config_set_field_bool() {
1071        let mut cfg = AppConfig::default();
1072
1073        cfg.set_field("ONE_LINER", "false").unwrap();
1074        assert!(!cfg.one_liner);
1075
1076        cfg.set_field("ONE_LINER", "true").unwrap();
1077        assert!(cfg.one_liner);
1078
1079        cfg.set_field("ONE_LINER", "1").unwrap();
1080        assert!(cfg.one_liner);
1081
1082        cfg.set_field("USE_GITMOJI", "TRUE").unwrap();
1083        assert!(cfg.use_gitmoji);
1084    }
1085
1086    #[test]
1087    fn test_app_config_set_field_usize() {
1088        let mut cfg = AppConfig::default();
1089        cfg.set_field("WARN_STAGED_FILES_THRESHOLD", "50").unwrap();
1090        assert_eq!(cfg.warn_staged_files_threshold, 50);
1091
1092        // Invalid falls back to default
1093        cfg.set_field("WARN_STAGED_FILES_THRESHOLD", "invalid")
1094            .unwrap();
1095        assert_eq!(cfg.warn_staged_files_threshold, 20);
1096    }
1097
1098    #[test]
1099    fn test_app_config_set_field_diff_globs() {
1100        let mut cfg = AppConfig::default();
1101        cfg.set_field("DIFF_EXCLUDE_GLOBS", "*.md, *.txt, *.log")
1102            .unwrap();
1103        assert_eq!(cfg.diff_exclude_globs, vec!["*.md", "*.txt", "*.log"]);
1104    }
1105
1106    #[test]
1107    fn test_app_config_set_field_post_commit_push() {
1108        let mut cfg = AppConfig::default();
1109        cfg.set_field("POST_COMMIT_PUSH", "always").unwrap();
1110        assert_eq!(cfg.post_commit_push, "always");
1111
1112        cfg.set_field("POST_COMMIT_PUSH", "NEVER").unwrap();
1113        assert_eq!(cfg.post_commit_push, "never");
1114
1115        cfg.set_field("POST_COMMIT_PUSH", "invalid").unwrap();
1116        assert_eq!(cfg.post_commit_push, "ask");
1117    }
1118
1119    #[test]
1120    fn test_app_config_set_field_auto_update() {
1121        let mut cfg = AppConfig::default();
1122        assert!(cfg.auto_update.is_none());
1123
1124        cfg.set_field("AUTO_UPDATE", "true").unwrap();
1125        assert_eq!(cfg.auto_update, Some(true));
1126
1127        cfg.set_field("AUTO_UPDATE", "false").unwrap();
1128        assert_eq!(cfg.auto_update, Some(false));
1129    }
1130
1131    #[test]
1132    fn test_app_config_merge_from() {
1133        let mut cfg = AppConfig::default();
1134        let other = AppConfig {
1135            provider: "openai".into(),
1136            model: "gpt-4".into(),
1137            one_liner: false,
1138            ..Default::default()
1139        };
1140
1141        cfg.merge_from(&other);
1142        assert_eq!(cfg.provider, "openai");
1143        assert_eq!(cfg.model, "gpt-4");
1144        assert!(!cfg.one_liner);
1145    }
1146
1147    #[test]
1148    fn test_app_config_merge_from_empty_strings_not_merged() {
1149        let mut cfg = AppConfig {
1150            provider: "groq".into(),
1151            api_key: "original-key".into(),
1152            ..Default::default()
1153        };
1154        let other = AppConfig {
1155            provider: "".into(), // Empty, should not override
1156            api_key: "".into(),  // Empty, should not override
1157            ..Default::default()
1158        };
1159
1160        cfg.merge_from(&other);
1161        assert_eq!(cfg.provider, "groq"); // Not changed
1162        assert_eq!(cfg.api_key, "original-key"); // Not changed
1163    }
1164
1165    #[test]
1166    fn test_validate_locale_en() {
1167        assert!(validate_locale("en").is_ok());
1168    }
1169
1170    #[test]
1171    fn test_validate_locale_invalid() {
1172        let result = validate_locale("xx-unknown");
1173        assert!(result.is_err());
1174        assert!(result
1175            .unwrap_err()
1176            .to_string()
1177            .contains("Unsupported locale"));
1178    }
1179
1180    #[test]
1181    fn test_env_field_map_coverage() {
1182        // Ensure all important fields are in the map
1183        let suffixes: Vec<&str> = ENV_FIELD_MAP.iter().map(|(s, _)| *s).collect();
1184        assert!(suffixes.contains(&"PROVIDER"));
1185        assert!(suffixes.contains(&"MODEL"));
1186        assert!(suffixes.contains(&"API_KEY"));
1187        assert!(suffixes.contains(&"DIFF_EXCLUDE_GLOBS"));
1188        assert!(suffixes.contains(&"FALLBACK_ENABLED"));
1189    }
1190
1191    #[test]
1192    fn test_apply_env_map_all_fields() {
1193        let mut cfg = AppConfig::default();
1194        let mut map = HashMap::new();
1195
1196        map.insert("ACR_PROVIDER".into(), "openai".into());
1197        map.insert("ACR_MODEL".into(), "gpt-4".into());
1198        map.insert("ACR_API_KEY".into(), "sk-test".into());
1199        map.insert("ACR_API_URL".into(), "https://custom.api".into());
1200        map.insert("ACR_API_HEADERS".into(), "X-Custom: value".into());
1201        map.insert("ACR_LOCALE".into(), "en".into());
1202        map.insert("ACR_ONE_LINER".into(), "false".into());
1203        map.insert("ACR_COMMIT_TEMPLATE".into(), "custom: $msg".into());
1204        map.insert("ACR_LLM_SYSTEM_PROMPT".into(), "custom prompt".into());
1205        map.insert("ACR_USE_GITMOJI".into(), "true".into());
1206        map.insert("ACR_GITMOJI_FORMAT".into(), "shortcode".into());
1207        map.insert("ACR_REVIEW_COMMIT".into(), "false".into());
1208        map.insert("ACR_POST_COMMIT_PUSH".into(), "always".into());
1209        map.insert("ACR_SUPPRESS_TOOL_OUTPUT".into(), "true".into());
1210        map.insert("ACR_WARN_STAGED_FILES_ENABLED".into(), "false".into());
1211        map.insert("ACR_WARN_STAGED_FILES_THRESHOLD".into(), "50".into());
1212        map.insert("ACR_CONFIRM_NEW_VERSION".into(), "false".into());
1213        map.insert("ACR_AUTO_UPDATE".into(), "true".into());
1214        map.insert("ACR_FALLBACK_ENABLED".into(), "false".into());
1215        map.insert("ACR_TRACK_GENERATED_COMMITS".into(), "false".into());
1216        map.insert("ACR_DIFF_EXCLUDE_GLOBS".into(), "*.md,*.txt".into());
1217
1218        cfg.apply_env_map(&map, false);
1219
1220        assert_eq!(cfg.provider, "openai");
1221        assert_eq!(cfg.model, "gpt-4");
1222        assert_eq!(cfg.api_key, "sk-test");
1223        assert_eq!(cfg.api_url, "https://custom.api");
1224        assert_eq!(cfg.api_headers, "X-Custom: value");
1225        assert!(!cfg.one_liner);
1226        assert_eq!(cfg.commit_template, "custom: $msg");
1227        assert_eq!(cfg.llm_system_prompt, "custom prompt");
1228        assert!(cfg.use_gitmoji);
1229        assert_eq!(cfg.gitmoji_format, "shortcode");
1230        assert!(!cfg.review_commit);
1231        assert_eq!(cfg.post_commit_push, "always");
1232        assert!(cfg.suppress_tool_output);
1233        assert!(!cfg.warn_staged_files_enabled);
1234        assert_eq!(cfg.warn_staged_files_threshold, 50);
1235        assert!(!cfg.confirm_new_version);
1236        assert_eq!(cfg.auto_update, Some(true));
1237        assert!(!cfg.fallback_enabled);
1238        assert!(!cfg.track_generated_commits);
1239        assert_eq!(cfg.diff_exclude_globs, vec!["*.md", "*.txt"]);
1240    }
1241
1242    #[test]
1243    fn test_apply_env_map_auto_update_skipped_for_local() {
1244        let mut cfg = AppConfig::default();
1245        let mut map = HashMap::new();
1246        map.insert("ACR_AUTO_UPDATE".into(), "true".into());
1247
1248        // from_local = true should skip auto_update
1249        cfg.apply_env_map(&map, true);
1250        assert!(cfg.auto_update.is_none());
1251
1252        // from_local = false should apply auto_update
1253        cfg.apply_env_map(&map, false);
1254        assert_eq!(cfg.auto_update, Some(true));
1255    }
1256
1257    #[test]
1258    fn test_apply_env_map_boolean_variations() {
1259        let mut cfg = AppConfig::default();
1260        let mut map = HashMap::new();
1261
1262        // Test "1" as true
1263        map.insert("ACR_USE_GITMOJI".into(), "1".into());
1264        cfg.apply_env_map(&map, false);
1265        assert!(cfg.use_gitmoji);
1266
1267        // Test "TRUE" (uppercase)
1268        map.clear();
1269        map.insert("ACR_REVIEW_COMMIT".into(), "TRUE".into());
1270        cfg.review_commit = false;
1271        cfg.apply_env_map(&map, false);
1272        assert!(cfg.review_commit);
1273    }
1274
1275    #[test]
1276    fn test_merge_from_with_all_fields() {
1277        let mut cfg = AppConfig::default();
1278        let other = AppConfig {
1279            provider: "anthropic".into(),
1280            model: "claude-3".into(),
1281            api_key: "sk-ant".into(),
1282            api_url: "https://api.anthropic.com".into(),
1283            api_headers: "x-api-key: test".into(),
1284            locale: "es".into(),
1285            one_liner: false,
1286            commit_template: "feat: $msg".into(),
1287            llm_system_prompt: "custom".into(),
1288            use_gitmoji: true,
1289            gitmoji_format: "shortcode".into(),
1290            review_commit: false,
1291            post_commit_push: "never".into(),
1292            suppress_tool_output: true,
1293            warn_staged_files_enabled: false,
1294            warn_staged_files_threshold: 100,
1295            confirm_new_version: false,
1296            auto_update: Some(true),
1297            fallback_enabled: false,
1298            track_generated_commits: false,
1299            diff_exclude_globs: vec!["*.log".into()],
1300        };
1301
1302        cfg.merge_from(&other);
1303
1304        assert_eq!(cfg.provider, "anthropic");
1305        assert_eq!(cfg.api_url, "https://api.anthropic.com");
1306        assert_eq!(cfg.api_headers, "x-api-key: test");
1307        assert_eq!(cfg.auto_update, Some(true));
1308    }
1309
1310    #[test]
1311    fn test_fields_display_with_custom_values() {
1312        let cfg = AppConfig {
1313            api_key: "short".into(), // Short key gets masked differently
1314            api_url: "https://custom.url".into(),
1315            api_headers: "X-Custom: value".into(),
1316            use_gitmoji: true,
1317            review_commit: false,
1318            suppress_tool_output: true,
1319            warn_staged_files_enabled: false,
1320            confirm_new_version: false,
1321            auto_update: Some(false),
1322            fallback_enabled: false,
1323            track_generated_commits: false,
1324            diff_exclude_globs: vec![],
1325            ..Default::default()
1326        };
1327
1328        let fields = cfg.fields_display();
1329
1330        // Find specific fields and check their values
1331        let api_url = fields.iter().find(|(n, _, _)| *n == "API URL").unwrap();
1332        assert_eq!(api_url.2, "https://custom.url");
1333
1334        let api_headers = fields.iter().find(|(n, _, _)| *n == "API Headers").unwrap();
1335        assert_eq!(api_headers.2, "X-Custom: value");
1336
1337        let gitmoji = fields.iter().find(|(n, _, _)| *n == "Use Gitmoji").unwrap();
1338        assert_eq!(gitmoji.2, "enabled");
1339
1340        let review = fields
1341            .iter()
1342            .find(|(n, _, _)| *n == "Review Commit")
1343            .unwrap();
1344        assert_eq!(review.2, "disabled");
1345
1346        let suppress = fields
1347            .iter()
1348            .find(|(n, _, _)| *n == "Suppress Tool Output")
1349            .unwrap();
1350        assert_eq!(suppress.2, "enabled");
1351
1352        let warn = fields
1353            .iter()
1354            .find(|(n, _, _)| *n == "Warn Staged Files")
1355            .unwrap();
1356        assert_eq!(warn.2, "disabled");
1357
1358        let confirm = fields
1359            .iter()
1360            .find(|(n, _, _)| *n == "Confirm New Version")
1361            .unwrap();
1362        assert_eq!(confirm.2, "disabled");
1363
1364        let auto = fields.iter().find(|(n, _, _)| *n == "Auto Update").unwrap();
1365        assert_eq!(auto.2, "disabled");
1366
1367        let fallback = fields
1368            .iter()
1369            .find(|(n, _, _)| *n == "Fallback Enabled")
1370            .unwrap();
1371        assert_eq!(fallback.2, "disabled");
1372
1373        let track = fields
1374            .iter()
1375            .find(|(n, _, _)| *n == "Track Generated Commits")
1376            .unwrap();
1377        assert_eq!(track.2, "disabled");
1378
1379        let globs = fields
1380            .iter()
1381            .find(|(n, _, _)| *n == "Diff Exclude Globs")
1382            .unwrap();
1383        assert_eq!(globs.2, "(none)");
1384    }
1385
1386    #[test]
1387    fn test_set_field_locale_validation() {
1388        let mut cfg = AppConfig::default();
1389        // Valid locale
1390        let result = cfg.set_field("LOCALE", "en");
1391        assert!(result.is_ok());
1392        assert_eq!(cfg.locale, "en");
1393    }
1394
1395    #[test]
1396    fn test_set_field_unknown_does_nothing() {
1397        let mut cfg = AppConfig::default();
1398        let original_provider = cfg.provider.clone();
1399        cfg.set_field("UNKNOWN_FIELD", "value").unwrap();
1400        assert_eq!(cfg.provider, original_provider);
1401    }
1402}