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