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
114const 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 pub fn load() -> Result<Self> {
139 let mut cfg = Self::default();
140
141 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 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 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 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 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 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 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 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
594pub 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
605pub 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}