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
14pub trait ConfigSource: Send + Sync {
20 fn load(&self) -> Result<AppConfig, AptuError>;
26}
27
28pub struct InMemoryConfigSource(pub AppConfig);
33
34impl ConfigSource for InMemoryConfigSource {
35 fn load(&self) -> Result<AppConfig, AptuError> {
36 Ok(self.0.clone())
37 }
38}
39
40#[cfg(not(target_arch = "wasm32"))]
45pub struct TomlConfigSource;
46
47#[cfg(not(target_arch = "wasm32"))]
48impl TomlConfigSource {
49 #[must_use]
51 pub fn new() -> Self {
52 Self
53 }
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57impl Default for TomlConfigSource {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63#[cfg(not(target_arch = "wasm32"))]
64impl ConfigSource for TomlConfigSource {
65 fn load(&self) -> Result<AppConfig, AptuError> {
66 let config_path = config_file_path();
67
68 let config = Config::builder()
69 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
71 .add_source(
73 Environment::with_prefix("APTU")
74 .prefix_separator("_")
75 .separator("__")
76 .try_parsing(true),
77 )
78 .build()?;
79
80 let app_config: AppConfig = config.try_deserialize()?;
81
82 app_config
84 .cache
85 .validate()
86 .map_err(|e| AptuError::Config { message: e })?;
87
88 Ok(app_config)
89 }
90}
91
92#[derive(Debug, Deserialize, Serialize, Default, Clone)]
94#[serde(default)]
95pub struct UserConfig {
96 pub default_repo: Option<String>,
98}
99
100#[derive(Debug, Deserialize, Serialize, Clone)]
102#[serde(default)]
103pub struct GitHubConfig {
104 pub api_timeout_seconds: u64,
106}
107
108impl Default for GitHubConfig {
109 fn default() -> Self {
110 Self {
111 api_timeout_seconds: 10,
112 }
113 }
114}
115
116#[derive(Debug, Deserialize, Serialize, Clone)]
118#[serde(default)]
119pub struct UiConfig {
120 pub color: bool,
122 pub progress_bars: bool,
124 pub confirm_before_post: bool,
126}
127
128impl Default for UiConfig {
129 fn default() -> Self {
130 Self {
131 color: true,
132 progress_bars: true,
133 confirm_before_post: true,
134 }
135 }
136}
137
138#[derive(Debug, Deserialize, Serialize, Clone)]
142#[serde(default)]
143pub struct PromptConfig {
144 pub max_issue_body_bytes: usize,
151 pub max_diff_bytes: usize,
158 pub max_commit_message_bytes: usize,
164}
165
166impl Default for PromptConfig {
167 fn default() -> Self {
168 Self {
169 max_issue_body_bytes: 32_768,
170 max_diff_bytes: 524_288,
171 max_commit_message_bytes: 4_096,
172 }
173 }
174}
175
176#[derive(Debug, Default, Deserialize, Serialize, Clone)]
178#[serde(default)]
179pub struct AppConfig {
180 pub user: UserConfig,
182 pub ai: AiConfig,
184 pub github: GitHubConfig,
186 pub ui: UiConfig,
188 pub cache: CacheConfig,
190 pub repos: ReposConfig,
192 #[serde(default)]
194 pub review: ReviewConfig,
195 #[serde(default)]
197 pub prompt: PromptConfig,
198}
199
200#[must_use]
205pub fn config_dir() -> PathBuf {
206 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
207 && !xdg_config.is_empty()
208 {
209 return PathBuf::from(xdg_config).join("aptu");
210 }
211 dirs::home_dir()
212 .expect("Could not determine home directory - is HOME set?")
213 .join(".config")
214 .join("aptu")
215}
216
217#[must_use]
222pub fn data_dir() -> PathBuf {
223 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
224 && !xdg_data.is_empty()
225 {
226 return PathBuf::from(xdg_data).join("aptu");
227 }
228 dirs::home_dir()
229 .expect("Could not determine home directory - is HOME set?")
230 .join(".local")
231 .join("share")
232 .join("aptu")
233}
234
235#[must_use]
243pub fn prompts_dir() -> PathBuf {
244 config_dir().join("prompts")
245}
246
247#[must_use]
249pub fn config_file_path() -> PathBuf {
250 config_dir().join("config.toml")
251}
252
253#[cfg(not(target_arch = "wasm32"))]
265pub fn load_config() -> Result<AppConfig, AptuError> {
266 TomlConfigSource::new().load()
267}
268
269#[cfg(test)]
270mod tests {
271 #![allow(unsafe_code)]
272 use super::*;
273 use serial_test::serial;
274
275 #[test]
276 #[serial]
277 fn test_load_config_defaults() {
278 let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
282 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
283 unsafe {
285 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
286 }
287 let config = load_config().expect("should load with defaults");
288 unsafe {
289 std::env::remove_var("XDG_CONFIG_HOME");
290 }
291
292 assert_eq!(config.ai.provider, "openrouter");
293 assert_eq!(config.ai.model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
294 assert_eq!(config.ai.timeout_seconds, 30);
295 assert_eq!(config.ai.max_tokens, 4096);
296 assert!(config.ai.allow_paid_models);
297 #[allow(clippy::float_cmp)]
298 {
299 assert_eq!(config.ai.temperature, 0.3);
300 }
301 assert_eq!(config.github.api_timeout_seconds, 10);
302 assert!(config.ui.color);
303 assert!(config.ui.confirm_before_post);
304 assert_eq!(config.cache.issue_ttl_minutes, 60);
305 }
306
307 #[test]
308 fn test_config_dir_exists() {
309 let dir = config_dir();
310 assert!(dir.ends_with("aptu"));
311 }
312
313 #[test]
314 fn test_data_dir_exists() {
315 let dir = data_dir();
316 assert!(dir.ends_with("aptu"));
317 }
318
319 #[test]
320 fn test_config_file_path() {
321 let path = config_file_path();
322 assert!(path.ends_with("config.toml"));
323 }
324
325 #[test]
326 fn test_config_with_task_triage_override() {
327 let config_str = r#"
329[ai]
330provider = "gemini"
331model = "gemini-3.1-flash-lite-preview"
332
333[ai.tasks.triage]
334model = "gemini-3.1-flash-lite-preview"
335"#;
336
337 let config = Config::builder()
338 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
339 .build()
340 .expect("should build config");
341
342 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
343
344 assert_eq!(app_config.ai.provider, "gemini");
345 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
346 assert!(app_config.ai.tasks.is_some());
347
348 let tasks = app_config.ai.tasks.unwrap();
349 assert!(tasks.triage.is_some());
350 assert!(tasks.review.is_none());
351 assert!(tasks.create.is_none());
352
353 let triage = tasks.triage.unwrap();
354 assert_eq!(triage.provider, None);
355 assert_eq!(
356 triage.model,
357 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
358 );
359 }
360
361 #[test]
362 fn test_config_with_multiple_task_overrides() {
363 let config_str = r#"
365[ai]
366provider = "openrouter"
367model = "mistralai/mistral-small-2603"
368
369[ai.tasks.triage]
370model = "mistralai/mistral-small-2603"
371
372[ai.tasks.review]
373provider = "openrouter"
374model = "anthropic/claude-haiku-4.5"
375
376[ai.tasks.create]
377model = "anthropic/claude-sonnet-4.6"
378"#;
379
380 let config = Config::builder()
381 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
382 .build()
383 .expect("should build config");
384
385 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
386
387 let tasks = app_config.ai.tasks.expect("tasks should exist");
388
389 let triage = tasks.triage.expect("triage should exist");
391 assert_eq!(triage.provider, None);
392 assert_eq!(
393 triage.model,
394 Some(super::super::ai::DEFAULT_OPENROUTER_MODEL.to_string())
395 );
396
397 let review = tasks.review.expect("review should exist");
399 assert_eq!(review.provider, Some("openrouter".to_string()));
400 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
401
402 let create = tasks.create.expect("create should exist");
404 assert_eq!(create.provider, None);
405 assert_eq!(
406 create.model,
407 Some("anthropic/claude-sonnet-4.6".to_string())
408 );
409 }
410
411 #[test]
412 fn test_config_with_partial_task_overrides() {
413 let config_str = r#"
415[ai]
416provider = "gemini"
417model = "gemini-3.1-flash-lite-preview"
418
419[ai.tasks.triage]
420provider = "gemini"
421
422[ai.tasks.review]
423model = "gemini-3.1-flash-lite-preview"
424"#;
425
426 let config = Config::builder()
427 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
428 .build()
429 .expect("should build config");
430
431 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
432
433 let tasks = app_config.ai.tasks.expect("tasks should exist");
434
435 let triage = tasks.triage.expect("triage should exist");
437 assert_eq!(triage.provider, Some("gemini".to_string()));
438 assert_eq!(triage.model, None);
439
440 let review = tasks.review.expect("review should exist");
442 assert_eq!(review.provider, None);
443 assert_eq!(
444 review.model,
445 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
446 );
447 }
448
449 #[test]
450 fn test_config_without_tasks_section() {
451 let config_str = r#"
453[ai]
454provider = "gemini"
455model = "gemini-3.1-flash-lite-preview"
456"#;
457
458 let config = Config::builder()
459 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
460 .build()
461 .expect("should build config");
462
463 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
464
465 assert_eq!(app_config.ai.provider, "gemini");
466 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
467 assert!(app_config.ai.tasks.is_none());
469 }
470
471 #[test]
472 fn test_resolve_for_task_with_defaults() {
473 let ai_config = AiConfig::default();
475
476 let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Triage);
478 assert_eq!(provider, "openrouter");
479 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
480 assert!(ai_config.allow_paid_models);
481
482 let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Review);
483 assert_eq!(provider, "openrouter");
484 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
485 assert!(ai_config.allow_paid_models);
486
487 let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Create);
488 assert_eq!(provider, "openrouter");
489 assert_eq!(model, "mistralai/mistral-small-2603");
490 assert!(ai_config.allow_paid_models);
491 }
492
493 #[test]
494 fn test_resolve_for_task_with_triage_override() {
495 let config_str = r#"
497[ai]
498provider = "gemini"
499model = "gemini-3.1-flash-lite-preview"
500
501[ai.tasks.triage]
502model = "gemini-3.1-flash-lite-preview"
503"#;
504
505 let config = Config::builder()
506 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
507 .build()
508 .expect("should build config");
509
510 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
511
512 let (provider, model) = app_config
514 .ai
515 .resolve_for_task(super::super::ai::TaskType::Triage);
516 assert_eq!(provider, "gemini");
517 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
518
519 let (provider, model) = app_config
521 .ai
522 .resolve_for_task(super::super::ai::TaskType::Review);
523 assert_eq!(provider, "gemini");
524 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
525
526 let (provider, model) = app_config
527 .ai
528 .resolve_for_task(super::super::ai::TaskType::Create);
529 assert_eq!(provider, "gemini");
530 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
531 }
532
533 #[test]
534 fn test_config_with_provider_override() {
535 let config_str = r#"
537[ai]
538provider = "gemini"
539model = "gemini-3.1-flash-lite-preview"
540
541[ai.tasks.review]
542provider = "openrouter"
543"#;
544
545 let config = Config::builder()
546 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
547 .build()
548 .expect("should build config");
549
550 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
551
552 let (provider, model) = app_config
554 .ai
555 .resolve_for_task(super::super::ai::TaskType::Review);
556 assert_eq!(provider, "openrouter");
557 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
558
559 let (provider, model) = app_config
561 .ai
562 .resolve_for_task(super::super::ai::TaskType::Triage);
563 assert_eq!(provider, "gemini");
564 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
565
566 let (provider, model) = app_config
567 .ai
568 .resolve_for_task(super::super::ai::TaskType::Create);
569 assert_eq!(provider, "gemini");
570 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
571 }
572
573 #[test]
574 fn test_config_with_full_overrides() {
575 let config_str = r#"
577[ai]
578provider = "gemini"
579model = "gemini-3.1-flash-lite-preview"
580
581[ai.tasks.triage]
582provider = "openrouter"
583model = "mistralai/mistral-small-2603"
584
585[ai.tasks.review]
586provider = "openrouter"
587model = "anthropic/claude-haiku-4.5"
588
589[ai.tasks.create]
590provider = "gemini"
591model = "gemini-3.1-flash-lite-preview"
592"#;
593
594 let config = Config::builder()
595 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
596 .build()
597 .expect("should build config");
598
599 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
600
601 let (provider, model) = app_config
603 .ai
604 .resolve_for_task(super::super::ai::TaskType::Triage);
605 assert_eq!(provider, "openrouter");
606 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
607
608 let (provider, model) = app_config
610 .ai
611 .resolve_for_task(super::super::ai::TaskType::Review);
612 assert_eq!(provider, "openrouter");
613 assert_eq!(model, "anthropic/claude-haiku-4.5");
614
615 let (provider, model) = app_config
617 .ai
618 .resolve_for_task(super::super::ai::TaskType::Create);
619 assert_eq!(provider, "gemini");
620 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
621 }
622
623 #[test]
624 fn test_resolve_for_task_partial_overrides() {
625 let config_str = r#"
627[ai]
628provider = "openrouter"
629model = "mistralai/mistral-small-2603"
630
631[ai.tasks.triage]
632model = "mistralai/mistral-small-2603"
633
634[ai.tasks.review]
635provider = "openrouter"
636
637[ai.tasks.create]
638"#;
639
640 let config = Config::builder()
641 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
642 .build()
643 .expect("should build config");
644
645 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
646
647 let (provider, model) = app_config
649 .ai
650 .resolve_for_task(super::super::ai::TaskType::Triage);
651 assert_eq!(provider, "openrouter");
652 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
653
654 let (provider, model) = app_config
656 .ai
657 .resolve_for_task(super::super::ai::TaskType::Review);
658 assert_eq!(provider, "openrouter");
659 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
660
661 let (provider, model) = app_config
663 .ai
664 .resolve_for_task(super::super::ai::TaskType::Create);
665 assert_eq!(provider, "openrouter");
666 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
667 }
668
669 #[test]
670 fn test_fallback_config_toml_parsing() {
671 let config_str = r#"
673[ai]
674provider = "gemini"
675model = "gemini-3.1-flash-lite-preview"
676
677[ai.fallback]
678chain = ["openrouter", "anthropic"]
679"#;
680
681 let config = Config::builder()
682 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
683 .build()
684 .expect("should build config");
685
686 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
687
688 assert_eq!(app_config.ai.provider, "gemini");
689 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
690 assert!(app_config.ai.fallback.is_some());
691
692 let fallback = app_config.ai.fallback.unwrap();
693 assert_eq!(fallback.chain.len(), 2);
694 assert_eq!(fallback.chain[0].provider, "openrouter");
695 assert_eq!(fallback.chain[1].provider, "anthropic");
696 }
697
698 #[test]
699 fn test_fallback_config_empty_chain() {
700 let config_str = r#"
702[ai]
703provider = "gemini"
704model = "gemini-3.1-flash-lite-preview"
705
706[ai.fallback]
707chain = []
708"#;
709
710 let config = Config::builder()
711 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
712 .build()
713 .expect("should build config");
714
715 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
716
717 assert!(app_config.ai.fallback.is_some());
718 let fallback = app_config.ai.fallback.unwrap();
719 assert_eq!(fallback.chain.len(), 0);
720 }
721
722 #[test]
723 fn test_fallback_config_single_provider() {
724 let config_str = r#"
726[ai]
727provider = "gemini"
728model = "gemini-3.1-flash-lite-preview"
729
730[ai.fallback]
731chain = ["openrouter"]
732"#;
733
734 let config = Config::builder()
735 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
736 .build()
737 .expect("should build config");
738
739 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
740
741 assert!(app_config.ai.fallback.is_some());
742 let fallback = app_config.ai.fallback.unwrap();
743 assert_eq!(fallback.chain.len(), 1);
744 assert_eq!(fallback.chain[0].provider, "openrouter");
745 }
746
747 #[test]
748 fn test_fallback_config_without_fallback_section() {
749 let config_str = r#"
751[ai]
752provider = "gemini"
753model = "gemini-3.1-flash-lite-preview"
754"#;
755
756 let config = Config::builder()
757 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
758 .build()
759 .expect("should build config");
760
761 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
762
763 assert!(app_config.ai.fallback.is_none());
764 }
765
766 #[test]
767 fn test_fallback_config_default() {
768 let ai_config = AiConfig::default();
770 assert!(ai_config.fallback.is_none());
771 }
772
773 #[test]
774 #[serial]
775 fn test_load_config_env_var_override() {
776 let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
778 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
779 unsafe {
781 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
782 std::env::set_var("APTU_AI__MODEL", "test-model-override");
783 std::env::set_var("APTU_AI__PROVIDER", "openrouter");
784 }
785 let config = load_config().expect("should load with env overrides");
786 unsafe {
787 std::env::remove_var("XDG_CONFIG_HOME");
788 std::env::remove_var("APTU_AI__MODEL");
789 std::env::remove_var("APTU_AI__PROVIDER");
790 }
791
792 assert_eq!(config.ai.model, "test-model-override");
793 assert_eq!(config.ai.provider, "openrouter");
794 }
795
796 #[test]
797 fn test_review_config_defaults() {
798 let review_config = ReviewConfig::default();
800
801 assert_eq!(
803 review_config.max_prompt_chars, 120_000,
804 "max_prompt_chars should default to 120_000"
805 );
806 assert_eq!(
807 review_config.max_full_content_files, 10,
808 "max_full_content_files should default to 10"
809 );
810 assert_eq!(
811 review_config.max_chars_per_file, 16_000,
812 "max_chars_per_file should default to 16_000"
813 );
814
815 let app_config = AppConfig::default();
817 assert_eq!(
818 app_config.review.max_prompt_chars, review_config.max_prompt_chars,
819 "AppConfig review defaults should match ReviewConfig defaults"
820 );
821 assert_eq!(
822 app_config.review.max_full_content_files, review_config.max_full_content_files,
823 "AppConfig review defaults should match ReviewConfig defaults"
824 );
825 assert_eq!(
826 app_config.review.max_chars_per_file, review_config.max_chars_per_file,
827 "AppConfig review defaults should match ReviewConfig defaults"
828 );
829 }
830
831 #[test]
832 fn test_in_memory_config_source_loads_defaults() {
833 let default_config = AppConfig::default();
834 let source = InMemoryConfigSource(default_config.clone());
835 let loaded = source.load().expect("load should succeed");
836 assert_eq!(loaded.ai.provider, default_config.ai.provider);
837 assert_eq!(loaded.ai.model, default_config.ai.model);
838 assert_eq!(loaded.ai.timeout_seconds, default_config.ai.timeout_seconds);
839 assert_eq!(loaded.ai.max_tokens, default_config.ai.max_tokens);
840 assert_eq!(
841 loaded.github.api_timeout_seconds,
842 default_config.github.api_timeout_seconds
843 );
844 }
845}