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
129#[derive(Debug, Deserialize, Clone)]
131#[serde(default)]
132pub struct AiConfig {
133 pub provider: String,
135 pub model: String,
137 pub timeout_seconds: u64,
139 pub allow_paid_models: bool,
141 pub max_tokens: u32,
143 pub temperature: f32,
145 pub circuit_breaker_threshold: u32,
147 pub circuit_breaker_reset_seconds: u64,
149 pub tasks: Option<TasksConfig>,
151 pub fallback: Option<FallbackConfig>,
153 pub custom_guidance: Option<String>,
159 pub validation_enabled: bool,
165}
166
167impl Default for AiConfig {
168 fn default() -> Self {
169 Self {
170 provider: "gemini".to_string(),
171 model: "gemini-3-flash-preview".to_string(),
172 timeout_seconds: 30,
173 allow_paid_models: false,
174 max_tokens: 4096,
175 temperature: 0.3,
176 circuit_breaker_threshold: 3,
177 circuit_breaker_reset_seconds: 60,
178 tasks: None,
179 fallback: None,
180 custom_guidance: None,
181 validation_enabled: true,
182 }
183 }
184}
185
186impl AiConfig {
187 #[must_use]
200 pub fn resolve_for_task(&self, task: TaskType) -> (String, String) {
201 let task_override = match task {
202 TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
203 TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
204 TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
205 };
206
207 let provider = task_override
208 .and_then(|o| o.provider.clone())
209 .unwrap_or_else(|| self.provider.clone());
210
211 let model = task_override
212 .and_then(|o| o.model.clone())
213 .unwrap_or_else(|| self.model.clone());
214
215 (provider, model)
216 }
217}
218
219#[derive(Debug, Deserialize, Clone)]
221#[serde(default)]
222pub struct GitHubConfig {
223 pub api_timeout_seconds: u64,
225}
226
227impl Default for GitHubConfig {
228 fn default() -> Self {
229 Self {
230 api_timeout_seconds: 10,
231 }
232 }
233}
234
235#[derive(Debug, Deserialize, Clone)]
237#[serde(default)]
238pub struct UiConfig {
239 pub color: bool,
241 pub progress_bars: bool,
243 pub confirm_before_post: bool,
245}
246
247impl Default for UiConfig {
248 fn default() -> Self {
249 Self {
250 color: true,
251 progress_bars: true,
252 confirm_before_post: true,
253 }
254 }
255}
256
257#[derive(Debug, Deserialize, Clone)]
259#[serde(default)]
260pub struct CacheConfig {
261 pub issue_ttl_minutes: u64,
263 pub repo_ttl_hours: u64,
265 pub curated_repos_url: String,
267}
268
269impl Default for CacheConfig {
270 fn default() -> Self {
271 Self {
272 issue_ttl_minutes: 60,
273 repo_ttl_hours: 24,
274 curated_repos_url:
275 "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
276 .to_string(),
277 }
278 }
279}
280
281#[derive(Debug, Deserialize, Clone)]
283#[serde(default)]
284pub struct ReposConfig {
285 pub curated: bool,
287}
288
289impl Default for ReposConfig {
290 fn default() -> Self {
291 Self { curated: true }
292 }
293}
294
295#[must_use]
300pub fn config_dir() -> PathBuf {
301 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
302 && !xdg_config.is_empty()
303 {
304 return PathBuf::from(xdg_config).join("aptu");
305 }
306 dirs::home_dir()
307 .expect("Could not determine home directory - is HOME set?")
308 .join(".config")
309 .join("aptu")
310}
311
312#[must_use]
317pub fn data_dir() -> PathBuf {
318 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
319 && !xdg_data.is_empty()
320 {
321 return PathBuf::from(xdg_data).join("aptu");
322 }
323 dirs::home_dir()
324 .expect("Could not determine home directory - is HOME set?")
325 .join(".local")
326 .join("share")
327 .join("aptu")
328}
329
330#[must_use]
332pub fn config_file_path() -> PathBuf {
333 config_dir().join("config.toml")
334}
335
336pub fn load_config() -> Result<AppConfig, AptuError> {
346 let config_path = config_file_path();
347
348 let config = Config::builder()
349 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
351 .add_source(
353 Environment::with_prefix("APTU")
354 .separator("__")
355 .try_parsing(true),
356 )
357 .build()?;
358
359 let app_config: AppConfig = config.try_deserialize()?;
360
361 Ok(app_config)
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_load_config_defaults() {
370 let config = load_config().expect("should load with defaults");
372
373 assert_eq!(config.ai.provider, "gemini");
374 assert_eq!(config.ai.model, "gemini-3-flash-preview");
375 assert_eq!(config.ai.timeout_seconds, 30);
376 assert_eq!(config.ai.max_tokens, 4096);
377 #[allow(clippy::float_cmp)]
378 {
379 assert_eq!(config.ai.temperature, 0.3);
380 }
381 assert_eq!(config.github.api_timeout_seconds, 10);
382 assert!(config.ui.color);
383 assert!(config.ui.confirm_before_post);
384 assert_eq!(config.cache.issue_ttl_minutes, 60);
385 }
386
387 #[test]
388 fn test_config_dir_exists() {
389 let dir = config_dir();
390 assert!(dir.ends_with("aptu"));
391 }
392
393 #[test]
394 fn test_data_dir_exists() {
395 let dir = data_dir();
396 assert!(dir.ends_with("aptu"));
397 }
398
399 #[test]
400 fn test_config_file_path() {
401 let path = config_file_path();
402 assert!(path.ends_with("config.toml"));
403 }
404
405 #[test]
406 fn test_config_with_task_triage_override() {
407 let config_str = r#"
409[ai]
410provider = "gemini"
411model = "gemini-3-flash-preview"
412
413[ai.tasks.triage]
414model = "gemini-2.5-flash-lite-preview-09-2025"
415"#;
416
417 let config = Config::builder()
418 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
419 .build()
420 .expect("should build config");
421
422 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
423
424 assert_eq!(app_config.ai.provider, "gemini");
425 assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
426 assert!(app_config.ai.tasks.is_some());
427
428 let tasks = app_config.ai.tasks.unwrap();
429 assert!(tasks.triage.is_some());
430 assert!(tasks.review.is_none());
431 assert!(tasks.create.is_none());
432
433 let triage = tasks.triage.unwrap();
434 assert_eq!(triage.provider, None);
435 assert_eq!(
436 triage.model,
437 Some("gemini-2.5-flash-lite-preview-09-2025".to_string())
438 );
439 }
440
441 #[test]
442 fn test_config_with_multiple_task_overrides() {
443 let config_str = r#"
445[ai]
446provider = "openrouter"
447model = "mistralai/devstral-2512:free"
448
449[ai.tasks.triage]
450model = "mistralai/devstral-2512:free"
451
452[ai.tasks.review]
453provider = "openrouter"
454model = "anthropic/claude-haiku-4.5"
455
456[ai.tasks.create]
457model = "anthropic/claude-sonnet-4.5"
458"#;
459
460 let config = Config::builder()
461 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
462 .build()
463 .expect("should build config");
464
465 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
466
467 let tasks = app_config.ai.tasks.expect("tasks should exist");
468
469 let triage = tasks.triage.expect("triage should exist");
471 assert_eq!(triage.provider, None);
472 assert_eq!(
473 triage.model,
474 Some("mistralai/devstral-2512:free".to_string())
475 );
476
477 let review = tasks.review.expect("review should exist");
479 assert_eq!(review.provider, Some("openrouter".to_string()));
480 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
481
482 let create = tasks.create.expect("create should exist");
484 assert_eq!(create.provider, None);
485 assert_eq!(
486 create.model,
487 Some("anthropic/claude-sonnet-4.5".to_string())
488 );
489 }
490
491 #[test]
492 fn test_config_with_partial_task_overrides() {
493 let config_str = r#"
495[ai]
496provider = "gemini"
497model = "gemini-3-flash-preview"
498
499[ai.tasks.triage]
500provider = "gemini"
501
502[ai.tasks.review]
503model = "gemini-3-pro-preview"
504"#;
505
506 let config = Config::builder()
507 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
508 .build()
509 .expect("should build config");
510
511 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
512
513 let tasks = app_config.ai.tasks.expect("tasks should exist");
514
515 let triage = tasks.triage.expect("triage should exist");
517 assert_eq!(triage.provider, Some("gemini".to_string()));
518 assert_eq!(triage.model, None);
519
520 let review = tasks.review.expect("review should exist");
522 assert_eq!(review.provider, None);
523 assert_eq!(review.model, Some("gemini-3-pro-preview".to_string()));
524 }
525
526 #[test]
527 fn test_config_without_tasks_section() {
528 let config_str = r#"
530[ai]
531provider = "gemini"
532model = "gemini-3-flash-preview"
533"#;
534
535 let config = Config::builder()
536 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
537 .build()
538 .expect("should build config");
539
540 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
541
542 assert_eq!(app_config.ai.provider, "gemini");
543 assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
544 assert!(app_config.ai.tasks.is_none());
545 }
546
547 #[test]
548 fn test_resolve_for_task_no_overrides() {
549 let ai_config = AiConfig::default();
551
552 let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
553 assert_eq!(provider, "gemini");
554 assert_eq!(model, "gemini-3-flash-preview");
555
556 let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
557 assert_eq!(provider, "gemini");
558 assert_eq!(model, "gemini-3-flash-preview");
559
560 let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
561 assert_eq!(provider, "gemini");
562 assert_eq!(model, "gemini-3-flash-preview");
563 }
564
565 #[test]
566 fn test_resolve_for_task_with_triage_override() {
567 let config_str = r#"
569[ai]
570provider = "gemini"
571model = "gemini-3-flash-preview"
572
573[ai.tasks.triage]
574model = "gemini-2.5-flash-lite-preview-09-2025"
575"#;
576
577 let config = Config::builder()
578 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
579 .build()
580 .expect("should build config");
581
582 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
583
584 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
586 assert_eq!(provider, "gemini");
587 assert_eq!(model, "gemini-2.5-flash-lite-preview-09-2025");
588
589 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
591 assert_eq!(provider, "gemini");
592 assert_eq!(model, "gemini-3-flash-preview");
593
594 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
595 assert_eq!(provider, "gemini");
596 assert_eq!(model, "gemini-3-flash-preview");
597 }
598
599 #[test]
600 fn test_resolve_for_task_with_provider_override() {
601 let config_str = r#"
603[ai]
604provider = "gemini"
605model = "gemini-3-flash-preview"
606
607[ai.tasks.review]
608provider = "openrouter"
609"#;
610
611 let config = Config::builder()
612 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
613 .build()
614 .expect("should build config");
615
616 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
617
618 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
620 assert_eq!(provider, "openrouter");
621 assert_eq!(model, "gemini-3-flash-preview");
622
623 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
625 assert_eq!(provider, "gemini");
626 assert_eq!(model, "gemini-3-flash-preview");
627
628 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
629 assert_eq!(provider, "gemini");
630 assert_eq!(model, "gemini-3-flash-preview");
631 }
632
633 #[test]
634 fn test_resolve_for_task_with_full_overrides() {
635 let config_str = r#"
637[ai]
638provider = "gemini"
639model = "gemini-3-flash-preview"
640
641[ai.tasks.triage]
642provider = "openrouter"
643model = "mistralai/devstral-2512:free"
644
645[ai.tasks.review]
646provider = "openrouter"
647model = "anthropic/claude-haiku-4.5"
648
649[ai.tasks.create]
650provider = "gemini"
651model = "gemini-3-pro-preview"
652"#;
653
654 let config = Config::builder()
655 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
656 .build()
657 .expect("should build config");
658
659 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
660
661 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
663 assert_eq!(provider, "openrouter");
664 assert_eq!(model, "mistralai/devstral-2512:free");
665
666 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
668 assert_eq!(provider, "openrouter");
669 assert_eq!(model, "anthropic/claude-haiku-4.5");
670
671 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
673 assert_eq!(provider, "gemini");
674 assert_eq!(model, "gemini-3-pro-preview");
675 }
676
677 #[test]
678 fn test_resolve_for_task_partial_overrides() {
679 let config_str = r#"
681[ai]
682provider = "openrouter"
683model = "mistralai/devstral-2512:free"
684
685[ai.tasks.triage]
686model = "mistralai/devstral-2512:free"
687
688[ai.tasks.review]
689provider = "openrouter"
690
691[ai.tasks.create]
692"#;
693
694 let config = Config::builder()
695 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
696 .build()
697 .expect("should build config");
698
699 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
700
701 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
703 assert_eq!(provider, "openrouter");
704 assert_eq!(model, "mistralai/devstral-2512:free");
705
706 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
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::Create);
713 assert_eq!(provider, "openrouter");
714 assert_eq!(model, "mistralai/devstral-2512:free");
715 }
716
717 #[test]
718 fn test_fallback_config_toml_parsing() {
719 let config_str = r#"
721[ai]
722provider = "gemini"
723model = "gemini-3-flash-preview"
724
725[ai.fallback]
726chain = ["openrouter", "anthropic"]
727"#;
728
729 let config = Config::builder()
730 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
731 .build()
732 .expect("should build config");
733
734 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
735
736 assert_eq!(app_config.ai.provider, "gemini");
737 assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
738 assert!(app_config.ai.fallback.is_some());
739
740 let fallback = app_config.ai.fallback.unwrap();
741 assert_eq!(fallback.chain.len(), 2);
742 assert_eq!(fallback.chain[0].provider, "openrouter");
743 assert_eq!(fallback.chain[1].provider, "anthropic");
744 }
745
746 #[test]
747 fn test_fallback_config_empty_chain() {
748 let config_str = r#"
750[ai]
751provider = "gemini"
752model = "gemini-3-flash-preview"
753
754[ai.fallback]
755chain = []
756"#;
757
758 let config = Config::builder()
759 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
760 .build()
761 .expect("should build config");
762
763 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
764
765 assert!(app_config.ai.fallback.is_some());
766 let fallback = app_config.ai.fallback.unwrap();
767 assert_eq!(fallback.chain.len(), 0);
768 }
769
770 #[test]
771 fn test_fallback_config_single_provider() {
772 let config_str = r#"
774[ai]
775provider = "gemini"
776model = "gemini-3-flash-preview"
777
778[ai.fallback]
779chain = ["openrouter"]
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(), 1);
792 assert_eq!(fallback.chain[0].provider, "openrouter");
793 }
794
795 #[test]
796 fn test_fallback_config_without_fallback_section() {
797 let config_str = r#"
799[ai]
800provider = "gemini"
801model = "gemini-3-flash-preview"
802"#;
803
804 let config = Config::builder()
805 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
806 .build()
807 .expect("should build config");
808
809 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
810
811 assert!(app_config.ai.fallback.is_none());
812 }
813
814 #[test]
815 fn test_fallback_config_default() {
816 let ai_config = AiConfig::default();
818 assert!(ai_config.fallback.is_none());
819 }
820}