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
135const 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 pub fn load() -> Result<Self> {
163 let mut cfg = Self::default();
164
165 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 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 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 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 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 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 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 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 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 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
690pub 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
701pub 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
819pub 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); }
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 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 assert!(!groups[0].fields.is_empty());
1040
1041 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 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(), api_key: "".into(), ..Default::default()
1142 };
1143
1144 cfg.merge_from(&other);
1145 assert_eq!(cfg.provider, "groq"); assert_eq!(cfg.api_key, "original-key"); }
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 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 cfg.apply_env_map(&map, true);
1231 assert!(cfg.auto_update.is_none());
1232
1233 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 map.insert("ACR_USE_GITMOJI".into(), "1".into());
1245 cfg.apply_env_map(&map, false);
1246 assert!(cfg.use_gitmoji);
1247
1248 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(), 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 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 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}