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: "mistral/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]
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 tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
383 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
384 unsafe {
386 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
387 }
388 let config = load_config().expect("should load with defaults");
389 unsafe {
390 std::env::remove_var("XDG_CONFIG_HOME");
391 }
392
393 assert_eq!(config.ai.provider, "openrouter");
394 assert_eq!(config.ai.model, "mistral/mistral-small-2603");
395 assert_eq!(config.ai.timeout_seconds, 30);
396 assert_eq!(config.ai.max_tokens, 4096);
397 assert_eq!(config.ai.allow_paid_models, true);
398 #[allow(clippy::float_cmp)]
399 {
400 assert_eq!(config.ai.temperature, 0.3);
401 }
402 assert_eq!(config.github.api_timeout_seconds, 10);
403 assert!(config.ui.color);
404 assert!(config.ui.confirm_before_post);
405 assert_eq!(config.cache.issue_ttl_minutes, 60);
406 }
407
408 #[test]
409 fn test_config_dir_exists() {
410 let dir = config_dir();
411 assert!(dir.ends_with("aptu"));
412 }
413
414 #[test]
415 fn test_data_dir_exists() {
416 let dir = data_dir();
417 assert!(dir.ends_with("aptu"));
418 }
419
420 #[test]
421 fn test_config_file_path() {
422 let path = config_file_path();
423 assert!(path.ends_with("config.toml"));
424 }
425
426 #[test]
427 fn test_config_with_task_triage_override() {
428 let config_str = r#"
430[ai]
431provider = "gemini"
432model = "gemini-3.1-flash-lite-preview"
433
434[ai.tasks.triage]
435model = "gemini-2.5-flash-lite-preview-09-2025"
436"#;
437
438 let config = Config::builder()
439 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
440 .build()
441 .expect("should build config");
442
443 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
444
445 assert_eq!(app_config.ai.provider, "gemini");
446 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
447 assert!(app_config.ai.tasks.is_some());
448
449 let tasks = app_config.ai.tasks.unwrap();
450 assert!(tasks.triage.is_some());
451 assert!(tasks.review.is_none());
452 assert!(tasks.create.is_none());
453
454 let triage = tasks.triage.unwrap();
455 assert_eq!(triage.provider, None);
456 assert_eq!(
457 triage.model,
458 Some("gemini-2.5-flash-lite-preview-09-2025".to_string())
459 );
460 }
461
462 #[test]
463 fn test_config_with_multiple_task_overrides() {
464 let config_str = r#"
466[ai]
467provider = "openrouter"
468model = "mistralai/devstral-2512:free"
469
470[ai.tasks.triage]
471model = "mistralai/devstral-2512:free"
472
473[ai.tasks.review]
474provider = "openrouter"
475model = "anthropic/claude-haiku-4.5"
476
477[ai.tasks.create]
478model = "anthropic/claude-sonnet-4.5"
479"#;
480
481 let config = Config::builder()
482 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
483 .build()
484 .expect("should build config");
485
486 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
487
488 let tasks = app_config.ai.tasks.expect("tasks should exist");
489
490 let triage = tasks.triage.expect("triage should exist");
492 assert_eq!(triage.provider, None);
493 assert_eq!(
494 triage.model,
495 Some("mistralai/devstral-2512:free".to_string())
496 );
497
498 let review = tasks.review.expect("review should exist");
500 assert_eq!(review.provider, Some("openrouter".to_string()));
501 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
502
503 let create = tasks.create.expect("create should exist");
505 assert_eq!(create.provider, None);
506 assert_eq!(
507 create.model,
508 Some("anthropic/claude-sonnet-4.5".to_string())
509 );
510 }
511
512 #[test]
513 fn test_config_with_partial_task_overrides() {
514 let config_str = r#"
516[ai]
517provider = "gemini"
518model = "gemini-3.1-flash-lite-preview"
519
520[ai.tasks.triage]
521provider = "gemini"
522
523[ai.tasks.review]
524model = "gemini-3.1-flash-lite-preview"
525"#;
526
527 let config = Config::builder()
528 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
529 .build()
530 .expect("should build config");
531
532 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
533
534 let tasks = app_config.ai.tasks.expect("tasks should exist");
535
536 let triage = tasks.triage.expect("triage should exist");
538 assert_eq!(triage.provider, Some("gemini".to_string()));
539 assert_eq!(triage.model, None);
540
541 let review = tasks.review.expect("review should exist");
543 assert_eq!(review.provider, None);
544 assert_eq!(
545 review.model,
546 Some("gemini-3.1-flash-lite-preview".to_string())
547 );
548 }
549
550 #[test]
551 fn test_config_without_tasks_section() {
552 let config_str = r#"
554[ai]
555provider = "gemini"
556model = "gemini-3.1-flash-lite-preview"
557"#;
558
559 let config = Config::builder()
560 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
561 .build()
562 .expect("should build config");
563
564 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
565
566 assert_eq!(app_config.ai.provider, "gemini");
567 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
568 assert!(app_config.ai.tasks.is_none());
570 }
571
572 #[test]
573 fn test_resolve_for_task_with_defaults() {
574 let ai_config = AiConfig::default();
576
577 let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
579 assert_eq!(provider, "openrouter");
580 assert_eq!(model, "mistral/mistral-small-2603");
581 assert_eq!(ai_config.allow_paid_models, true);
582
583 let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
584 assert_eq!(provider, "openrouter");
585 assert_eq!(model, "mistral/mistral-small-2603");
586 assert_eq!(ai_config.allow_paid_models, true);
587
588 let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
589 assert_eq!(provider, "openrouter");
590 assert_eq!(model, "mistral/mistral-small-2603");
591 assert_eq!(ai_config.allow_paid_models, true);
592 }
593
594 #[test]
595 fn test_resolve_for_task_with_triage_override() {
596 let config_str = r#"
598[ai]
599provider = "gemini"
600model = "gemini-3.1-flash-lite-preview"
601
602[ai.tasks.triage]
603model = "gemini-2.5-flash-lite-preview-09-2025"
604"#;
605
606 let config = Config::builder()
607 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
608 .build()
609 .expect("should build config");
610
611 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
612
613 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
615 assert_eq!(provider, "gemini");
616 assert_eq!(model, "gemini-2.5-flash-lite-preview-09-2025");
617
618 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
620 assert_eq!(provider, "gemini");
621 assert_eq!(model, "gemini-3.1-flash-lite-preview");
622
623 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
624 assert_eq!(provider, "gemini");
625 assert_eq!(model, "gemini-3.1-flash-lite-preview");
626 }
627
628 #[test]
629 fn test_resolve_for_task_with_provider_override() {
630 let config_str = r#"
632[ai]
633provider = "gemini"
634model = "gemini-3.1-flash-lite-preview"
635
636[ai.tasks.review]
637provider = "openrouter"
638"#;
639
640 let config = Config::builder()
641 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
642 .build()
643 .expect("should build config");
644
645 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
646
647 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
649 assert_eq!(provider, "openrouter");
650 assert_eq!(model, "gemini-3.1-flash-lite-preview");
651
652 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
654 assert_eq!(provider, "gemini");
655 assert_eq!(model, "gemini-3.1-flash-lite-preview");
656
657 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
658 assert_eq!(provider, "gemini");
659 assert_eq!(model, "gemini-3.1-flash-lite-preview");
660 }
661
662 #[test]
663 fn test_resolve_for_task_with_full_overrides() {
664 let config_str = r#"
666[ai]
667provider = "gemini"
668model = "gemini-3.1-flash-lite-preview"
669
670[ai.tasks.triage]
671provider = "openrouter"
672model = "mistralai/devstral-2512:free"
673
674[ai.tasks.review]
675provider = "openrouter"
676model = "anthropic/claude-haiku-4.5"
677
678[ai.tasks.create]
679provider = "gemini"
680model = "gemini-3.1-flash-lite-preview"
681"#;
682
683 let config = Config::builder()
684 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
685 .build()
686 .expect("should build config");
687
688 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
689
690 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
692 assert_eq!(provider, "openrouter");
693 assert_eq!(model, "mistralai/devstral-2512:free");
694
695 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
697 assert_eq!(provider, "openrouter");
698 assert_eq!(model, "anthropic/claude-haiku-4.5");
699
700 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
702 assert_eq!(provider, "gemini");
703 assert_eq!(model, "gemini-3.1-flash-lite-preview");
704 }
705
706 #[test]
707 fn test_resolve_for_task_partial_overrides() {
708 let config_str = r#"
710[ai]
711provider = "openrouter"
712model = "mistralai/devstral-2512:free"
713
714[ai.tasks.triage]
715model = "mistralai/devstral-2512:free"
716
717[ai.tasks.review]
718provider = "openrouter"
719
720[ai.tasks.create]
721"#;
722
723 let config = Config::builder()
724 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
725 .build()
726 .expect("should build config");
727
728 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
729
730 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
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::Review);
737 assert_eq!(provider, "openrouter");
738 assert_eq!(model, "mistralai/devstral-2512:free");
739
740 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
742 assert_eq!(provider, "openrouter");
743 assert_eq!(model, "mistralai/devstral-2512:free");
744 }
745
746 #[test]
747 fn test_fallback_config_toml_parsing() {
748 let config_str = r#"
750[ai]
751provider = "gemini"
752model = "gemini-3.1-flash-lite-preview"
753
754[ai.fallback]
755chain = ["openrouter", "anthropic"]
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_eq!(app_config.ai.provider, "gemini");
766 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
767 assert!(app_config.ai.fallback.is_some());
768
769 let fallback = app_config.ai.fallback.unwrap();
770 assert_eq!(fallback.chain.len(), 2);
771 assert_eq!(fallback.chain[0].provider, "openrouter");
772 assert_eq!(fallback.chain[1].provider, "anthropic");
773 }
774
775 #[test]
776 fn test_fallback_config_empty_chain() {
777 let config_str = r#"
779[ai]
780provider = "gemini"
781model = "gemini-3.1-flash-lite-preview"
782
783[ai.fallback]
784chain = []
785"#;
786
787 let config = Config::builder()
788 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
789 .build()
790 .expect("should build config");
791
792 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
793
794 assert!(app_config.ai.fallback.is_some());
795 let fallback = app_config.ai.fallback.unwrap();
796 assert_eq!(fallback.chain.len(), 0);
797 }
798
799 #[test]
800 fn test_fallback_config_single_provider() {
801 let config_str = r#"
803[ai]
804provider = "gemini"
805model = "gemini-3.1-flash-lite-preview"
806
807[ai.fallback]
808chain = ["openrouter"]
809"#;
810
811 let config = Config::builder()
812 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
813 .build()
814 .expect("should build config");
815
816 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
817
818 assert!(app_config.ai.fallback.is_some());
819 let fallback = app_config.ai.fallback.unwrap();
820 assert_eq!(fallback.chain.len(), 1);
821 assert_eq!(fallback.chain[0].provider, "openrouter");
822 }
823
824 #[test]
825 fn test_fallback_config_without_fallback_section() {
826 let config_str = r#"
828[ai]
829provider = "gemini"
830model = "gemini-3.1-flash-lite-preview"
831"#;
832
833 let config = Config::builder()
834 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
835 .build()
836 .expect("should build config");
837
838 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
839
840 assert!(app_config.ai.fallback.is_none());
841 }
842
843 #[test]
844 fn test_fallback_config_default() {
845 let ai_config = AiConfig::default();
847 assert!(ai_config.fallback.is_none());
848 }
849}