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. \
18I'll send you an output of 'git diff --staged' command, and you are to convert \
19it into a commit message. Follow the Conventional Commits specification.";
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct AppConfig {
23    #[serde(default = "default_provider")]
24    pub provider: String,
25    #[serde(default = "default_model")]
26    pub model: String,
27    #[serde(default)]
28    pub api_key: String,
29    #[serde(default)]
30    pub api_url: String,
31    #[serde(default)]
32    pub api_headers: String,
33    #[serde(default = "default_locale")]
34    pub locale: String,
35    #[serde(default = "default_true")]
36    pub one_liner: bool,
37    #[serde(default = "default_commit_template")]
38    pub commit_template: String,
39    #[serde(default = "default_system_prompt")]
40    pub llm_system_prompt: String,
41    #[serde(default)]
42    pub use_gitmoji: bool,
43    #[serde(default = "default_gitmoji_format")]
44    pub gitmoji_format: String,
45    #[serde(default)]
46    pub review_commit: bool,
47    #[serde(default = "default_post_commit_push")]
48    pub post_commit_push: String,
49    #[serde(default)]
50    pub suppress_tool_output: bool,
51    #[serde(default = "default_true")]
52    pub warn_staged_files_enabled: bool,
53    #[serde(default = "default_warn_staged_files_threshold")]
54    pub warn_staged_files_threshold: usize,
55    #[serde(default = "default_true")]
56    pub confirm_new_version: bool,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub auto_update: Option<bool>,
59}
60
61fn default_provider() -> String {
62    "groq".into()
63}
64fn default_model() -> String {
65    "llama-3.3-70b-versatile".into()
66}
67fn default_locale() -> String {
68    "en".into()
69}
70fn default_true() -> bool {
71    true
72}
73fn default_post_commit_push() -> String {
74    "ask".into()
75}
76fn default_commit_template() -> String {
77    "$msg".into()
78}
79fn default_system_prompt() -> String {
80    DEFAULT_SYSTEM_PROMPT.into()
81}
82fn default_gitmoji_format() -> String {
83    "unicode".into()
84}
85fn default_warn_staged_files_threshold() -> usize {
86    20
87}
88
89impl Default for AppConfig {
90    fn default() -> Self {
91        Self {
92            provider: default_provider(),
93            model: default_model(),
94            api_key: String::new(),
95            api_url: String::new(),
96            api_headers: String::new(),
97            locale: default_locale(),
98            one_liner: true,
99            commit_template: default_commit_template(),
100            llm_system_prompt: default_system_prompt(),
101            use_gitmoji: false,
102            gitmoji_format: default_gitmoji_format(),
103            review_commit: true,
104            post_commit_push: default_post_commit_push(),
105            suppress_tool_output: false,
106            warn_staged_files_enabled: true,
107            warn_staged_files_threshold: default_warn_staged_files_threshold(),
108            confirm_new_version: true,
109            auto_update: None,
110        }
111    }
112}
113
114/// Map of ACR_ env var suffix → struct field name
115const ENV_FIELD_MAP: &[(&str, &str)] = &[
116    ("PROVIDER", "provider"),
117    ("MODEL", "model"),
118    ("API_KEY", "api_key"),
119    ("API_URL", "api_url"),
120    ("API_HEADERS", "api_headers"),
121    ("LOCALE", "locale"),
122    ("ONE_LINER", "one_liner"),
123    ("COMMIT_TEMPLATE", "commit_template"),
124    ("LLM_SYSTEM_PROMPT", "llm_system_prompt"),
125    ("USE_GITMOJI", "use_gitmoji"),
126    ("GITMOJI_FORMAT", "gitmoji_format"),
127    ("REVIEW_COMMIT", "review_commit"),
128    ("POST_COMMIT_PUSH", "post_commit_push"),
129    ("SUPPRESS_TOOL_OUTPUT", "suppress_tool_output"),
130    ("WARN_STAGED_FILES_ENABLED", "warn_staged_files_enabled"),
131    ("WARN_STAGED_FILES_THRESHOLD", "warn_staged_files_threshold"),
132    ("CONFIRM_NEW_VERSION", "confirm_new_version"),
133    ("AUTO_UPDATE", "auto_update"),
134];
135
136impl AppConfig {
137    /// Load config with layered resolution: defaults → global TOML → local .env → env vars
138    pub fn load() -> Result<Self> {
139        let mut cfg = Self::default();
140
141        // Layer 1: Global TOML
142        if let Some(path) = global_config_path() {
143            if path.exists() {
144                let content = std::fs::read_to_string(&path)
145                    .with_context(|| format!("Failed to read {}", path.display()))?;
146                let file_cfg: AppConfig = toml::from_str(&content)
147                    .with_context(|| format!("Failed to parse {}", path.display()))?;
148                cfg.merge_from(&file_cfg);
149            }
150        }
151
152        // Layer 2: Local .env (in git repo root)
153        if let Ok(root) = crate::git::find_repo_root() {
154            let env_path = PathBuf::from(&root).join(".env");
155            if env_path.exists() {
156                let env_map = parse_dotenv(&env_path)?;
157                cfg.apply_env_map(&env_map);
158            }
159        }
160
161        // Layer 3: Actual environment variables
162        let mut env_map = HashMap::new();
163        for (suffix, _) in ENV_FIELD_MAP {
164            let key = format!("ACR_{suffix}");
165            if let Ok(val) = std::env::var(&key) {
166                env_map.insert(key, val);
167            }
168        }
169        cfg.apply_env_map(&env_map);
170        cfg.ensure_valid_locale()?;
171
172        Ok(cfg)
173    }
174
175    fn merge_from(&mut self, other: &AppConfig) {
176        if !other.provider.is_empty() {
177            self.provider = other.provider.clone();
178        }
179        if !other.model.is_empty() {
180            self.model = other.model.clone();
181        }
182        if !other.api_key.is_empty() {
183            self.api_key = other.api_key.clone();
184        }
185        if !other.api_url.is_empty() {
186            self.api_url = other.api_url.clone();
187        }
188        if !other.api_headers.is_empty() {
189            self.api_headers = other.api_headers.clone();
190        }
191        if !other.locale.is_empty() {
192            self.locale = other.locale.clone();
193        }
194        self.one_liner = other.one_liner;
195        if !other.commit_template.is_empty() {
196            self.commit_template = other.commit_template.clone();
197        }
198        if !other.llm_system_prompt.is_empty() {
199            self.llm_system_prompt = other.llm_system_prompt.clone();
200        }
201        self.use_gitmoji = other.use_gitmoji;
202        if !other.gitmoji_format.is_empty() {
203            self.gitmoji_format = other.gitmoji_format.clone();
204        }
205        self.review_commit = other.review_commit;
206        if !other.post_commit_push.is_empty() {
207            self.post_commit_push = normalize_post_commit_push(&other.post_commit_push);
208        }
209        self.suppress_tool_output = other.suppress_tool_output;
210        self.warn_staged_files_enabled = other.warn_staged_files_enabled;
211        self.warn_staged_files_threshold = other.warn_staged_files_threshold;
212        self.confirm_new_version = other.confirm_new_version;
213        if other.auto_update.is_some() {
214            self.auto_update = other.auto_update;
215        }
216    }
217
218    fn apply_env_map(&mut self, map: &HashMap<String, String>) {
219        for (suffix, _field) in ENV_FIELD_MAP {
220            let key = format!("ACR_{suffix}");
221            if let Some(val) = map.get(&key) {
222                match *suffix {
223                    "PROVIDER" => self.provider = val.clone(),
224                    "MODEL" => self.model = val.clone(),
225                    "API_KEY" => self.api_key = val.clone(),
226                    "API_URL" => self.api_url = val.clone(),
227                    "API_HEADERS" => self.api_headers = val.clone(),
228                    "LOCALE" => self.locale = val.clone(),
229                    "ONE_LINER" => self.one_liner = val == "1" || val.eq_ignore_ascii_case("true"),
230                    "COMMIT_TEMPLATE" => self.commit_template = val.clone(),
231                    "LLM_SYSTEM_PROMPT" => self.llm_system_prompt = val.clone(),
232                    "USE_GITMOJI" => {
233                        self.use_gitmoji = val == "1" || val.eq_ignore_ascii_case("true")
234                    }
235                    "GITMOJI_FORMAT" => self.gitmoji_format = val.clone(),
236                    "REVIEW_COMMIT" => {
237                        self.review_commit = val == "1" || val.eq_ignore_ascii_case("true")
238                    }
239                    "POST_COMMIT_PUSH" => self.post_commit_push = normalize_post_commit_push(val),
240                    "SUPPRESS_TOOL_OUTPUT" => {
241                        self.suppress_tool_output = val == "1" || val.eq_ignore_ascii_case("true")
242                    }
243                    "WARN_STAGED_FILES_ENABLED" => {
244                        self.warn_staged_files_enabled =
245                            val == "1" || val.eq_ignore_ascii_case("true")
246                    }
247                    "WARN_STAGED_FILES_THRESHOLD" => {
248                        self.warn_staged_files_threshold =
249                            parse_usize_or_default(val, default_warn_staged_files_threshold());
250                    }
251                    "CONFIRM_NEW_VERSION" => {
252                        self.confirm_new_version = val == "1" || val.eq_ignore_ascii_case("true")
253                    }
254                    "AUTO_UPDATE" => {
255                        self.auto_update =
256                            Some(val == "1" || val.eq_ignore_ascii_case("true"));
257                    }
258                    _ => {}
259                }
260            }
261        }
262    }
263
264    /// Save to global TOML config file
265    pub fn save_global(&self) -> Result<()> {
266        let path = global_config_path().context("Could not determine global config directory")?;
267        if let Some(parent) = path.parent() {
268            std::fs::create_dir_all(parent)
269                .with_context(|| format!("Failed to create {}", parent.display()))?;
270        }
271        let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
272        std::fs::write(&path, content)
273            .with_context(|| format!("Failed to write {}", path.display()))?;
274        Ok(())
275    }
276
277    /// Save to local .env file in the git repo root
278    pub fn save_local(&self) -> Result<()> {
279        let root = crate::git::find_repo_root().context("Not in a git repository")?;
280        let env_path = PathBuf::from(&root).join(".env");
281
282        let mut lines = Vec::new();
283        lines.push(format!("ACR_PROVIDER={}", self.provider));
284        lines.push(format!("ACR_MODEL={}", self.model));
285        if !self.api_key.is_empty() {
286            lines.push(format!("ACR_API_KEY={}", self.api_key));
287        }
288        if !self.api_url.is_empty() {
289            lines.push(format!("ACR_API_URL={}", self.api_url));
290        }
291        if !self.api_headers.is_empty() {
292            lines.push(format!("ACR_API_HEADERS={}", self.api_headers));
293        }
294        lines.push(format!("ACR_LOCALE={}", self.locale));
295        lines.push(format!(
296            "ACR_ONE_LINER={}",
297            if self.one_liner { "1" } else { "0" }
298        ));
299        if self.commit_template != "$msg" {
300            lines.push(format!("ACR_COMMIT_TEMPLATE={}", self.commit_template));
301        }
302        if self.llm_system_prompt != DEFAULT_SYSTEM_PROMPT {
303            lines.push(format!("ACR_LLM_SYSTEM_PROMPT={}", self.llm_system_prompt));
304        }
305        lines.push(format!(
306            "ACR_USE_GITMOJI={}",
307            if self.use_gitmoji { "1" } else { "0" }
308        ));
309        lines.push(format!("ACR_GITMOJI_FORMAT={}", self.gitmoji_format));
310        lines.push(format!(
311            "ACR_REVIEW_COMMIT={}",
312            if self.review_commit { "1" } else { "0" }
313        ));
314        lines.push(format!(
315            "ACR_POST_COMMIT_PUSH={}",
316            normalize_post_commit_push(&self.post_commit_push)
317        ));
318        lines.push(format!(
319            "ACR_SUPPRESS_TOOL_OUTPUT={}",
320            if self.suppress_tool_output { "1" } else { "0" }
321        ));
322        lines.push(format!(
323            "ACR_WARN_STAGED_FILES_ENABLED={}",
324            if self.warn_staged_files_enabled {
325                "1"
326            } else {
327                "0"
328            }
329        ));
330        lines.push(format!(
331            "ACR_WARN_STAGED_FILES_THRESHOLD={}",
332            self.warn_staged_files_threshold
333        ));
334        lines.push(format!(
335            "ACR_CONFIRM_NEW_VERSION={}",
336            if self.confirm_new_version { "1" } else { "0" }
337        ));
338        if let Some(auto_update) = self.auto_update {
339            lines.push(format!(
340                "ACR_AUTO_UPDATE={}",
341                if auto_update { "1" } else { "0" }
342            ));
343        }
344
345        std::fs::write(&env_path, lines.join("\n") + "\n")
346            .with_context(|| format!("Failed to write {}", env_path.display()))?;
347        Ok(())
348    }
349
350    /// Get all fields as (display_name, env_suffix, current_value) tuples
351    pub fn fields_display(&self) -> Vec<(&'static str, &'static str, String)> {
352        vec![
353            ("Provider", "PROVIDER", self.provider.clone()),
354            ("Model", "MODEL", self.model.clone()),
355            (
356                "API Key",
357                "API_KEY",
358                if self.api_key.is_empty() {
359                    "(not set)".into()
360                } else {
361                    mask_key(&self.api_key)
362                },
363            ),
364            (
365                "API URL",
366                "API_URL",
367                if self.api_url.is_empty() {
368                    "(auto from provider)".into()
369                } else {
370                    self.api_url.clone()
371                },
372            ),
373            (
374                "API Headers",
375                "API_HEADERS",
376                if self.api_headers.is_empty() {
377                    "(auto from provider)".into()
378                } else {
379                    self.api_headers.clone()
380                },
381            ),
382            ("Locale", "LOCALE", self.locale.clone()),
383            (
384                "One-liner",
385                "ONE_LINER",
386                if self.one_liner {
387                    "enabled".into()
388                } else {
389                    "disabled".into()
390                },
391            ),
392            (
393                "Commit Template",
394                "COMMIT_TEMPLATE",
395                self.commit_template.clone(),
396            ),
397            (
398                "System Prompt",
399                "LLM_SYSTEM_PROMPT",
400                truncate(&self.llm_system_prompt, 60),
401            ),
402            (
403                "Use Gitmoji",
404                "USE_GITMOJI",
405                if self.use_gitmoji {
406                    "enabled".into()
407                } else {
408                    "disabled".into()
409                },
410            ),
411            (
412                "Gitmoji Format",
413                "GITMOJI_FORMAT",
414                self.gitmoji_format.clone(),
415            ),
416            (
417                "Review Commit",
418                "REVIEW_COMMIT",
419                if self.review_commit {
420                    "enabled".into()
421                } else {
422                    "disabled".into()
423                },
424            ),
425            (
426                "Post Commit Push",
427                "POST_COMMIT_PUSH",
428                normalize_post_commit_push(&self.post_commit_push),
429            ),
430            (
431                "Suppress Tool Output",
432                "SUPPRESS_TOOL_OUTPUT",
433                if self.suppress_tool_output {
434                    "enabled".into()
435                } else {
436                    "disabled".into()
437                },
438            ),
439            (
440                "Warn Staged Files",
441                "WARN_STAGED_FILES_ENABLED",
442                if self.warn_staged_files_enabled {
443                    "enabled".into()
444                } else {
445                    "disabled".into()
446                },
447            ),
448            (
449                "Staged Warn Threshold",
450                "WARN_STAGED_FILES_THRESHOLD",
451                self.warn_staged_files_threshold.to_string(),
452            ),
453            (
454                "Confirm New Version",
455                "CONFIRM_NEW_VERSION",
456                if self.confirm_new_version {
457                    "enabled".into()
458                } else {
459                    "disabled".into()
460                },
461            ),
462            (
463                "Auto Update",
464                "AUTO_UPDATE",
465                match self.auto_update {
466                    Some(true) => "enabled".into(),
467                    Some(false) => "disabled".into(),
468                    None => "(not set)".into(),
469                },
470            ),
471        ]
472    }
473
474    /// Field groups for the interactive config UI
475    pub fn grouped_fields(&self) -> Vec<FieldGroup> {
476        let fields = self.fields_display();
477        let field_map: std::collections::HashMap<&str, (&'static str, String)> = fields
478            .iter()
479            .map(|(name, suffix, val)| (*suffix, (*name, val.clone())))
480            .collect();
481
482        let basic_keys: &[&'static str] = &["PROVIDER", "MODEL", "API_KEY", "API_URL"];
483        let llm_keys: &[&'static str] = &[
484            "API_HEADERS",
485            "LOCALE",
486            "LLM_SYSTEM_PROMPT",
487            "COMMIT_TEMPLATE",
488        ];
489        let commit_keys: &[&'static str] = &[
490            "ONE_LINER",
491            "USE_GITMOJI",
492            "GITMOJI_FORMAT",
493            "REVIEW_COMMIT",
494        ];
495        let post_commit_keys: &[&'static str] = &["POST_COMMIT_PUSH", "SUPPRESS_TOOL_OUTPUT"];
496        let warnings_keys: &[&'static str] = &[
497            "WARN_STAGED_FILES_ENABLED",
498            "WARN_STAGED_FILES_THRESHOLD",
499            "CONFIRM_NEW_VERSION",
500            "AUTO_UPDATE",
501        ];
502
503        let collect =
504            |keys: &[&'static str]| -> Vec<(&'static str, &'static str, String)> {
505                keys.iter()
506                    .filter_map(|k| {
507                        field_map
508                            .get(k)
509                            .map(|(name, val)| (*name, *k, val.clone()))
510                    })
511                    .collect()
512            };
513
514        vec![
515            FieldGroup {
516                name: "Basic",
517                fields: collect(basic_keys),
518                subgroups: vec![],
519            },
520            FieldGroup {
521                name: "Advanced",
522                fields: vec![],
523                subgroups: vec![
524                    FieldSubgroup {
525                        name: "LLM Settings",
526                        fields: collect(llm_keys),
527                    },
528                    FieldSubgroup {
529                        name: "Commit Behavior",
530                        fields: collect(commit_keys),
531                    },
532                    FieldSubgroup {
533                        name: "Post-Commit",
534                        fields: collect(post_commit_keys),
535                    },
536                    FieldSubgroup {
537                        name: "Warnings & Updates",
538                        fields: collect(warnings_keys),
539                    },
540                ],
541            },
542        ]
543    }
544
545    /// Set a field by its env suffix
546    pub fn set_field(&mut self, suffix: &str, value: &str) -> Result<()> {
547        match suffix {
548            "PROVIDER" => self.provider = value.into(),
549            "MODEL" => self.model = value.into(),
550            "API_KEY" => self.api_key = value.into(),
551            "API_URL" => self.api_url = value.into(),
552            "API_HEADERS" => self.api_headers = value.into(),
553            "LOCALE" => {
554                let locale = normalize_locale(value);
555                validate_locale(&locale)?;
556                self.locale = locale;
557            }
558            "ONE_LINER" => self.one_liner = value == "1" || value.eq_ignore_ascii_case("true"),
559            "COMMIT_TEMPLATE" => self.commit_template = value.into(),
560            "LLM_SYSTEM_PROMPT" => self.llm_system_prompt = value.into(),
561            "USE_GITMOJI" => self.use_gitmoji = value == "1" || value.eq_ignore_ascii_case("true"),
562            "GITMOJI_FORMAT" => self.gitmoji_format = value.into(),
563            "REVIEW_COMMIT" => {
564                self.review_commit = value == "1" || value.eq_ignore_ascii_case("true")
565            }
566            "POST_COMMIT_PUSH" => self.post_commit_push = normalize_post_commit_push(value),
567            "SUPPRESS_TOOL_OUTPUT" => {
568                self.suppress_tool_output = value == "1" || value.eq_ignore_ascii_case("true")
569            }
570            "WARN_STAGED_FILES_ENABLED" => {
571                self.warn_staged_files_enabled = value == "1" || value.eq_ignore_ascii_case("true");
572            }
573            "WARN_STAGED_FILES_THRESHOLD" => {
574                self.warn_staged_files_threshold =
575                    parse_usize_or_default(value, default_warn_staged_files_threshold());
576            }
577            "CONFIRM_NEW_VERSION" => {
578                self.confirm_new_version = value == "1" || value.eq_ignore_ascii_case("true");
579            }
580            "AUTO_UPDATE" => {
581                self.auto_update = Some(value == "1" || value.eq_ignore_ascii_case("true"));
582            }
583            _ => {}
584        }
585        Ok(())
586    }
587
588    fn ensure_valid_locale(&mut self) -> Result<()> {
589        self.locale = normalize_locale(&self.locale);
590        validate_locale(&self.locale)
591    }
592}
593
594/// Global config file path
595pub fn global_config_path() -> Option<PathBuf> {
596    if let Some(override_dir) = std::env::var_os("ACR_CONFIG_HOME") {
597        let override_path = PathBuf::from(override_dir);
598        if !override_path.as_os_str().is_empty() {
599            return Some(override_path.join("cgen").join("config.toml"));
600        }
601    }
602    dirs::config_dir().map(|d| d.join("cgen").join("config.toml"))
603}
604
605/// Save only the auto_update preference to global config without overwriting other fields
606pub fn save_auto_update_preference(value: bool) -> Result<()> {
607    let path = global_config_path().context("Could not determine global config directory")?;
608
609    let mut table: toml::Table = if path.exists() {
610        let content = std::fs::read_to_string(&path)
611            .with_context(|| format!("Failed to read {}", path.display()))?;
612        content.parse().unwrap_or_default()
613    } else {
614        toml::Table::new()
615    };
616
617    table.insert("auto_update".to_string(), toml::Value::Boolean(value));
618
619    if let Some(parent) = path.parent() {
620        std::fs::create_dir_all(parent)
621            .with_context(|| format!("Failed to create {}", parent.display()))?;
622    }
623
624    let content = toml::to_string_pretty(&table).context("Failed to serialize config")?;
625    std::fs::write(&path, content)
626        .with_context(|| format!("Failed to write {}", path.display()))?;
627    Ok(())
628}
629
630fn mask_key(key: &str) -> String {
631    if key.len() <= 8 {
632        "*".repeat(key.len())
633    } else {
634        format!("{}...{}", &key[..4], &key[key.len() - 4..])
635    }
636}
637
638fn truncate(s: &str, max: usize) -> String {
639    if s.len() <= max {
640        s.to_string()
641    } else {
642        format!("{}...", &s[..max])
643    }
644}
645
646fn normalize_post_commit_push(value: &str) -> String {
647    match value.trim().to_ascii_lowercase().as_str() {
648        "never" => "never".into(),
649        "always" => "always".into(),
650        _ => "ask".into(),
651    }
652}
653
654fn parse_usize_or_default(value: &str, default: usize) -> usize {
655    value.trim().parse::<usize>().unwrap_or(default)
656}
657
658fn normalize_locale(value: &str) -> String {
659    let normalized = value.trim();
660    if normalized.is_empty() {
661        default_locale()
662    } else {
663        normalized.to_ascii_lowercase()
664    }
665}
666
667fn validate_locale(locale: &str) -> Result<()> {
668    if locale == "en" || locale_has_i18n(locale) {
669        return Ok(());
670    }
671    anyhow::bail!(
672        "Unsupported locale '{}'. Only 'en' is available unless matching i18n resources exist. Set locale with `cgen config` or add i18n files first.",
673        locale
674    );
675}
676
677fn locale_has_i18n(locale: &str) -> bool {
678    locale_i18n_dirs()
679        .iter()
680        .any(|dir| locale_exists_in_i18n_dir(dir, locale))
681}
682
683fn locale_i18n_dirs() -> Vec<PathBuf> {
684    let mut dirs = Vec::new();
685    if let Ok(repo_root) = crate::git::find_repo_root() {
686        dirs.push(PathBuf::from(repo_root).join("i18n"));
687    }
688    if let Ok(current_dir) = std::env::current_dir() {
689        let i18n_dir = current_dir.join("i18n");
690        if !dirs.contains(&i18n_dir) {
691            dirs.push(i18n_dir);
692        }
693    }
694    dirs
695}
696
697fn locale_exists_in_i18n_dir(i18n_dir: &PathBuf, locale: &str) -> bool {
698    if !i18n_dir.exists() {
699        return false;
700    }
701    if i18n_dir.join(locale).is_dir() {
702        return true;
703    }
704
705    let entries = match std::fs::read_dir(i18n_dir) {
706        Ok(entries) => entries,
707        Err(_) => return false,
708    };
709
710    entries.filter_map(|entry| entry.ok()).any(|entry| {
711        let path = entry.path();
712        if path.is_file() {
713            return path
714                .file_stem()
715                .and_then(|stem| stem.to_str())
716                .map(|stem| stem.eq_ignore_ascii_case(locale))
717                .unwrap_or(false);
718        }
719        false
720    })
721}
722
723fn parse_dotenv(path: &PathBuf) -> Result<HashMap<String, String>> {
724    let content = std::fs::read_to_string(path)
725        .with_context(|| format!("Failed to read {}", path.display()))?;
726    let mut map = HashMap::new();
727    for line in content.lines() {
728        let line = line.trim();
729        if line.is_empty() || line.starts_with('#') {
730            continue;
731        }
732        if let Some((key, val)) = line.split_once('=') {
733            let key = key.trim().to_string();
734            let val = val.trim().trim_matches('"').trim_matches('\'').to_string();
735            map.insert(key, val);
736        }
737    }
738    Ok(map)
739}