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