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 for warning in app_config.review.validate_consistency() {
90 tracing::warn!("{}", warning);
91 }
92
93 Ok(app_config)
94 }
95}
96
97#[derive(Debug, Deserialize, Serialize, Default, Clone)]
99#[serde(default)]
100pub struct UserConfig {
101 pub default_repo: Option<String>,
103}
104
105#[derive(Debug, Deserialize, Serialize, Clone)]
107#[serde(default)]
108pub struct GitHubConfig {
109 pub api_timeout_seconds: u64,
111}
112
113impl Default for GitHubConfig {
114 fn default() -> Self {
115 Self {
116 api_timeout_seconds: 10,
117 }
118 }
119}
120
121#[derive(Debug, Deserialize, Serialize, Clone)]
123#[serde(default)]
124pub struct UiConfig {
125 pub color: bool,
127 pub progress_bars: bool,
129 pub confirm_before_post: bool,
131}
132
133impl Default for UiConfig {
134 fn default() -> Self {
135 Self {
136 color: true,
137 progress_bars: true,
138 confirm_before_post: true,
139 }
140 }
141}
142
143#[derive(Debug, Deserialize, Serialize, Clone)]
147#[serde(default)]
148pub struct PromptConfig {
149 pub max_issue_body_bytes: usize,
156 pub max_diff_bytes: usize,
163 pub max_commit_message_bytes: usize,
169}
170
171impl Default for PromptConfig {
172 fn default() -> Self {
173 Self {
174 max_issue_body_bytes: 32_768,
175 max_diff_bytes: 524_288,
176 max_commit_message_bytes: 4_096,
177 }
178 }
179}
180
181#[derive(Debug, Default, Deserialize, Serialize, Clone)]
183#[serde(default)]
184pub struct AppConfig {
185 pub user: UserConfig,
187 pub ai: AiConfig,
189 pub github: GitHubConfig,
191 pub ui: UiConfig,
193 pub cache: CacheConfig,
195 pub repos: ReposConfig,
197 #[serde(default)]
199 pub review: ReviewConfig,
200 #[serde(default)]
202 pub prompt: PromptConfig,
203}
204
205#[must_use]
210pub fn config_dir() -> PathBuf {
211 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
212 && !xdg_config.is_empty()
213 {
214 return PathBuf::from(xdg_config).join("aptu");
215 }
216 dirs::home_dir()
217 .expect("Could not determine home directory - is HOME set?")
218 .join(".config")
219 .join("aptu")
220}
221
222#[must_use]
227pub fn data_dir() -> PathBuf {
228 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
229 && !xdg_data.is_empty()
230 {
231 return PathBuf::from(xdg_data).join("aptu");
232 }
233 dirs::home_dir()
234 .expect("Could not determine home directory - is HOME set?")
235 .join(".local")
236 .join("share")
237 .join("aptu")
238}
239
240#[must_use]
248pub fn prompts_dir() -> PathBuf {
249 config_dir().join("prompts")
250}
251
252#[must_use]
254pub fn config_file_path() -> PathBuf {
255 config_dir().join("config.toml")
256}
257
258#[cfg(not(target_arch = "wasm32"))]
270pub fn load_config() -> Result<AppConfig, AptuError> {
271 TomlConfigSource::new().load()
272}
273
274#[cfg(test)]
275mod tests {
276 #![allow(unsafe_code)]
277 use super::*;
278 use serial_test::serial;
279
280 #[test]
281 #[serial]
282 fn test_load_config_defaults() {
283 let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
287 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
288 unsafe {
290 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
291 }
292 let config = load_config().expect("should load with defaults");
293 unsafe {
294 std::env::remove_var("XDG_CONFIG_HOME");
295 }
296
297 assert_eq!(config.ai.provider, "openrouter");
298 assert_eq!(config.ai.model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
299 assert_eq!(config.ai.timeout_seconds, 30);
300 assert_eq!(config.ai.max_tokens, 4096);
301 assert!(config.ai.allow_paid_models);
302 #[allow(clippy::float_cmp)]
303 {
304 assert_eq!(config.ai.temperature, 0.3);
305 }
306 assert_eq!(config.github.api_timeout_seconds, 10);
307 assert!(config.ui.color);
308 assert!(config.ui.confirm_before_post);
309 assert_eq!(config.cache.issue_ttl_minutes, 60);
310 }
311
312 #[test]
313 fn test_config_dir_exists() {
314 let dir = config_dir();
315 assert!(dir.ends_with("aptu"));
316 }
317
318 #[test]
319 fn test_data_dir_exists() {
320 let dir = data_dir();
321 assert!(dir.ends_with("aptu"));
322 }
323
324 #[test]
325 fn test_config_file_path() {
326 let path = config_file_path();
327 assert!(path.ends_with("config.toml"));
328 }
329
330 #[test]
331 fn test_config_with_task_triage_override() {
332 let config_str = r#"
334[ai]
335provider = "gemini"
336model = "gemini-3.1-flash-lite-preview"
337
338[ai.tasks.triage]
339model = "gemini-3.1-flash-lite-preview"
340"#;
341
342 let config = Config::builder()
343 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
344 .build()
345 .expect("should build config");
346
347 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
348
349 assert_eq!(app_config.ai.provider, "gemini");
350 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
351 assert!(app_config.ai.tasks.is_some());
352
353 let tasks = app_config.ai.tasks.unwrap();
354 assert!(tasks.triage.is_some());
355 assert!(tasks.review.is_none());
356 assert!(tasks.create.is_none());
357
358 let triage = tasks.triage.unwrap();
359 assert_eq!(triage.provider, None);
360 assert_eq!(
361 triage.model,
362 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
363 );
364 }
365
366 #[test]
367 fn test_config_with_multiple_task_overrides() {
368 let config_str = r#"
370[ai]
371provider = "openrouter"
372model = "mistralai/mistral-small-2603"
373
374[ai.tasks.triage]
375model = "mistralai/mistral-small-2603"
376
377[ai.tasks.review]
378provider = "openrouter"
379model = "anthropic/claude-haiku-4.5"
380
381[ai.tasks.create]
382model = "anthropic/claude-sonnet-4.6"
383"#;
384
385 let config = Config::builder()
386 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
387 .build()
388 .expect("should build config");
389
390 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
391
392 let tasks = app_config.ai.tasks.expect("tasks should exist");
393
394 let triage = tasks.triage.expect("triage should exist");
396 assert_eq!(triage.provider, None);
397 assert_eq!(
398 triage.model,
399 Some(super::super::ai::DEFAULT_OPENROUTER_MODEL.to_string())
400 );
401
402 let review = tasks.review.expect("review should exist");
404 assert_eq!(review.provider, Some("openrouter".to_string()));
405 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
406
407 let create = tasks.create.expect("create should exist");
409 assert_eq!(create.provider, None);
410 assert_eq!(
411 create.model,
412 Some("anthropic/claude-sonnet-4.6".to_string())
413 );
414 }
415
416 #[test]
417 fn test_config_with_partial_task_overrides() {
418 let config_str = r#"
420[ai]
421provider = "gemini"
422model = "gemini-3.1-flash-lite-preview"
423
424[ai.tasks.triage]
425provider = "gemini"
426
427[ai.tasks.review]
428model = "gemini-3.1-flash-lite-preview"
429"#;
430
431 let config = Config::builder()
432 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
433 .build()
434 .expect("should build config");
435
436 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
437
438 let tasks = app_config.ai.tasks.expect("tasks should exist");
439
440 let triage = tasks.triage.expect("triage should exist");
442 assert_eq!(triage.provider, Some("gemini".to_string()));
443 assert_eq!(triage.model, None);
444
445 let review = tasks.review.expect("review should exist");
447 assert_eq!(review.provider, None);
448 assert_eq!(
449 review.model,
450 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
451 );
452 }
453
454 #[test]
455 fn test_config_without_tasks_section() {
456 let config_str = r#"
458[ai]
459provider = "gemini"
460model = "gemini-3.1-flash-lite-preview"
461"#;
462
463 let config = Config::builder()
464 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
465 .build()
466 .expect("should build config");
467
468 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
469
470 assert_eq!(app_config.ai.provider, "gemini");
471 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
472 assert!(app_config.ai.tasks.is_none());
474 }
475
476 #[test]
477 fn test_resolve_for_task_with_defaults() {
478 let ai_config = AiConfig::default();
480
481 let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Triage);
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::Review);
488 assert_eq!(provider, "openrouter");
489 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
490 assert!(ai_config.allow_paid_models);
491
492 let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Create);
493 assert_eq!(provider, "openrouter");
494 assert_eq!(model, "mistralai/mistral-small-2603");
495 assert!(ai_config.allow_paid_models);
496 }
497
498 #[test]
499 fn test_resolve_for_task_with_triage_override() {
500 let config_str = r#"
502[ai]
503provider = "gemini"
504model = "gemini-3.1-flash-lite-preview"
505
506[ai.tasks.triage]
507model = "gemini-3.1-flash-lite-preview"
508"#;
509
510 let config = Config::builder()
511 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
512 .build()
513 .expect("should build config");
514
515 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
516
517 let (provider, model) = app_config
519 .ai
520 .resolve_for_task(super::super::ai::TaskType::Triage);
521 assert_eq!(provider, "gemini");
522 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
523
524 let (provider, model) = app_config
526 .ai
527 .resolve_for_task(super::super::ai::TaskType::Review);
528 assert_eq!(provider, "gemini");
529 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
530
531 let (provider, model) = app_config
532 .ai
533 .resolve_for_task(super::super::ai::TaskType::Create);
534 assert_eq!(provider, "gemini");
535 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
536 }
537
538 #[test]
539 fn test_config_with_provider_override() {
540 let config_str = r#"
542[ai]
543provider = "gemini"
544model = "gemini-3.1-flash-lite-preview"
545
546[ai.tasks.review]
547provider = "openrouter"
548"#;
549
550 let config = Config::builder()
551 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
552 .build()
553 .expect("should build config");
554
555 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
556
557 let (provider, model) = app_config
559 .ai
560 .resolve_for_task(super::super::ai::TaskType::Review);
561 assert_eq!(provider, "openrouter");
562 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
563
564 let (provider, model) = app_config
566 .ai
567 .resolve_for_task(super::super::ai::TaskType::Triage);
568 assert_eq!(provider, "gemini");
569 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
570
571 let (provider, model) = app_config
572 .ai
573 .resolve_for_task(super::super::ai::TaskType::Create);
574 assert_eq!(provider, "gemini");
575 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
576 }
577
578 #[test]
579 fn test_config_with_full_overrides() {
580 let config_str = r#"
582[ai]
583provider = "gemini"
584model = "gemini-3.1-flash-lite-preview"
585
586[ai.tasks.triage]
587provider = "openrouter"
588model = "mistralai/mistral-small-2603"
589
590[ai.tasks.review]
591provider = "openrouter"
592model = "anthropic/claude-haiku-4.5"
593
594[ai.tasks.create]
595provider = "gemini"
596model = "gemini-3.1-flash-lite-preview"
597"#;
598
599 let config = Config::builder()
600 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
601 .build()
602 .expect("should build config");
603
604 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
605
606 let (provider, model) = app_config
608 .ai
609 .resolve_for_task(super::super::ai::TaskType::Triage);
610 assert_eq!(provider, "openrouter");
611 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
612
613 let (provider, model) = app_config
615 .ai
616 .resolve_for_task(super::super::ai::TaskType::Review);
617 assert_eq!(provider, "openrouter");
618 assert_eq!(model, "anthropic/claude-haiku-4.5");
619
620 let (provider, model) = app_config
622 .ai
623 .resolve_for_task(super::super::ai::TaskType::Create);
624 assert_eq!(provider, "gemini");
625 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
626 }
627
628 #[test]
629 fn test_resolve_for_task_partial_overrides() {
630 let config_str = r#"
632[ai]
633provider = "openrouter"
634model = "mistralai/mistral-small-2603"
635
636[ai.tasks.triage]
637model = "mistralai/mistral-small-2603"
638
639[ai.tasks.review]
640provider = "openrouter"
641
642[ai.tasks.create]
643"#;
644
645 let config = Config::builder()
646 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
647 .build()
648 .expect("should build config");
649
650 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
651
652 let (provider, model) = app_config
654 .ai
655 .resolve_for_task(super::super::ai::TaskType::Triage);
656 assert_eq!(provider, "openrouter");
657 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
658
659 let (provider, model) = app_config
661 .ai
662 .resolve_for_task(super::super::ai::TaskType::Review);
663 assert_eq!(provider, "openrouter");
664 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
665
666 let (provider, model) = app_config
668 .ai
669 .resolve_for_task(super::super::ai::TaskType::Create);
670 assert_eq!(provider, "openrouter");
671 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
672 }
673
674 #[test]
675 fn test_fallback_config_toml_parsing() {
676 let config_str = r#"
678[ai]
679provider = "gemini"
680model = "gemini-3.1-flash-lite-preview"
681
682[ai.fallback]
683chain = ["openrouter", "anthropic"]
684"#;
685
686 let config = Config::builder()
687 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
688 .build()
689 .expect("should build config");
690
691 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
692
693 assert_eq!(app_config.ai.provider, "gemini");
694 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
695 assert!(app_config.ai.fallback.is_some());
696
697 let fallback = app_config.ai.fallback.unwrap();
698 assert_eq!(fallback.chain.len(), 2);
699 assert_eq!(fallback.chain[0].provider, "openrouter");
700 assert_eq!(fallback.chain[1].provider, "anthropic");
701 }
702
703 #[test]
704 fn test_fallback_config_empty_chain() {
705 let config_str = r#"
707[ai]
708provider = "gemini"
709model = "gemini-3.1-flash-lite-preview"
710
711[ai.fallback]
712chain = []
713"#;
714
715 let config = Config::builder()
716 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
717 .build()
718 .expect("should build config");
719
720 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
721
722 assert!(app_config.ai.fallback.is_some());
723 let fallback = app_config.ai.fallback.unwrap();
724 assert_eq!(fallback.chain.len(), 0);
725 }
726
727 #[test]
728 fn test_fallback_config_single_provider() {
729 let config_str = r#"
731[ai]
732provider = "gemini"
733model = "gemini-3.1-flash-lite-preview"
734
735[ai.fallback]
736chain = ["openrouter"]
737"#;
738
739 let config = Config::builder()
740 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
741 .build()
742 .expect("should build config");
743
744 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
745
746 assert!(app_config.ai.fallback.is_some());
747 let fallback = app_config.ai.fallback.unwrap();
748 assert_eq!(fallback.chain.len(), 1);
749 assert_eq!(fallback.chain[0].provider, "openrouter");
750 }
751
752 #[test]
753 fn test_fallback_config_without_fallback_section() {
754 let config_str = r#"
756[ai]
757provider = "gemini"
758model = "gemini-3.1-flash-lite-preview"
759"#;
760
761 let config = Config::builder()
762 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
763 .build()
764 .expect("should build config");
765
766 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
767
768 assert!(app_config.ai.fallback.is_none());
769 }
770
771 #[test]
772 fn test_fallback_config_default() {
773 let ai_config = AiConfig::default();
775 assert!(ai_config.fallback.is_none());
776 }
777
778 #[test]
779 #[serial]
780 fn test_load_config_env_var_override() {
781 let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
783 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
784 unsafe {
786 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
787 std::env::set_var("APTU_AI__MODEL", "test-model-override");
788 std::env::set_var("APTU_AI__PROVIDER", "openrouter");
789 }
790 let config = load_config().expect("should load with env overrides");
791 unsafe {
792 std::env::remove_var("XDG_CONFIG_HOME");
793 std::env::remove_var("APTU_AI__MODEL");
794 std::env::remove_var("APTU_AI__PROVIDER");
795 }
796
797 assert_eq!(config.ai.model, "test-model-override");
798 assert_eq!(config.ai.provider, "openrouter");
799 }
800
801 #[test]
802 fn test_review_config_defaults() {
803 let review_config = ReviewConfig::default();
805
806 assert_eq!(
808 review_config.max_prompt_chars, 120_000,
809 "max_prompt_chars should default to 120_000"
810 );
811 assert_eq!(
812 review_config.max_full_content_files, 10,
813 "max_full_content_files should default to 10"
814 );
815 assert_eq!(
816 review_config.max_chars_per_file, 16_000,
817 "max_chars_per_file should default to 16_000"
818 );
819
820 let app_config = AppConfig::default();
822 assert_eq!(
823 app_config.review.max_prompt_chars, review_config.max_prompt_chars,
824 "AppConfig review defaults should match ReviewConfig defaults"
825 );
826 assert_eq!(
827 app_config.review.max_full_content_files, review_config.max_full_content_files,
828 "AppConfig review defaults should match ReviewConfig defaults"
829 );
830 assert_eq!(
831 app_config.review.max_chars_per_file, review_config.max_chars_per_file,
832 "AppConfig review defaults should match ReviewConfig defaults"
833 );
834 }
835
836 #[test]
837 fn test_in_memory_config_source_loads_defaults() {
838 let default_config = AppConfig::default();
839 let source = InMemoryConfigSource(default_config.clone());
840 let loaded = source.load().expect("load should succeed");
841 assert_eq!(loaded.ai.provider, default_config.ai.provider);
842 assert_eq!(loaded.ai.model, default_config.ai.model);
843 assert_eq!(loaded.ai.timeout_seconds, default_config.ai.timeout_seconds);
844 assert_eq!(loaded.ai.max_tokens, default_config.ai.max_tokens);
845 assert_eq!(
846 loaded.github.api_timeout_seconds,
847 default_config.github.api_timeout_seconds
848 );
849 }
850}