1use std::path::PathBuf;
6
7use config::{Config, Environment, File};
8use serde::{Deserialize, Serialize};
9
10use crate::error::AptuError;
11
12use super::{AiConfig, CacheConfig, ReposConfig, ReviewConfig};
13
14#[derive(Debug, Deserialize, Serialize, Default, Clone)]
16#[serde(default)]
17pub struct UserConfig {
18 pub default_repo: Option<String>,
20}
21
22#[derive(Debug, Deserialize, Serialize, Clone)]
24#[serde(default)]
25pub struct GitHubConfig {
26 pub api_timeout_seconds: u64,
28}
29
30impl Default for GitHubConfig {
31 fn default() -> Self {
32 Self {
33 api_timeout_seconds: 10,
34 }
35 }
36}
37
38#[derive(Debug, Deserialize, Serialize, Clone)]
40#[serde(default)]
41pub struct UiConfig {
42 pub color: bool,
44 pub progress_bars: bool,
46 pub confirm_before_post: bool,
48}
49
50impl Default for UiConfig {
51 fn default() -> Self {
52 Self {
53 color: true,
54 progress_bars: true,
55 confirm_before_post: true,
56 }
57 }
58}
59
60#[derive(Debug, Deserialize, Serialize, Clone)]
64#[serde(default)]
65pub struct PromptConfig {
66 pub max_issue_body_bytes: usize,
73 pub max_diff_bytes: usize,
80 pub max_commit_message_bytes: usize,
86}
87
88impl Default for PromptConfig {
89 fn default() -> Self {
90 Self {
91 max_issue_body_bytes: 32_768,
92 max_diff_bytes: 131_072,
93 max_commit_message_bytes: 4_096,
94 }
95 }
96}
97
98#[derive(Debug, Default, Deserialize, Serialize, Clone)]
100#[serde(default)]
101pub struct AppConfig {
102 pub user: UserConfig,
104 pub ai: AiConfig,
106 pub github: GitHubConfig,
108 pub ui: UiConfig,
110 pub cache: CacheConfig,
112 pub repos: ReposConfig,
114 #[serde(default)]
116 pub review: ReviewConfig,
117 #[serde(default)]
119 pub prompt: PromptConfig,
120}
121
122#[must_use]
127pub fn config_dir() -> PathBuf {
128 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
129 && !xdg_config.is_empty()
130 {
131 return PathBuf::from(xdg_config).join("aptu");
132 }
133 dirs::home_dir()
134 .expect("Could not determine home directory - is HOME set?")
135 .join(".config")
136 .join("aptu")
137}
138
139#[must_use]
144pub fn data_dir() -> PathBuf {
145 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
146 && !xdg_data.is_empty()
147 {
148 return PathBuf::from(xdg_data).join("aptu");
149 }
150 dirs::home_dir()
151 .expect("Could not determine home directory - is HOME set?")
152 .join(".local")
153 .join("share")
154 .join("aptu")
155}
156
157#[must_use]
165pub fn prompts_dir() -> PathBuf {
166 config_dir().join("prompts")
167}
168
169#[must_use]
171pub fn config_file_path() -> PathBuf {
172 config_dir().join("config.toml")
173}
174
175pub fn load_config() -> Result<AppConfig, AptuError> {
185 let config_path = config_file_path();
186
187 let config = Config::builder()
188 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
190 .add_source(
192 Environment::with_prefix("APTU")
193 .prefix_separator("_")
194 .separator("__")
195 .try_parsing(true),
196 )
197 .build()?;
198
199 let app_config: AppConfig = config.try_deserialize()?;
200
201 app_config
203 .cache
204 .validate()
205 .map_err(|e| AptuError::Config { message: e })?;
206
207 Ok(app_config)
208}
209
210#[cfg(test)]
211mod tests {
212 #![allow(unsafe_code)]
213 use super::*;
214 use serial_test::serial;
215
216 #[test]
217 #[serial]
218 fn test_load_config_defaults() {
219 let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
223 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
224 unsafe {
226 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
227 }
228 let config = load_config().expect("should load with defaults");
229 unsafe {
230 std::env::remove_var("XDG_CONFIG_HOME");
231 }
232
233 assert_eq!(config.ai.provider, "openrouter");
234 assert_eq!(config.ai.model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
235 assert_eq!(config.ai.timeout_seconds, 30);
236 assert_eq!(config.ai.max_tokens, 4096);
237 assert!(config.ai.allow_paid_models);
238 #[allow(clippy::float_cmp)]
239 {
240 assert_eq!(config.ai.temperature, 0.3);
241 }
242 assert_eq!(config.github.api_timeout_seconds, 10);
243 assert!(config.ui.color);
244 assert!(config.ui.confirm_before_post);
245 assert_eq!(config.cache.issue_ttl_minutes, 60);
246 }
247
248 #[test]
249 fn test_config_dir_exists() {
250 let dir = config_dir();
251 assert!(dir.ends_with("aptu"));
252 }
253
254 #[test]
255 fn test_data_dir_exists() {
256 let dir = data_dir();
257 assert!(dir.ends_with("aptu"));
258 }
259
260 #[test]
261 fn test_config_file_path() {
262 let path = config_file_path();
263 assert!(path.ends_with("config.toml"));
264 }
265
266 #[test]
267 fn test_config_with_task_triage_override() {
268 let config_str = r#"
270[ai]
271provider = "gemini"
272model = "gemini-3.1-flash-lite-preview"
273
274[ai.tasks.triage]
275model = "gemini-3.1-flash-lite-preview"
276"#;
277
278 let config = Config::builder()
279 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
280 .build()
281 .expect("should build config");
282
283 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
284
285 assert_eq!(app_config.ai.provider, "gemini");
286 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
287 assert!(app_config.ai.tasks.is_some());
288
289 let tasks = app_config.ai.tasks.unwrap();
290 assert!(tasks.triage.is_some());
291 assert!(tasks.review.is_none());
292 assert!(tasks.create.is_none());
293
294 let triage = tasks.triage.unwrap();
295 assert_eq!(triage.provider, None);
296 assert_eq!(
297 triage.model,
298 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
299 );
300 }
301
302 #[test]
303 fn test_config_with_multiple_task_overrides() {
304 let config_str = r#"
306[ai]
307provider = "openrouter"
308model = "mistralai/mistral-small-2603"
309
310[ai.tasks.triage]
311model = "mistralai/mistral-small-2603"
312
313[ai.tasks.review]
314provider = "openrouter"
315model = "anthropic/claude-haiku-4.5"
316
317[ai.tasks.create]
318model = "anthropic/claude-sonnet-4.6"
319"#;
320
321 let config = Config::builder()
322 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
323 .build()
324 .expect("should build config");
325
326 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
327
328 let tasks = app_config.ai.tasks.expect("tasks should exist");
329
330 let triage = tasks.triage.expect("triage should exist");
332 assert_eq!(triage.provider, None);
333 assert_eq!(
334 triage.model,
335 Some(super::super::ai::DEFAULT_OPENROUTER_MODEL.to_string())
336 );
337
338 let review = tasks.review.expect("review should exist");
340 assert_eq!(review.provider, Some("openrouter".to_string()));
341 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
342
343 let create = tasks.create.expect("create should exist");
345 assert_eq!(create.provider, None);
346 assert_eq!(
347 create.model,
348 Some("anthropic/claude-sonnet-4.6".to_string())
349 );
350 }
351
352 #[test]
353 fn test_config_with_partial_task_overrides() {
354 let config_str = r#"
356[ai]
357provider = "gemini"
358model = "gemini-3.1-flash-lite-preview"
359
360[ai.tasks.triage]
361provider = "gemini"
362
363[ai.tasks.review]
364model = "gemini-3.1-flash-lite-preview"
365"#;
366
367 let config = Config::builder()
368 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
369 .build()
370 .expect("should build config");
371
372 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
373
374 let tasks = app_config.ai.tasks.expect("tasks should exist");
375
376 let triage = tasks.triage.expect("triage should exist");
378 assert_eq!(triage.provider, Some("gemini".to_string()));
379 assert_eq!(triage.model, None);
380
381 let review = tasks.review.expect("review should exist");
383 assert_eq!(review.provider, None);
384 assert_eq!(
385 review.model,
386 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
387 );
388 }
389
390 #[test]
391 fn test_config_without_tasks_section() {
392 let config_str = r#"
394[ai]
395provider = "gemini"
396model = "gemini-3.1-flash-lite-preview"
397"#;
398
399 let config = Config::builder()
400 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
401 .build()
402 .expect("should build config");
403
404 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
405
406 assert_eq!(app_config.ai.provider, "gemini");
407 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
408 assert!(app_config.ai.tasks.is_none());
410 }
411
412 #[test]
413 fn test_resolve_for_task_with_defaults() {
414 let ai_config = AiConfig::default();
416
417 let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Triage);
419 assert_eq!(provider, "openrouter");
420 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
421 assert!(ai_config.allow_paid_models);
422
423 let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Review);
424 assert_eq!(provider, "openrouter");
425 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
426 assert!(ai_config.allow_paid_models);
427
428 let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Create);
429 assert_eq!(provider, "openrouter");
430 assert_eq!(model, "mistralai/mistral-small-2603");
431 assert!(ai_config.allow_paid_models);
432 }
433
434 #[test]
435 fn test_resolve_for_task_with_triage_override() {
436 let config_str = r#"
438[ai]
439provider = "gemini"
440model = "gemini-3.1-flash-lite-preview"
441
442[ai.tasks.triage]
443model = "gemini-3.1-flash-lite-preview"
444"#;
445
446 let config = Config::builder()
447 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
448 .build()
449 .expect("should build config");
450
451 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
452
453 let (provider, model) = app_config
455 .ai
456 .resolve_for_task(super::super::ai::TaskType::Triage);
457 assert_eq!(provider, "gemini");
458 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
459
460 let (provider, model) = app_config
462 .ai
463 .resolve_for_task(super::super::ai::TaskType::Review);
464 assert_eq!(provider, "gemini");
465 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
466
467 let (provider, model) = app_config
468 .ai
469 .resolve_for_task(super::super::ai::TaskType::Create);
470 assert_eq!(provider, "gemini");
471 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
472 }
473
474 #[test]
475 fn test_config_with_provider_override() {
476 let config_str = r#"
478[ai]
479provider = "gemini"
480model = "gemini-3.1-flash-lite-preview"
481
482[ai.tasks.review]
483provider = "openrouter"
484"#;
485
486 let config = Config::builder()
487 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
488 .build()
489 .expect("should build config");
490
491 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
492
493 let (provider, model) = app_config
495 .ai
496 .resolve_for_task(super::super::ai::TaskType::Review);
497 assert_eq!(provider, "openrouter");
498 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
499
500 let (provider, model) = app_config
502 .ai
503 .resolve_for_task(super::super::ai::TaskType::Triage);
504 assert_eq!(provider, "gemini");
505 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
506
507 let (provider, model) = app_config
508 .ai
509 .resolve_for_task(super::super::ai::TaskType::Create);
510 assert_eq!(provider, "gemini");
511 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
512 }
513
514 #[test]
515 fn test_config_with_full_overrides() {
516 let config_str = r#"
518[ai]
519provider = "gemini"
520model = "gemini-3.1-flash-lite-preview"
521
522[ai.tasks.triage]
523provider = "openrouter"
524model = "mistralai/mistral-small-2603"
525
526[ai.tasks.review]
527provider = "openrouter"
528model = "anthropic/claude-haiku-4.5"
529
530[ai.tasks.create]
531provider = "gemini"
532model = "gemini-3.1-flash-lite-preview"
533"#;
534
535 let config = Config::builder()
536 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
537 .build()
538 .expect("should build config");
539
540 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
541
542 let (provider, model) = app_config
544 .ai
545 .resolve_for_task(super::super::ai::TaskType::Triage);
546 assert_eq!(provider, "openrouter");
547 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
548
549 let (provider, model) = app_config
551 .ai
552 .resolve_for_task(super::super::ai::TaskType::Review);
553 assert_eq!(provider, "openrouter");
554 assert_eq!(model, "anthropic/claude-haiku-4.5");
555
556 let (provider, model) = app_config
558 .ai
559 .resolve_for_task(super::super::ai::TaskType::Create);
560 assert_eq!(provider, "gemini");
561 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
562 }
563
564 #[test]
565 fn test_resolve_for_task_partial_overrides() {
566 let config_str = r#"
568[ai]
569provider = "openrouter"
570model = "mistralai/mistral-small-2603"
571
572[ai.tasks.triage]
573model = "mistralai/mistral-small-2603"
574
575[ai.tasks.review]
576provider = "openrouter"
577
578[ai.tasks.create]
579"#;
580
581 let config = Config::builder()
582 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
583 .build()
584 .expect("should build config");
585
586 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
587
588 let (provider, model) = app_config
590 .ai
591 .resolve_for_task(super::super::ai::TaskType::Triage);
592 assert_eq!(provider, "openrouter");
593 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
594
595 let (provider, model) = app_config
597 .ai
598 .resolve_for_task(super::super::ai::TaskType::Review);
599 assert_eq!(provider, "openrouter");
600 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
601
602 let (provider, model) = app_config
604 .ai
605 .resolve_for_task(super::super::ai::TaskType::Create);
606 assert_eq!(provider, "openrouter");
607 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
608 }
609
610 #[test]
611 fn test_fallback_config_toml_parsing() {
612 let config_str = r#"
614[ai]
615provider = "gemini"
616model = "gemini-3.1-flash-lite-preview"
617
618[ai.fallback]
619chain = ["openrouter", "anthropic"]
620"#;
621
622 let config = Config::builder()
623 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
624 .build()
625 .expect("should build config");
626
627 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
628
629 assert_eq!(app_config.ai.provider, "gemini");
630 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
631 assert!(app_config.ai.fallback.is_some());
632
633 let fallback = app_config.ai.fallback.unwrap();
634 assert_eq!(fallback.chain.len(), 2);
635 assert_eq!(fallback.chain[0].provider, "openrouter");
636 assert_eq!(fallback.chain[1].provider, "anthropic");
637 }
638
639 #[test]
640 fn test_fallback_config_empty_chain() {
641 let config_str = r#"
643[ai]
644provider = "gemini"
645model = "gemini-3.1-flash-lite-preview"
646
647[ai.fallback]
648chain = []
649"#;
650
651 let config = Config::builder()
652 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
653 .build()
654 .expect("should build config");
655
656 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
657
658 assert!(app_config.ai.fallback.is_some());
659 let fallback = app_config.ai.fallback.unwrap();
660 assert_eq!(fallback.chain.len(), 0);
661 }
662
663 #[test]
664 fn test_fallback_config_single_provider() {
665 let config_str = r#"
667[ai]
668provider = "gemini"
669model = "gemini-3.1-flash-lite-preview"
670
671[ai.fallback]
672chain = ["openrouter"]
673"#;
674
675 let config = Config::builder()
676 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
677 .build()
678 .expect("should build config");
679
680 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
681
682 assert!(app_config.ai.fallback.is_some());
683 let fallback = app_config.ai.fallback.unwrap();
684 assert_eq!(fallback.chain.len(), 1);
685 assert_eq!(fallback.chain[0].provider, "openrouter");
686 }
687
688 #[test]
689 fn test_fallback_config_without_fallback_section() {
690 let config_str = r#"
692[ai]
693provider = "gemini"
694model = "gemini-3.1-flash-lite-preview"
695"#;
696
697 let config = Config::builder()
698 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
699 .build()
700 .expect("should build config");
701
702 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
703
704 assert!(app_config.ai.fallback.is_none());
705 }
706
707 #[test]
708 fn test_fallback_config_default() {
709 let ai_config = AiConfig::default();
711 assert!(ai_config.fallback.is_none());
712 }
713
714 #[test]
715 #[serial]
716 fn test_load_config_env_var_override() {
717 let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
719 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
720 unsafe {
722 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
723 std::env::set_var("APTU_AI__MODEL", "test-model-override");
724 std::env::set_var("APTU_AI__PROVIDER", "openrouter");
725 }
726 let config = load_config().expect("should load with env overrides");
727 unsafe {
728 std::env::remove_var("XDG_CONFIG_HOME");
729 std::env::remove_var("APTU_AI__MODEL");
730 std::env::remove_var("APTU_AI__PROVIDER");
731 }
732
733 assert_eq!(config.ai.model, "test-model-override");
734 assert_eq!(config.ai.provider, "openrouter");
735 }
736
737 #[test]
738 fn test_review_config_defaults() {
739 let review_config = ReviewConfig::default();
741
742 assert_eq!(
744 review_config.max_prompt_chars, 120_000,
745 "max_prompt_chars should default to 120_000"
746 );
747 assert_eq!(
748 review_config.max_full_content_files, 10,
749 "max_full_content_files should default to 10"
750 );
751 assert_eq!(
752 review_config.max_chars_per_file, 4_000,
753 "max_chars_per_file should default to 4_000"
754 );
755
756 let app_config = AppConfig::default();
758 assert_eq!(
759 app_config.review.max_prompt_chars, review_config.max_prompt_chars,
760 "AppConfig review defaults should match ReviewConfig defaults"
761 );
762 assert_eq!(
763 app_config.review.max_full_content_files, review_config.max_full_content_files,
764 "AppConfig review defaults should match ReviewConfig defaults"
765 );
766 assert_eq!(
767 app_config.review.max_chars_per_file, review_config.max_chars_per_file,
768 "AppConfig review defaults should match ReviewConfig defaults"
769 );
770 }
771}