1use std::path::PathBuf;
22
23use config::{Config, Environment, File};
24use serde::{Deserialize, Serialize};
25
26use crate::error::AptuError;
27
28pub const DEFAULT_OPENROUTER_MODEL: &str = "mistralai/mistral-small-2603";
30pub const DEFAULT_GEMINI_MODEL: &str = "gemini-3.1-flash-lite-preview";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TaskType {
36 Triage,
38 Review,
40 Create,
42}
43
44#[derive(Debug, Default, Deserialize, Serialize, Clone)]
46#[serde(default)]
47pub struct AppConfig {
48 pub user: UserConfig,
50 pub ai: AiConfig,
52 pub github: GitHubConfig,
54 pub ui: UiConfig,
56 pub cache: CacheConfig,
58 pub repos: ReposConfig,
60}
61
62#[derive(Debug, Deserialize, Serialize, Default, Clone)]
64#[serde(default)]
65pub struct UserConfig {
66 pub default_repo: Option<String>,
68}
69
70#[derive(Debug, Deserialize, Serialize, Default, Clone)]
72#[serde(default)]
73pub struct TaskOverride {
74 pub provider: Option<String>,
76 pub model: Option<String>,
78}
79
80#[derive(Debug, Deserialize, Serialize, Default, Clone)]
82#[serde(default)]
83pub struct TasksConfig {
84 pub triage: Option<TaskOverride>,
86 pub review: Option<TaskOverride>,
88 pub create: Option<TaskOverride>,
90}
91
92#[derive(Debug, Clone, Serialize)]
94pub struct FallbackEntry {
95 pub provider: String,
97 pub model: Option<String>,
99}
100
101impl<'de> Deserialize<'de> for FallbackEntry {
102 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
103 where
104 D: serde::Deserializer<'de>,
105 {
106 #[derive(Deserialize)]
107 #[serde(untagged)]
108 enum EntryVariant {
109 String(String),
110 Struct {
111 provider: String,
112 model: Option<String>,
113 },
114 }
115
116 match EntryVariant::deserialize(deserializer)? {
117 EntryVariant::String(provider) => Ok(FallbackEntry {
118 provider,
119 model: None,
120 }),
121 EntryVariant::Struct { provider, model } => Ok(FallbackEntry { provider, model }),
122 }
123 }
124}
125
126#[derive(Debug, Deserialize, Serialize, Clone, Default)]
128#[serde(default)]
129pub struct FallbackConfig {
130 pub chain: Vec<FallbackEntry>,
132}
133
134fn default_retry_max_attempts() -> u32 {
136 3
137}
138
139#[derive(Debug, Deserialize, Serialize, Clone)]
141#[serde(default)]
142pub struct AiConfig {
143 pub provider: String,
145 pub model: String,
147 pub timeout_seconds: u64,
149 pub allow_paid_models: bool,
151 pub max_tokens: u32,
153 pub temperature: f32,
155 pub circuit_breaker_threshold: u32,
157 pub circuit_breaker_reset_seconds: u64,
159 #[serde(default = "default_retry_max_attempts")]
161 pub retry_max_attempts: u32,
162 pub tasks: Option<TasksConfig>,
164 pub fallback: Option<FallbackConfig>,
166 pub custom_guidance: Option<String>,
172 pub validation_enabled: bool,
178}
179
180impl Default for AiConfig {
181 fn default() -> Self {
182 Self {
183 provider: "openrouter".to_string(),
184 model: DEFAULT_OPENROUTER_MODEL.to_string(),
185 timeout_seconds: 30,
186 allow_paid_models: true,
187 max_tokens: 4096,
188 temperature: 0.3,
189 circuit_breaker_threshold: 3,
190 circuit_breaker_reset_seconds: 60,
191 retry_max_attempts: default_retry_max_attempts(),
192 tasks: None,
193 fallback: None,
194 custom_guidance: None,
195 validation_enabled: true,
196 }
197 }
198}
199
200impl AiConfig {
201 #[must_use]
214 pub fn resolve_for_task(&self, task: TaskType) -> (String, String) {
215 let task_override = match task {
216 TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
217 TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
218 TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
219 };
220
221 let provider = task_override
222 .and_then(|o| o.provider.clone())
223 .unwrap_or_else(|| self.provider.clone());
224
225 let model = task_override
226 .and_then(|o| o.model.clone())
227 .unwrap_or_else(|| self.model.clone());
228
229 (provider, model)
230 }
231}
232
233#[derive(Debug, Deserialize, Serialize, Clone)]
235#[serde(default)]
236pub struct GitHubConfig {
237 pub api_timeout_seconds: u64,
239}
240
241impl Default for GitHubConfig {
242 fn default() -> Self {
243 Self {
244 api_timeout_seconds: 10,
245 }
246 }
247}
248
249#[derive(Debug, Deserialize, Serialize, Clone)]
251#[serde(default)]
252pub struct UiConfig {
253 pub color: bool,
255 pub progress_bars: bool,
257 pub confirm_before_post: bool,
259}
260
261impl Default for UiConfig {
262 fn default() -> Self {
263 Self {
264 color: true,
265 progress_bars: true,
266 confirm_before_post: true,
267 }
268 }
269}
270
271#[derive(Debug, Deserialize, Serialize, Clone)]
273#[serde(default)]
274pub struct CacheConfig {
275 pub issue_ttl_minutes: i64,
277 pub repo_ttl_hours: i64,
279 pub curated_repos_url: String,
281}
282
283impl Default for CacheConfig {
284 fn default() -> Self {
285 Self {
286 issue_ttl_minutes: crate::cache::DEFAULT_ISSUE_TTL_MINS,
287 repo_ttl_hours: crate::cache::DEFAULT_REPO_TTL_HOURS,
288 curated_repos_url:
289 "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
290 .to_string(),
291 }
292 }
293}
294
295#[derive(Debug, Deserialize, Serialize, Clone)]
297#[serde(default)]
298pub struct ReposConfig {
299 pub curated: bool,
301}
302
303impl Default for ReposConfig {
304 fn default() -> Self {
305 Self { curated: true }
306 }
307}
308
309#[must_use]
314pub fn config_dir() -> PathBuf {
315 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
316 && !xdg_config.is_empty()
317 {
318 return PathBuf::from(xdg_config).join("aptu");
319 }
320 dirs::home_dir()
321 .expect("Could not determine home directory - is HOME set?")
322 .join(".config")
323 .join("aptu")
324}
325
326#[must_use]
331pub fn data_dir() -> PathBuf {
332 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
333 && !xdg_data.is_empty()
334 {
335 return PathBuf::from(xdg_data).join("aptu");
336 }
337 dirs::home_dir()
338 .expect("Could not determine home directory - is HOME set?")
339 .join(".local")
340 .join("share")
341 .join("aptu")
342}
343
344#[must_use]
352pub fn prompts_dir() -> PathBuf {
353 config_dir().join("prompts")
354}
355
356#[must_use]
358pub fn config_file_path() -> PathBuf {
359 config_dir().join("config.toml")
360}
361
362pub fn load_config() -> Result<AppConfig, AptuError> {
372 let config_path = config_file_path();
373
374 let config = Config::builder()
375 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
377 .add_source(
379 Environment::with_prefix("APTU")
380 .prefix_separator("_")
381 .separator("__")
382 .try_parsing(true),
383 )
384 .build()?;
385
386 let app_config: AppConfig = config.try_deserialize()?;
387
388 Ok(app_config)
389}
390
391#[cfg(test)]
392mod tests {
393 #![allow(unsafe_code)]
394 use super::*;
395 use serial_test::serial;
396
397 #[test]
398 #[serial]
399 fn test_load_config_defaults() {
400 let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
404 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
405 unsafe {
407 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
408 }
409 let config = load_config().expect("should load with defaults");
410 unsafe {
411 std::env::remove_var("XDG_CONFIG_HOME");
412 }
413
414 assert_eq!(config.ai.provider, "openrouter");
415 assert_eq!(config.ai.model, DEFAULT_OPENROUTER_MODEL);
416 assert_eq!(config.ai.timeout_seconds, 30);
417 assert_eq!(config.ai.max_tokens, 4096);
418 assert_eq!(config.ai.allow_paid_models, true);
419 #[allow(clippy::float_cmp)]
420 {
421 assert_eq!(config.ai.temperature, 0.3);
422 }
423 assert_eq!(config.github.api_timeout_seconds, 10);
424 assert!(config.ui.color);
425 assert!(config.ui.confirm_before_post);
426 assert_eq!(config.cache.issue_ttl_minutes, 60);
427 }
428
429 #[test]
430 fn test_config_dir_exists() {
431 let dir = config_dir();
432 assert!(dir.ends_with("aptu"));
433 }
434
435 #[test]
436 fn test_data_dir_exists() {
437 let dir = data_dir();
438 assert!(dir.ends_with("aptu"));
439 }
440
441 #[test]
442 fn test_config_file_path() {
443 let path = config_file_path();
444 assert!(path.ends_with("config.toml"));
445 }
446
447 #[test]
448 fn test_config_with_task_triage_override() {
449 let config_str = r#"
451[ai]
452provider = "gemini"
453model = "gemini-3.1-flash-lite-preview"
454
455[ai.tasks.triage]
456model = "gemini-3.1-flash-lite-preview"
457"#;
458
459 let config = Config::builder()
460 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
461 .build()
462 .expect("should build config");
463
464 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
465
466 assert_eq!(app_config.ai.provider, "gemini");
467 assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
468 assert!(app_config.ai.tasks.is_some());
469
470 let tasks = app_config.ai.tasks.unwrap();
471 assert!(tasks.triage.is_some());
472 assert!(tasks.review.is_none());
473 assert!(tasks.create.is_none());
474
475 let triage = tasks.triage.unwrap();
476 assert_eq!(triage.provider, None);
477 assert_eq!(triage.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
478 }
479
480 #[test]
481 fn test_config_with_multiple_task_overrides() {
482 let config_str = r#"
484[ai]
485provider = "openrouter"
486model = "mistralai/mistral-small-2603"
487
488[ai.tasks.triage]
489model = "mistralai/mistral-small-2603"
490
491[ai.tasks.review]
492provider = "openrouter"
493model = "anthropic/claude-haiku-4.5"
494
495[ai.tasks.create]
496model = "anthropic/claude-sonnet-4.6"
497"#;
498
499 let config = Config::builder()
500 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
501 .build()
502 .expect("should build config");
503
504 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
505
506 let tasks = app_config.ai.tasks.expect("tasks should exist");
507
508 let triage = tasks.triage.expect("triage should exist");
510 assert_eq!(triage.provider, None);
511 assert_eq!(triage.model, Some(DEFAULT_OPENROUTER_MODEL.to_string()));
512
513 let review = tasks.review.expect("review should exist");
515 assert_eq!(review.provider, Some("openrouter".to_string()));
516 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
517
518 let create = tasks.create.expect("create should exist");
520 assert_eq!(create.provider, None);
521 assert_eq!(
522 create.model,
523 Some("anthropic/claude-sonnet-4.6".to_string())
524 );
525 }
526
527 #[test]
528 fn test_config_with_partial_task_overrides() {
529 let config_str = r#"
531[ai]
532provider = "gemini"
533model = "gemini-3.1-flash-lite-preview"
534
535[ai.tasks.triage]
536provider = "gemini"
537
538[ai.tasks.review]
539model = "gemini-3.1-flash-lite-preview"
540"#;
541
542 let config = Config::builder()
543 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
544 .build()
545 .expect("should build config");
546
547 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
548
549 let tasks = app_config.ai.tasks.expect("tasks should exist");
550
551 let triage = tasks.triage.expect("triage should exist");
553 assert_eq!(triage.provider, Some("gemini".to_string()));
554 assert_eq!(triage.model, None);
555
556 let review = tasks.review.expect("review should exist");
558 assert_eq!(review.provider, None);
559 assert_eq!(review.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
560 }
561
562 #[test]
563 fn test_config_without_tasks_section() {
564 let config_str = r#"
566[ai]
567provider = "gemini"
568model = "gemini-3.1-flash-lite-preview"
569"#;
570
571 let config = Config::builder()
572 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
573 .build()
574 .expect("should build config");
575
576 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
577
578 assert_eq!(app_config.ai.provider, "gemini");
579 assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
580 assert!(app_config.ai.tasks.is_none());
582 }
583
584 #[test]
585 fn test_resolve_for_task_with_defaults() {
586 let ai_config = AiConfig::default();
588
589 let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
591 assert_eq!(provider, "openrouter");
592 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
593 assert_eq!(ai_config.allow_paid_models, true);
594
595 let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
596 assert_eq!(provider, "openrouter");
597 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
598 assert_eq!(ai_config.allow_paid_models, true);
599
600 let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
601 assert_eq!(provider, "openrouter");
602 assert_eq!(model, "mistralai/mistral-small-2603");
603 assert_eq!(ai_config.allow_paid_models, true);
604 }
605
606 #[test]
607 fn test_resolve_for_task_with_triage_override() {
608 let config_str = r#"
610[ai]
611provider = "gemini"
612model = "gemini-3.1-flash-lite-preview"
613
614[ai.tasks.triage]
615model = "gemini-3.1-flash-lite-preview"
616"#;
617
618 let config = Config::builder()
619 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
620 .build()
621 .expect("should build config");
622
623 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
624
625 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
627 assert_eq!(provider, "gemini");
628 assert_eq!(model, DEFAULT_GEMINI_MODEL);
629
630 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
632 assert_eq!(provider, "gemini");
633 assert_eq!(model, DEFAULT_GEMINI_MODEL);
634
635 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
636 assert_eq!(provider, "gemini");
637 assert_eq!(model, DEFAULT_GEMINI_MODEL);
638 }
639
640 #[test]
641 fn test_resolve_for_task_with_provider_override() {
642 let config_str = r#"
644[ai]
645provider = "gemini"
646model = "gemini-3.1-flash-lite-preview"
647
648[ai.tasks.review]
649provider = "openrouter"
650"#;
651
652 let config = Config::builder()
653 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
654 .build()
655 .expect("should build config");
656
657 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
658
659 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
661 assert_eq!(provider, "openrouter");
662 assert_eq!(model, DEFAULT_GEMINI_MODEL);
663
664 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
666 assert_eq!(provider, "gemini");
667 assert_eq!(model, DEFAULT_GEMINI_MODEL);
668
669 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
670 assert_eq!(provider, "gemini");
671 assert_eq!(model, DEFAULT_GEMINI_MODEL);
672 }
673
674 #[test]
675 fn test_resolve_for_task_with_full_overrides() {
676 let config_str = r#"
678[ai]
679provider = "gemini"
680model = "gemini-3.1-flash-lite-preview"
681
682[ai.tasks.triage]
683provider = "openrouter"
684model = "mistralai/mistral-small-2603"
685
686[ai.tasks.review]
687provider = "openrouter"
688model = "anthropic/claude-haiku-4.5"
689
690[ai.tasks.create]
691provider = "gemini"
692model = "gemini-3.1-flash-lite-preview"
693"#;
694
695 let config = Config::builder()
696 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
697 .build()
698 .expect("should build config");
699
700 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
701
702 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
704 assert_eq!(provider, "openrouter");
705 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
706
707 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
709 assert_eq!(provider, "openrouter");
710 assert_eq!(model, "anthropic/claude-haiku-4.5");
711
712 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
714 assert_eq!(provider, "gemini");
715 assert_eq!(model, DEFAULT_GEMINI_MODEL);
716 }
717
718 #[test]
719 fn test_resolve_for_task_partial_overrides() {
720 let config_str = r#"
722[ai]
723provider = "openrouter"
724model = "mistralai/mistral-small-2603"
725
726[ai.tasks.triage]
727model = "mistralai/mistral-small-2603"
728
729[ai.tasks.review]
730provider = "openrouter"
731
732[ai.tasks.create]
733"#;
734
735 let config = Config::builder()
736 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
737 .build()
738 .expect("should build config");
739
740 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
741
742 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
744 assert_eq!(provider, "openrouter");
745 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
746
747 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
749 assert_eq!(provider, "openrouter");
750 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
751
752 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
754 assert_eq!(provider, "openrouter");
755 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
756 }
757
758 #[test]
759 fn test_fallback_config_toml_parsing() {
760 let config_str = r#"
762[ai]
763provider = "gemini"
764model = "gemini-3.1-flash-lite-preview"
765
766[ai.fallback]
767chain = ["openrouter", "anthropic"]
768"#;
769
770 let config = Config::builder()
771 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
772 .build()
773 .expect("should build config");
774
775 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
776
777 assert_eq!(app_config.ai.provider, "gemini");
778 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
779 assert!(app_config.ai.fallback.is_some());
780
781 let fallback = app_config.ai.fallback.unwrap();
782 assert_eq!(fallback.chain.len(), 2);
783 assert_eq!(fallback.chain[0].provider, "openrouter");
784 assert_eq!(fallback.chain[1].provider, "anthropic");
785 }
786
787 #[test]
788 fn test_fallback_config_empty_chain() {
789 let config_str = r#"
791[ai]
792provider = "gemini"
793model = "gemini-3.1-flash-lite-preview"
794
795[ai.fallback]
796chain = []
797"#;
798
799 let config = Config::builder()
800 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
801 .build()
802 .expect("should build config");
803
804 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
805
806 assert!(app_config.ai.fallback.is_some());
807 let fallback = app_config.ai.fallback.unwrap();
808 assert_eq!(fallback.chain.len(), 0);
809 }
810
811 #[test]
812 fn test_fallback_config_single_provider() {
813 let config_str = r#"
815[ai]
816provider = "gemini"
817model = "gemini-3.1-flash-lite-preview"
818
819[ai.fallback]
820chain = ["openrouter"]
821"#;
822
823 let config = Config::builder()
824 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
825 .build()
826 .expect("should build config");
827
828 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
829
830 assert!(app_config.ai.fallback.is_some());
831 let fallback = app_config.ai.fallback.unwrap();
832 assert_eq!(fallback.chain.len(), 1);
833 assert_eq!(fallback.chain[0].provider, "openrouter");
834 }
835
836 #[test]
837 fn test_fallback_config_without_fallback_section() {
838 let config_str = r#"
840[ai]
841provider = "gemini"
842model = "gemini-3.1-flash-lite-preview"
843"#;
844
845 let config = Config::builder()
846 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
847 .build()
848 .expect("should build config");
849
850 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
851
852 assert!(app_config.ai.fallback.is_none());
853 }
854
855 #[test]
856 fn test_fallback_config_default() {
857 let ai_config = AiConfig::default();
859 assert!(ai_config.fallback.is_none());
860 }
861
862 #[test]
863 #[serial]
864 fn test_load_config_env_var_override() {
865 let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
867 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
868 unsafe {
870 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
871 std::env::set_var("APTU_AI__MODEL", "test-model-override");
872 std::env::set_var("APTU_AI__PROVIDER", "openrouter");
873 }
874 let config = load_config().expect("should load with env overrides");
875 unsafe {
876 std::env::remove_var("XDG_CONFIG_HOME");
877 std::env::remove_var("APTU_AI__MODEL");
878 std::env::remove_var("APTU_AI__PROVIDER");
879 }
880
881 assert_eq!(config.ai.model, "test-model-override");
882 assert_eq!(config.ai.provider, "openrouter");
883 }
884}