1use std::path::PathBuf;
22
23use config::{Config, Environment, File};
24use serde::Deserialize;
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, 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, Default, Clone)]
59#[serde(default)]
60pub struct UserConfig {
61 pub default_repo: Option<String>,
63}
64
65#[derive(Debug, Deserialize, Default, Clone)]
67#[serde(default)]
68pub struct TaskOverride {
69 pub provider: Option<String>,
71 pub model: Option<String>,
73}
74
75#[derive(Debug, Deserialize, 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)]
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, 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, 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: "gemini".to_string(),
179 model: "gemini-3-flash-preview".to_string(),
180 timeout_seconds: 30,
181 allow_paid_models: false,
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: Some(TasksConfig {
188 triage: None,
189 review: Some(TaskOverride {
190 provider: Some("groq".to_string()),
191 model: Some("openai/gpt-oss-120b".to_string()),
192 }),
193 create: None,
194 }),
195 fallback: None,
196 custom_guidance: None,
197 validation_enabled: true,
198 }
199 }
200}
201
202impl AiConfig {
203 #[must_use]
216 pub fn resolve_for_task(&self, task: TaskType) -> (String, String) {
217 let task_override = match task {
218 TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
219 TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
220 TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
221 };
222
223 let provider = task_override
224 .and_then(|o| o.provider.clone())
225 .unwrap_or_else(|| self.provider.clone());
226
227 let model = task_override
228 .and_then(|o| o.model.clone())
229 .unwrap_or_else(|| self.model.clone());
230
231 (provider, model)
232 }
233}
234
235#[derive(Debug, Deserialize, Clone)]
237#[serde(default)]
238pub struct GitHubConfig {
239 pub api_timeout_seconds: u64,
241}
242
243impl Default for GitHubConfig {
244 fn default() -> Self {
245 Self {
246 api_timeout_seconds: 10,
247 }
248 }
249}
250
251#[derive(Debug, Deserialize, Clone)]
253#[serde(default)]
254pub struct UiConfig {
255 pub color: bool,
257 pub progress_bars: bool,
259 pub confirm_before_post: bool,
261}
262
263impl Default for UiConfig {
264 fn default() -> Self {
265 Self {
266 color: true,
267 progress_bars: true,
268 confirm_before_post: true,
269 }
270 }
271}
272
273#[derive(Debug, Deserialize, Clone)]
275#[serde(default)]
276pub struct CacheConfig {
277 pub issue_ttl_minutes: i64,
279 pub repo_ttl_hours: i64,
281 pub curated_repos_url: String,
283}
284
285impl Default for CacheConfig {
286 fn default() -> Self {
287 Self {
288 issue_ttl_minutes: crate::cache::DEFAULT_ISSUE_TTL_MINS,
289 repo_ttl_hours: crate::cache::DEFAULT_REPO_TTL_HOURS,
290 curated_repos_url:
291 "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
292 .to_string(),
293 }
294 }
295}
296
297#[derive(Debug, Deserialize, Clone)]
299#[serde(default)]
300pub struct ReposConfig {
301 pub curated: bool,
303}
304
305impl Default for ReposConfig {
306 fn default() -> Self {
307 Self { curated: true }
308 }
309}
310
311#[must_use]
316pub fn config_dir() -> PathBuf {
317 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
318 && !xdg_config.is_empty()
319 {
320 return PathBuf::from(xdg_config).join("aptu");
321 }
322 dirs::home_dir()
323 .expect("Could not determine home directory - is HOME set?")
324 .join(".config")
325 .join("aptu")
326}
327
328#[must_use]
333pub fn data_dir() -> PathBuf {
334 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
335 && !xdg_data.is_empty()
336 {
337 return PathBuf::from(xdg_data).join("aptu");
338 }
339 dirs::home_dir()
340 .expect("Could not determine home directory - is HOME set?")
341 .join(".local")
342 .join("share")
343 .join("aptu")
344}
345
346#[must_use]
348pub fn config_file_path() -> PathBuf {
349 config_dir().join("config.toml")
350}
351
352pub fn load_config() -> Result<AppConfig, AptuError> {
362 let config_path = config_file_path();
363
364 let config = Config::builder()
365 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
367 .add_source(
369 Environment::with_prefix("APTU")
370 .separator("__")
371 .try_parsing(true),
372 )
373 .build()?;
374
375 let app_config: AppConfig = config.try_deserialize()?;
376
377 Ok(app_config)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn test_load_config_defaults() {
386 let config = load_config().expect("should load with defaults");
388
389 assert_eq!(config.ai.provider, "gemini");
390 assert_eq!(config.ai.model, "gemini-3-flash-preview");
391 assert_eq!(config.ai.timeout_seconds, 30);
392 assert_eq!(config.ai.max_tokens, 4096);
393 #[allow(clippy::float_cmp)]
394 {
395 assert_eq!(config.ai.temperature, 0.3);
396 }
397 assert_eq!(config.github.api_timeout_seconds, 10);
398 assert!(config.ui.color);
399 assert!(config.ui.confirm_before_post);
400 assert_eq!(config.cache.issue_ttl_minutes, 60);
401 }
402
403 #[test]
404 fn test_config_dir_exists() {
405 let dir = config_dir();
406 assert!(dir.ends_with("aptu"));
407 }
408
409 #[test]
410 fn test_data_dir_exists() {
411 let dir = data_dir();
412 assert!(dir.ends_with("aptu"));
413 }
414
415 #[test]
416 fn test_config_file_path() {
417 let path = config_file_path();
418 assert!(path.ends_with("config.toml"));
419 }
420
421 #[test]
422 fn test_config_with_task_triage_override() {
423 let config_str = r#"
425[ai]
426provider = "gemini"
427model = "gemini-3-flash-preview"
428
429[ai.tasks.triage]
430model = "gemini-2.5-flash-lite-preview-09-2025"
431"#;
432
433 let config = Config::builder()
434 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
435 .build()
436 .expect("should build config");
437
438 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
439
440 assert_eq!(app_config.ai.provider, "gemini");
441 assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
442 assert!(app_config.ai.tasks.is_some());
443
444 let tasks = app_config.ai.tasks.unwrap();
445 assert!(tasks.triage.is_some());
446 assert!(tasks.review.is_none());
447 assert!(tasks.create.is_none());
448
449 let triage = tasks.triage.unwrap();
450 assert_eq!(triage.provider, None);
451 assert_eq!(
452 triage.model,
453 Some("gemini-2.5-flash-lite-preview-09-2025".to_string())
454 );
455 }
456
457 #[test]
458 fn test_config_with_multiple_task_overrides() {
459 let config_str = r#"
461[ai]
462provider = "openrouter"
463model = "mistralai/devstral-2512:free"
464
465[ai.tasks.triage]
466model = "mistralai/devstral-2512:free"
467
468[ai.tasks.review]
469provider = "openrouter"
470model = "anthropic/claude-haiku-4.5"
471
472[ai.tasks.create]
473model = "anthropic/claude-sonnet-4.5"
474"#;
475
476 let config = Config::builder()
477 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
478 .build()
479 .expect("should build config");
480
481 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
482
483 let tasks = app_config.ai.tasks.expect("tasks should exist");
484
485 let triage = tasks.triage.expect("triage should exist");
487 assert_eq!(triage.provider, None);
488 assert_eq!(
489 triage.model,
490 Some("mistralai/devstral-2512:free".to_string())
491 );
492
493 let review = tasks.review.expect("review should exist");
495 assert_eq!(review.provider, Some("openrouter".to_string()));
496 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
497
498 let create = tasks.create.expect("create should exist");
500 assert_eq!(create.provider, None);
501 assert_eq!(
502 create.model,
503 Some("anthropic/claude-sonnet-4.5".to_string())
504 );
505 }
506
507 #[test]
508 fn test_config_with_partial_task_overrides() {
509 let config_str = r#"
511[ai]
512provider = "gemini"
513model = "gemini-3-flash-preview"
514
515[ai.tasks.triage]
516provider = "gemini"
517
518[ai.tasks.review]
519model = "gemini-3-pro-preview"
520"#;
521
522 let config = Config::builder()
523 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
524 .build()
525 .expect("should build config");
526
527 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
528
529 let tasks = app_config.ai.tasks.expect("tasks should exist");
530
531 let triage = tasks.triage.expect("triage should exist");
533 assert_eq!(triage.provider, Some("gemini".to_string()));
534 assert_eq!(triage.model, None);
535
536 let review = tasks.review.expect("review should exist");
538 assert_eq!(review.provider, None);
539 assert_eq!(review.model, Some("gemini-3-pro-preview".to_string()));
540 }
541
542 #[test]
543 fn test_config_without_tasks_section() {
544 let config_str = r#"
546[ai]
547provider = "gemini"
548model = "gemini-3-flash-preview"
549"#;
550
551 let config = Config::builder()
552 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
553 .build()
554 .expect("should build config");
555
556 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
557
558 assert_eq!(app_config.ai.provider, "gemini");
559 assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
560 assert!(app_config.ai.tasks.is_some());
562 let tasks = app_config.ai.tasks.unwrap();
563 assert!(tasks.review.is_some());
564 let review = tasks.review.unwrap();
565 assert_eq!(review.provider, Some("groq".to_string()));
566 assert_eq!(review.model, Some("openai/gpt-oss-120b".to_string()));
567 }
568
569 #[test]
570 fn test_resolve_for_task_with_defaults() {
571 let ai_config = AiConfig::default();
573
574 let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
576 assert_eq!(provider, "gemini");
577 assert_eq!(model, "gemini-3-flash-preview");
578
579 let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
581 assert_eq!(provider, "groq");
582 assert_eq!(model, "openai/gpt-oss-120b");
583
584 let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
585 assert_eq!(provider, "gemini");
586 assert_eq!(model, "gemini-3-flash-preview");
587 }
588
589 #[test]
590 fn test_resolve_for_task_with_triage_override() {
591 let config_str = r#"
593[ai]
594provider = "gemini"
595model = "gemini-3-flash-preview"
596
597[ai.tasks.triage]
598model = "gemini-2.5-flash-lite-preview-09-2025"
599"#;
600
601 let config = Config::builder()
602 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
603 .build()
604 .expect("should build config");
605
606 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
607
608 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
610 assert_eq!(provider, "gemini");
611 assert_eq!(model, "gemini-2.5-flash-lite-preview-09-2025");
612
613 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
615 assert_eq!(provider, "gemini");
616 assert_eq!(model, "gemini-3-flash-preview");
617
618 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
619 assert_eq!(provider, "gemini");
620 assert_eq!(model, "gemini-3-flash-preview");
621 }
622
623 #[test]
624 fn test_resolve_for_task_with_provider_override() {
625 let config_str = r#"
627[ai]
628provider = "gemini"
629model = "gemini-3-flash-preview"
630
631[ai.tasks.review]
632provider = "openrouter"
633"#;
634
635 let config = Config::builder()
636 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
637 .build()
638 .expect("should build config");
639
640 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
641
642 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
644 assert_eq!(provider, "openrouter");
645 assert_eq!(model, "gemini-3-flash-preview");
646
647 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
649 assert_eq!(provider, "gemini");
650 assert_eq!(model, "gemini-3-flash-preview");
651
652 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
653 assert_eq!(provider, "gemini");
654 assert_eq!(model, "gemini-3-flash-preview");
655 }
656
657 #[test]
658 fn test_resolve_for_task_with_full_overrides() {
659 let config_str = r#"
661[ai]
662provider = "gemini"
663model = "gemini-3-flash-preview"
664
665[ai.tasks.triage]
666provider = "openrouter"
667model = "mistralai/devstral-2512:free"
668
669[ai.tasks.review]
670provider = "openrouter"
671model = "anthropic/claude-haiku-4.5"
672
673[ai.tasks.create]
674provider = "gemini"
675model = "gemini-3-pro-preview"
676"#;
677
678 let config = Config::builder()
679 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
680 .build()
681 .expect("should build config");
682
683 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
684
685 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
687 assert_eq!(provider, "openrouter");
688 assert_eq!(model, "mistralai/devstral-2512:free");
689
690 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
692 assert_eq!(provider, "openrouter");
693 assert_eq!(model, "anthropic/claude-haiku-4.5");
694
695 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
697 assert_eq!(provider, "gemini");
698 assert_eq!(model, "gemini-3-pro-preview");
699 }
700
701 #[test]
702 fn test_resolve_for_task_partial_overrides() {
703 let config_str = r#"
705[ai]
706provider = "openrouter"
707model = "mistralai/devstral-2512:free"
708
709[ai.tasks.triage]
710model = "mistralai/devstral-2512:free"
711
712[ai.tasks.review]
713provider = "openrouter"
714
715[ai.tasks.create]
716"#;
717
718 let config = Config::builder()
719 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
720 .build()
721 .expect("should build config");
722
723 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
724
725 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
727 assert_eq!(provider, "openrouter");
728 assert_eq!(model, "mistralai/devstral-2512:free");
729
730 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
732 assert_eq!(provider, "openrouter");
733 assert_eq!(model, "mistralai/devstral-2512:free");
734
735 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
737 assert_eq!(provider, "openrouter");
738 assert_eq!(model, "mistralai/devstral-2512:free");
739 }
740
741 #[test]
742 fn test_fallback_config_toml_parsing() {
743 let config_str = r#"
745[ai]
746provider = "gemini"
747model = "gemini-3-flash-preview"
748
749[ai.fallback]
750chain = ["openrouter", "anthropic"]
751"#;
752
753 let config = Config::builder()
754 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
755 .build()
756 .expect("should build config");
757
758 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
759
760 assert_eq!(app_config.ai.provider, "gemini");
761 assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
762 assert!(app_config.ai.fallback.is_some());
763
764 let fallback = app_config.ai.fallback.unwrap();
765 assert_eq!(fallback.chain.len(), 2);
766 assert_eq!(fallback.chain[0].provider, "openrouter");
767 assert_eq!(fallback.chain[1].provider, "anthropic");
768 }
769
770 #[test]
771 fn test_fallback_config_empty_chain() {
772 let config_str = r#"
774[ai]
775provider = "gemini"
776model = "gemini-3-flash-preview"
777
778[ai.fallback]
779chain = []
780"#;
781
782 let config = Config::builder()
783 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
784 .build()
785 .expect("should build config");
786
787 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
788
789 assert!(app_config.ai.fallback.is_some());
790 let fallback = app_config.ai.fallback.unwrap();
791 assert_eq!(fallback.chain.len(), 0);
792 }
793
794 #[test]
795 fn test_fallback_config_single_provider() {
796 let config_str = r#"
798[ai]
799provider = "gemini"
800model = "gemini-3-flash-preview"
801
802[ai.fallback]
803chain = ["openrouter"]
804"#;
805
806 let config = Config::builder()
807 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
808 .build()
809 .expect("should build config");
810
811 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
812
813 assert!(app_config.ai.fallback.is_some());
814 let fallback = app_config.ai.fallback.unwrap();
815 assert_eq!(fallback.chain.len(), 1);
816 assert_eq!(fallback.chain[0].provider, "openrouter");
817 }
818
819 #[test]
820 fn test_fallback_config_without_fallback_section() {
821 let config_str = r#"
823[ai]
824provider = "gemini"
825model = "gemini-3-flash-preview"
826"#;
827
828 let config = Config::builder()
829 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
830 .build()
831 .expect("should build config");
832
833 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
834
835 assert!(app_config.ai.fallback.is_none());
836 }
837
838 #[test]
839 fn test_fallback_config_default() {
840 let ai_config = AiConfig::default();
842 assert!(ai_config.fallback.is_none());
843 }
844}