1use std::path::PathBuf;
22
23use config::{Config, Environment, File};
24use serde::{Deserialize, Serialize};
25
26use crate::error::AptuError;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum TaskType {
31 Triage,
33 Review,
35 Create,
37}
38
39#[derive(Debug, Default, Deserialize, Serialize, Clone)]
41#[serde(default)]
42pub struct AppConfig {
43 pub user: UserConfig,
45 pub ai: AiConfig,
47 pub github: GitHubConfig,
49 pub ui: UiConfig,
51 pub cache: CacheConfig,
53 pub repos: ReposConfig,
55}
56
57#[derive(Debug, Deserialize, Serialize, Default, Clone)]
59#[serde(default)]
60pub struct UserConfig {
61 pub default_repo: Option<String>,
63}
64
65#[derive(Debug, Deserialize, Serialize, Default, Clone)]
67#[serde(default)]
68pub struct TaskOverride {
69 pub provider: Option<String>,
71 pub model: Option<String>,
73}
74
75#[derive(Debug, Deserialize, Serialize, Default, Clone)]
77#[serde(default)]
78pub struct TasksConfig {
79 pub triage: Option<TaskOverride>,
81 pub review: Option<TaskOverride>,
83 pub create: Option<TaskOverride>,
85}
86
87#[derive(Debug, Clone, Serialize)]
89pub struct FallbackEntry {
90 pub provider: String,
92 pub model: Option<String>,
94}
95
96impl<'de> Deserialize<'de> for FallbackEntry {
97 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98 where
99 D: serde::Deserializer<'de>,
100 {
101 #[derive(Deserialize)]
102 #[serde(untagged)]
103 enum EntryVariant {
104 String(String),
105 Struct {
106 provider: String,
107 model: Option<String>,
108 },
109 }
110
111 match EntryVariant::deserialize(deserializer)? {
112 EntryVariant::String(provider) => Ok(FallbackEntry {
113 provider,
114 model: None,
115 }),
116 EntryVariant::Struct { provider, model } => Ok(FallbackEntry { provider, model }),
117 }
118 }
119}
120
121#[derive(Debug, Deserialize, Serialize, Clone, Default)]
123#[serde(default)]
124pub struct FallbackConfig {
125 pub chain: Vec<FallbackEntry>,
127}
128
129fn default_retry_max_attempts() -> u32 {
131 3
132}
133
134#[derive(Debug, Deserialize, Serialize, Clone)]
136#[serde(default)]
137pub struct AiConfig {
138 pub provider: String,
140 pub model: String,
142 pub timeout_seconds: u64,
144 pub allow_paid_models: bool,
146 pub max_tokens: u32,
148 pub temperature: f32,
150 pub circuit_breaker_threshold: u32,
152 pub circuit_breaker_reset_seconds: u64,
154 #[serde(default = "default_retry_max_attempts")]
156 pub retry_max_attempts: u32,
157 pub tasks: Option<TasksConfig>,
159 pub fallback: Option<FallbackConfig>,
161 pub custom_guidance: Option<String>,
167 pub validation_enabled: bool,
173}
174
175impl Default for AiConfig {
176 fn default() -> Self {
177 Self {
178 provider: "openrouter".to_string(),
179 model: "mistralai/mistral-small-2603".to_string(),
180 timeout_seconds: 30,
181 allow_paid_models: true,
182 max_tokens: 4096,
183 temperature: 0.3,
184 circuit_breaker_threshold: 3,
185 circuit_breaker_reset_seconds: 60,
186 retry_max_attempts: default_retry_max_attempts(),
187 tasks: None,
188 fallback: None,
189 custom_guidance: None,
190 validation_enabled: true,
191 }
192 }
193}
194
195impl AiConfig {
196 #[must_use]
209 pub fn resolve_for_task(&self, task: TaskType) -> (String, String) {
210 let task_override = match task {
211 TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
212 TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
213 TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
214 };
215
216 let provider = task_override
217 .and_then(|o| o.provider.clone())
218 .unwrap_or_else(|| self.provider.clone());
219
220 let model = task_override
221 .and_then(|o| o.model.clone())
222 .unwrap_or_else(|| self.model.clone());
223
224 (provider, model)
225 }
226}
227
228#[derive(Debug, Deserialize, Serialize, Clone)]
230#[serde(default)]
231pub struct GitHubConfig {
232 pub api_timeout_seconds: u64,
234}
235
236impl Default for GitHubConfig {
237 fn default() -> Self {
238 Self {
239 api_timeout_seconds: 10,
240 }
241 }
242}
243
244#[derive(Debug, Deserialize, Serialize, Clone)]
246#[serde(default)]
247pub struct UiConfig {
248 pub color: bool,
250 pub progress_bars: bool,
252 pub confirm_before_post: bool,
254}
255
256impl Default for UiConfig {
257 fn default() -> Self {
258 Self {
259 color: true,
260 progress_bars: true,
261 confirm_before_post: true,
262 }
263 }
264}
265
266#[derive(Debug, Deserialize, Serialize, Clone)]
268#[serde(default)]
269pub struct CacheConfig {
270 pub issue_ttl_minutes: i64,
272 pub repo_ttl_hours: i64,
274 pub curated_repos_url: String,
276}
277
278impl Default for CacheConfig {
279 fn default() -> Self {
280 Self {
281 issue_ttl_minutes: crate::cache::DEFAULT_ISSUE_TTL_MINS,
282 repo_ttl_hours: crate::cache::DEFAULT_REPO_TTL_HOURS,
283 curated_repos_url:
284 "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
285 .to_string(),
286 }
287 }
288}
289
290#[derive(Debug, Deserialize, Serialize, Clone)]
292#[serde(default)]
293pub struct ReposConfig {
294 pub curated: bool,
296}
297
298impl Default for ReposConfig {
299 fn default() -> Self {
300 Self { curated: true }
301 }
302}
303
304#[must_use]
309pub fn config_dir() -> PathBuf {
310 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
311 && !xdg_config.is_empty()
312 {
313 return PathBuf::from(xdg_config).join("aptu");
314 }
315 dirs::home_dir()
316 .expect("Could not determine home directory - is HOME set?")
317 .join(".config")
318 .join("aptu")
319}
320
321#[must_use]
326pub fn data_dir() -> PathBuf {
327 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
328 && !xdg_data.is_empty()
329 {
330 return PathBuf::from(xdg_data).join("aptu");
331 }
332 dirs::home_dir()
333 .expect("Could not determine home directory - is HOME set?")
334 .join(".local")
335 .join("share")
336 .join("aptu")
337}
338
339#[must_use]
347pub fn prompts_dir() -> PathBuf {
348 config_dir().join("prompts")
349}
350
351#[must_use]
353pub fn config_file_path() -> PathBuf {
354 config_dir().join("config.toml")
355}
356
357pub fn load_config() -> Result<AppConfig, AptuError> {
367 let config_path = config_file_path();
368
369 let config = Config::builder()
370 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
372 .add_source(
374 Environment::with_prefix("APTU")
375 .prefix_separator("_")
376 .separator("__")
377 .try_parsing(true),
378 )
379 .build()?;
380
381 let app_config: AppConfig = config.try_deserialize()?;
382
383 Ok(app_config)
384}
385
386#[cfg(test)]
387mod tests {
388 #![allow(unsafe_code)]
389 use super::*;
390 use serial_test::serial;
391
392 #[test]
393 #[serial]
394 fn test_load_config_defaults() {
395 let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
399 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
400 unsafe {
402 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
403 }
404 let config = load_config().expect("should load with defaults");
405 unsafe {
406 std::env::remove_var("XDG_CONFIG_HOME");
407 }
408
409 assert_eq!(config.ai.provider, "openrouter");
410 assert_eq!(config.ai.model, "mistralai/mistral-small-2603");
411 assert_eq!(config.ai.timeout_seconds, 30);
412 assert_eq!(config.ai.max_tokens, 4096);
413 assert_eq!(config.ai.allow_paid_models, true);
414 #[allow(clippy::float_cmp)]
415 {
416 assert_eq!(config.ai.temperature, 0.3);
417 }
418 assert_eq!(config.github.api_timeout_seconds, 10);
419 assert!(config.ui.color);
420 assert!(config.ui.confirm_before_post);
421 assert_eq!(config.cache.issue_ttl_minutes, 60);
422 }
423
424 #[test]
425 fn test_config_dir_exists() {
426 let dir = config_dir();
427 assert!(dir.ends_with("aptu"));
428 }
429
430 #[test]
431 fn test_data_dir_exists() {
432 let dir = data_dir();
433 assert!(dir.ends_with("aptu"));
434 }
435
436 #[test]
437 fn test_config_file_path() {
438 let path = config_file_path();
439 assert!(path.ends_with("config.toml"));
440 }
441
442 #[test]
443 fn test_config_with_task_triage_override() {
444 let config_str = r#"
446[ai]
447provider = "gemini"
448model = "gemini-3.1-flash-lite-preview"
449
450[ai.tasks.triage]
451model = "gemini-2.5-flash-lite-preview-09-2025"
452"#;
453
454 let config = Config::builder()
455 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
456 .build()
457 .expect("should build config");
458
459 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
460
461 assert_eq!(app_config.ai.provider, "gemini");
462 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
463 assert!(app_config.ai.tasks.is_some());
464
465 let tasks = app_config.ai.tasks.unwrap();
466 assert!(tasks.triage.is_some());
467 assert!(tasks.review.is_none());
468 assert!(tasks.create.is_none());
469
470 let triage = tasks.triage.unwrap();
471 assert_eq!(triage.provider, None);
472 assert_eq!(
473 triage.model,
474 Some("gemini-2.5-flash-lite-preview-09-2025".to_string())
475 );
476 }
477
478 #[test]
479 fn test_config_with_multiple_task_overrides() {
480 let config_str = r#"
482[ai]
483provider = "openrouter"
484model = "mistralai/devstral-2512:free"
485
486[ai.tasks.triage]
487model = "mistralai/devstral-2512:free"
488
489[ai.tasks.review]
490provider = "openrouter"
491model = "anthropic/claude-haiku-4.5"
492
493[ai.tasks.create]
494model = "anthropic/claude-sonnet-4.5"
495"#;
496
497 let config = Config::builder()
498 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
499 .build()
500 .expect("should build config");
501
502 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
503
504 let tasks = app_config.ai.tasks.expect("tasks should exist");
505
506 let triage = tasks.triage.expect("triage should exist");
508 assert_eq!(triage.provider, None);
509 assert_eq!(
510 triage.model,
511 Some("mistralai/devstral-2512:free".to_string())
512 );
513
514 let review = tasks.review.expect("review should exist");
516 assert_eq!(review.provider, Some("openrouter".to_string()));
517 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
518
519 let create = tasks.create.expect("create should exist");
521 assert_eq!(create.provider, None);
522 assert_eq!(
523 create.model,
524 Some("anthropic/claude-sonnet-4.5".to_string())
525 );
526 }
527
528 #[test]
529 fn test_config_with_partial_task_overrides() {
530 let config_str = r#"
532[ai]
533provider = "gemini"
534model = "gemini-3.1-flash-lite-preview"
535
536[ai.tasks.triage]
537provider = "gemini"
538
539[ai.tasks.review]
540model = "gemini-3.1-flash-lite-preview"
541"#;
542
543 let config = Config::builder()
544 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
545 .build()
546 .expect("should build config");
547
548 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
549
550 let tasks = app_config.ai.tasks.expect("tasks should exist");
551
552 let triage = tasks.triage.expect("triage should exist");
554 assert_eq!(triage.provider, Some("gemini".to_string()));
555 assert_eq!(triage.model, None);
556
557 let review = tasks.review.expect("review should exist");
559 assert_eq!(review.provider, None);
560 assert_eq!(
561 review.model,
562 Some("gemini-3.1-flash-lite-preview".to_string())
563 );
564 }
565
566 #[test]
567 fn test_config_without_tasks_section() {
568 let config_str = r#"
570[ai]
571provider = "gemini"
572model = "gemini-3.1-flash-lite-preview"
573"#;
574
575 let config = Config::builder()
576 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
577 .build()
578 .expect("should build config");
579
580 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
581
582 assert_eq!(app_config.ai.provider, "gemini");
583 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
584 assert!(app_config.ai.tasks.is_none());
586 }
587
588 #[test]
589 fn test_resolve_for_task_with_defaults() {
590 let ai_config = AiConfig::default();
592
593 let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
595 assert_eq!(provider, "openrouter");
596 assert_eq!(model, "mistralai/mistral-small-2603");
597 assert_eq!(ai_config.allow_paid_models, true);
598
599 let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
600 assert_eq!(provider, "openrouter");
601 assert_eq!(model, "mistralai/mistral-small-2603");
602 assert_eq!(ai_config.allow_paid_models, true);
603
604 let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
605 assert_eq!(provider, "openrouter");
606 assert_eq!(model, "mistralai/mistral-small-2603");
607 assert_eq!(ai_config.allow_paid_models, true);
608 }
609
610 #[test]
611 fn test_resolve_for_task_with_triage_override() {
612 let config_str = r#"
614[ai]
615provider = "gemini"
616model = "gemini-3.1-flash-lite-preview"
617
618[ai.tasks.triage]
619model = "gemini-2.5-flash-lite-preview-09-2025"
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 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
631 assert_eq!(provider, "gemini");
632 assert_eq!(model, "gemini-2.5-flash-lite-preview-09-2025");
633
634 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
636 assert_eq!(provider, "gemini");
637 assert_eq!(model, "gemini-3.1-flash-lite-preview");
638
639 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
640 assert_eq!(provider, "gemini");
641 assert_eq!(model, "gemini-3.1-flash-lite-preview");
642 }
643
644 #[test]
645 fn test_resolve_for_task_with_provider_override() {
646 let config_str = r#"
648[ai]
649provider = "gemini"
650model = "gemini-3.1-flash-lite-preview"
651
652[ai.tasks.review]
653provider = "openrouter"
654"#;
655
656 let config = Config::builder()
657 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
658 .build()
659 .expect("should build config");
660
661 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
662
663 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
665 assert_eq!(provider, "openrouter");
666 assert_eq!(model, "gemini-3.1-flash-lite-preview");
667
668 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
670 assert_eq!(provider, "gemini");
671 assert_eq!(model, "gemini-3.1-flash-lite-preview");
672
673 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
674 assert_eq!(provider, "gemini");
675 assert_eq!(model, "gemini-3.1-flash-lite-preview");
676 }
677
678 #[test]
679 fn test_resolve_for_task_with_full_overrides() {
680 let config_str = r#"
682[ai]
683provider = "gemini"
684model = "gemini-3.1-flash-lite-preview"
685
686[ai.tasks.triage]
687provider = "openrouter"
688model = "mistralai/devstral-2512:free"
689
690[ai.tasks.review]
691provider = "openrouter"
692model = "anthropic/claude-haiku-4.5"
693
694[ai.tasks.create]
695provider = "gemini"
696model = "gemini-3.1-flash-lite-preview"
697"#;
698
699 let config = Config::builder()
700 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
701 .build()
702 .expect("should build config");
703
704 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
705
706 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
708 assert_eq!(provider, "openrouter");
709 assert_eq!(model, "mistralai/devstral-2512:free");
710
711 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
713 assert_eq!(provider, "openrouter");
714 assert_eq!(model, "anthropic/claude-haiku-4.5");
715
716 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
718 assert_eq!(provider, "gemini");
719 assert_eq!(model, "gemini-3.1-flash-lite-preview");
720 }
721
722 #[test]
723 fn test_resolve_for_task_partial_overrides() {
724 let config_str = r#"
726[ai]
727provider = "openrouter"
728model = "mistralai/devstral-2512:free"
729
730[ai.tasks.triage]
731model = "mistralai/devstral-2512:free"
732
733[ai.tasks.review]
734provider = "openrouter"
735
736[ai.tasks.create]
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 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
748 assert_eq!(provider, "openrouter");
749 assert_eq!(model, "mistralai/devstral-2512:free");
750
751 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
753 assert_eq!(provider, "openrouter");
754 assert_eq!(model, "mistralai/devstral-2512:free");
755
756 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
758 assert_eq!(provider, "openrouter");
759 assert_eq!(model, "mistralai/devstral-2512:free");
760 }
761
762 #[test]
763 fn test_fallback_config_toml_parsing() {
764 let config_str = r#"
766[ai]
767provider = "gemini"
768model = "gemini-3.1-flash-lite-preview"
769
770[ai.fallback]
771chain = ["openrouter", "anthropic"]
772"#;
773
774 let config = Config::builder()
775 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
776 .build()
777 .expect("should build config");
778
779 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
780
781 assert_eq!(app_config.ai.provider, "gemini");
782 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
783 assert!(app_config.ai.fallback.is_some());
784
785 let fallback = app_config.ai.fallback.unwrap();
786 assert_eq!(fallback.chain.len(), 2);
787 assert_eq!(fallback.chain[0].provider, "openrouter");
788 assert_eq!(fallback.chain[1].provider, "anthropic");
789 }
790
791 #[test]
792 fn test_fallback_config_empty_chain() {
793 let config_str = r#"
795[ai]
796provider = "gemini"
797model = "gemini-3.1-flash-lite-preview"
798
799[ai.fallback]
800chain = []
801"#;
802
803 let config = Config::builder()
804 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
805 .build()
806 .expect("should build config");
807
808 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
809
810 assert!(app_config.ai.fallback.is_some());
811 let fallback = app_config.ai.fallback.unwrap();
812 assert_eq!(fallback.chain.len(), 0);
813 }
814
815 #[test]
816 fn test_fallback_config_single_provider() {
817 let config_str = r#"
819[ai]
820provider = "gemini"
821model = "gemini-3.1-flash-lite-preview"
822
823[ai.fallback]
824chain = ["openrouter"]
825"#;
826
827 let config = Config::builder()
828 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
829 .build()
830 .expect("should build config");
831
832 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
833
834 assert!(app_config.ai.fallback.is_some());
835 let fallback = app_config.ai.fallback.unwrap();
836 assert_eq!(fallback.chain.len(), 1);
837 assert_eq!(fallback.chain[0].provider, "openrouter");
838 }
839
840 #[test]
841 fn test_fallback_config_without_fallback_section() {
842 let config_str = r#"
844[ai]
845provider = "gemini"
846model = "gemini-3.1-flash-lite-preview"
847"#;
848
849 let config = Config::builder()
850 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
851 .build()
852 .expect("should build config");
853
854 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
855
856 assert!(app_config.ai.fallback.is_none());
857 }
858
859 #[test]
860 fn test_fallback_config_default() {
861 let ai_config = AiConfig::default();
863 assert!(ai_config.fallback.is_none());
864 }
865
866 #[test]
867 #[serial]
868 fn test_load_config_env_var_override() {
869 let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
871 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
872 unsafe {
874 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
875 std::env::set_var("APTU_AI__MODEL", "test-model-override");
876 std::env::set_var("APTU_AI__PROVIDER", "openrouter");
877 }
878 let config = load_config().expect("should load with env overrides");
879 unsafe {
880 std::env::remove_var("XDG_CONFIG_HOME");
881 std::env::remove_var("APTU_AI__MODEL");
882 std::env::remove_var("APTU_AI__PROVIDER");
883 }
884
885 assert_eq!(config.ai.model, "test-model-override");
886 assert_eq!(config.ai.provider, "openrouter");
887 }
888}