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: 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, 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, 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, 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, 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]
341pub fn config_file_path() -> PathBuf {
342 config_dir().join("config.toml")
343}
344
345pub fn load_config() -> Result<AppConfig, AptuError> {
355 let config_path = config_file_path();
356
357 let config = Config::builder()
358 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
360 .add_source(
362 Environment::with_prefix("APTU")
363 .separator("__")
364 .try_parsing(true),
365 )
366 .build()?;
367
368 let app_config: AppConfig = config.try_deserialize()?;
369
370 Ok(app_config)
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn test_load_config_defaults() {
379 let config = load_config().expect("should load with defaults");
381
382 assert_eq!(config.ai.provider, "gemini");
383 assert_eq!(config.ai.model, "gemini-3-flash-preview");
384 assert_eq!(config.ai.timeout_seconds, 30);
385 assert_eq!(config.ai.max_tokens, 4096);
386 #[allow(clippy::float_cmp)]
387 {
388 assert_eq!(config.ai.temperature, 0.3);
389 }
390 assert_eq!(config.github.api_timeout_seconds, 10);
391 assert!(config.ui.color);
392 assert!(config.ui.confirm_before_post);
393 assert_eq!(config.cache.issue_ttl_minutes, 60);
394 }
395
396 #[test]
397 fn test_config_dir_exists() {
398 let dir = config_dir();
399 assert!(dir.ends_with("aptu"));
400 }
401
402 #[test]
403 fn test_data_dir_exists() {
404 let dir = data_dir();
405 assert!(dir.ends_with("aptu"));
406 }
407
408 #[test]
409 fn test_config_file_path() {
410 let path = config_file_path();
411 assert!(path.ends_with("config.toml"));
412 }
413
414 #[test]
415 fn test_config_with_task_triage_override() {
416 let config_str = r#"
418[ai]
419provider = "gemini"
420model = "gemini-3-flash-preview"
421
422[ai.tasks.triage]
423model = "gemini-2.5-flash-lite-preview-09-2025"
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 assert_eq!(app_config.ai.provider, "gemini");
434 assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
435 assert!(app_config.ai.tasks.is_some());
436
437 let tasks = app_config.ai.tasks.unwrap();
438 assert!(tasks.triage.is_some());
439 assert!(tasks.review.is_none());
440 assert!(tasks.create.is_none());
441
442 let triage = tasks.triage.unwrap();
443 assert_eq!(triage.provider, None);
444 assert_eq!(
445 triage.model,
446 Some("gemini-2.5-flash-lite-preview-09-2025".to_string())
447 );
448 }
449
450 #[test]
451 fn test_config_with_multiple_task_overrides() {
452 let config_str = r#"
454[ai]
455provider = "openrouter"
456model = "mistralai/devstral-2512:free"
457
458[ai.tasks.triage]
459model = "mistralai/devstral-2512:free"
460
461[ai.tasks.review]
462provider = "openrouter"
463model = "anthropic/claude-haiku-4.5"
464
465[ai.tasks.create]
466model = "anthropic/claude-sonnet-4.5"
467"#;
468
469 let config = Config::builder()
470 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
471 .build()
472 .expect("should build config");
473
474 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
475
476 let tasks = app_config.ai.tasks.expect("tasks should exist");
477
478 let triage = tasks.triage.expect("triage should exist");
480 assert_eq!(triage.provider, None);
481 assert_eq!(
482 triage.model,
483 Some("mistralai/devstral-2512:free".to_string())
484 );
485
486 let review = tasks.review.expect("review should exist");
488 assert_eq!(review.provider, Some("openrouter".to_string()));
489 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
490
491 let create = tasks.create.expect("create should exist");
493 assert_eq!(create.provider, None);
494 assert_eq!(
495 create.model,
496 Some("anthropic/claude-sonnet-4.5".to_string())
497 );
498 }
499
500 #[test]
501 fn test_config_with_partial_task_overrides() {
502 let config_str = r#"
504[ai]
505provider = "gemini"
506model = "gemini-3-flash-preview"
507
508[ai.tasks.triage]
509provider = "gemini"
510
511[ai.tasks.review]
512model = "gemini-3-pro-preview"
513"#;
514
515 let config = Config::builder()
516 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
517 .build()
518 .expect("should build config");
519
520 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
521
522 let tasks = app_config.ai.tasks.expect("tasks should exist");
523
524 let triage = tasks.triage.expect("triage should exist");
526 assert_eq!(triage.provider, Some("gemini".to_string()));
527 assert_eq!(triage.model, None);
528
529 let review = tasks.review.expect("review should exist");
531 assert_eq!(review.provider, None);
532 assert_eq!(review.model, Some("gemini-3-pro-preview".to_string()));
533 }
534
535 #[test]
536 fn test_config_without_tasks_section() {
537 let config_str = r#"
539[ai]
540provider = "gemini"
541model = "gemini-3-flash-preview"
542"#;
543
544 let config = Config::builder()
545 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
546 .build()
547 .expect("should build config");
548
549 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
550
551 assert_eq!(app_config.ai.provider, "gemini");
552 assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
553 assert!(app_config.ai.tasks.is_none());
554 }
555
556 #[test]
557 fn test_resolve_for_task_no_overrides() {
558 let ai_config = AiConfig::default();
560
561 let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
562 assert_eq!(provider, "gemini");
563 assert_eq!(model, "gemini-3-flash-preview");
564
565 let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
566 assert_eq!(provider, "gemini");
567 assert_eq!(model, "gemini-3-flash-preview");
568
569 let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
570 assert_eq!(provider, "gemini");
571 assert_eq!(model, "gemini-3-flash-preview");
572 }
573
574 #[test]
575 fn test_resolve_for_task_with_triage_override() {
576 let config_str = r#"
578[ai]
579provider = "gemini"
580model = "gemini-3-flash-preview"
581
582[ai.tasks.triage]
583model = "gemini-2.5-flash-lite-preview-09-2025"
584"#;
585
586 let config = Config::builder()
587 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
588 .build()
589 .expect("should build config");
590
591 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
592
593 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
595 assert_eq!(provider, "gemini");
596 assert_eq!(model, "gemini-2.5-flash-lite-preview-09-2025");
597
598 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
600 assert_eq!(provider, "gemini");
601 assert_eq!(model, "gemini-3-flash-preview");
602
603 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
604 assert_eq!(provider, "gemini");
605 assert_eq!(model, "gemini-3-flash-preview");
606 }
607
608 #[test]
609 fn test_resolve_for_task_with_provider_override() {
610 let config_str = r#"
612[ai]
613provider = "gemini"
614model = "gemini-3-flash-preview"
615
616[ai.tasks.review]
617provider = "openrouter"
618"#;
619
620 let config = Config::builder()
621 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
622 .build()
623 .expect("should build config");
624
625 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
626
627 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
629 assert_eq!(provider, "openrouter");
630 assert_eq!(model, "gemini-3-flash-preview");
631
632 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
634 assert_eq!(provider, "gemini");
635 assert_eq!(model, "gemini-3-flash-preview");
636
637 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
638 assert_eq!(provider, "gemini");
639 assert_eq!(model, "gemini-3-flash-preview");
640 }
641
642 #[test]
643 fn test_resolve_for_task_with_full_overrides() {
644 let config_str = r#"
646[ai]
647provider = "gemini"
648model = "gemini-3-flash-preview"
649
650[ai.tasks.triage]
651provider = "openrouter"
652model = "mistralai/devstral-2512:free"
653
654[ai.tasks.review]
655provider = "openrouter"
656model = "anthropic/claude-haiku-4.5"
657
658[ai.tasks.create]
659provider = "gemini"
660model = "gemini-3-pro-preview"
661"#;
662
663 let config = Config::builder()
664 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
665 .build()
666 .expect("should build config");
667
668 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
669
670 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
672 assert_eq!(provider, "openrouter");
673 assert_eq!(model, "mistralai/devstral-2512:free");
674
675 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
677 assert_eq!(provider, "openrouter");
678 assert_eq!(model, "anthropic/claude-haiku-4.5");
679
680 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
682 assert_eq!(provider, "gemini");
683 assert_eq!(model, "gemini-3-pro-preview");
684 }
685
686 #[test]
687 fn test_resolve_for_task_partial_overrides() {
688 let config_str = r#"
690[ai]
691provider = "openrouter"
692model = "mistralai/devstral-2512:free"
693
694[ai.tasks.triage]
695model = "mistralai/devstral-2512:free"
696
697[ai.tasks.review]
698provider = "openrouter"
699
700[ai.tasks.create]
701"#;
702
703 let config = Config::builder()
704 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
705 .build()
706 .expect("should build config");
707
708 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
709
710 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
712 assert_eq!(provider, "openrouter");
713 assert_eq!(model, "mistralai/devstral-2512:free");
714
715 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
717 assert_eq!(provider, "openrouter");
718 assert_eq!(model, "mistralai/devstral-2512:free");
719
720 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
722 assert_eq!(provider, "openrouter");
723 assert_eq!(model, "mistralai/devstral-2512:free");
724 }
725
726 #[test]
727 fn test_fallback_config_toml_parsing() {
728 let config_str = r#"
730[ai]
731provider = "gemini"
732model = "gemini-3-flash-preview"
733
734[ai.fallback]
735chain = ["openrouter", "anthropic"]
736"#;
737
738 let config = Config::builder()
739 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
740 .build()
741 .expect("should build config");
742
743 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
744
745 assert_eq!(app_config.ai.provider, "gemini");
746 assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
747 assert!(app_config.ai.fallback.is_some());
748
749 let fallback = app_config.ai.fallback.unwrap();
750 assert_eq!(fallback.chain.len(), 2);
751 assert_eq!(fallback.chain[0].provider, "openrouter");
752 assert_eq!(fallback.chain[1].provider, "anthropic");
753 }
754
755 #[test]
756 fn test_fallback_config_empty_chain() {
757 let config_str = r#"
759[ai]
760provider = "gemini"
761model = "gemini-3-flash-preview"
762
763[ai.fallback]
764chain = []
765"#;
766
767 let config = Config::builder()
768 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
769 .build()
770 .expect("should build config");
771
772 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
773
774 assert!(app_config.ai.fallback.is_some());
775 let fallback = app_config.ai.fallback.unwrap();
776 assert_eq!(fallback.chain.len(), 0);
777 }
778
779 #[test]
780 fn test_fallback_config_single_provider() {
781 let config_str = r#"
783[ai]
784provider = "gemini"
785model = "gemini-3-flash-preview"
786
787[ai.fallback]
788chain = ["openrouter"]
789"#;
790
791 let config = Config::builder()
792 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
793 .build()
794 .expect("should build config");
795
796 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
797
798 assert!(app_config.ai.fallback.is_some());
799 let fallback = app_config.ai.fallback.unwrap();
800 assert_eq!(fallback.chain.len(), 1);
801 assert_eq!(fallback.chain[0].provider, "openrouter");
802 }
803
804 #[test]
805 fn test_fallback_config_without_fallback_section() {
806 let config_str = r#"
808[ai]
809provider = "gemini"
810model = "gemini-3-flash-preview"
811"#;
812
813 let config = Config::builder()
814 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
815 .build()
816 .expect("should build config");
817
818 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
819
820 assert!(app_config.ai.fallback.is_none());
821 }
822
823 #[test]
824 fn test_fallback_config_default() {
825 let ai_config = AiConfig::default();
827 assert!(ai_config.fallback.is_none());
828 }
829}