1use std::path::PathBuf;
6
7use config::{Config, Environment, File};
8use serde::{Deserialize, Serialize};
9
10use crate::error::AptuError;
11
12use super::{AiConfig, CacheConfig, ReposConfig, ReviewConfig};
13
14pub trait ConfigSource: Send + Sync {
20 fn load(&self) -> Result<AppConfig, AptuError>;
26}
27
28pub struct InMemoryConfigSource(pub AppConfig);
33
34impl ConfigSource for InMemoryConfigSource {
35 fn load(&self) -> Result<AppConfig, AptuError> {
36 Ok(self.0.clone())
37 }
38}
39
40#[cfg(not(target_arch = "wasm32"))]
45pub struct TomlConfigSource;
46
47#[cfg(not(target_arch = "wasm32"))]
48impl TomlConfigSource {
49 #[must_use]
51 pub fn new() -> Self {
52 Self
53 }
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57impl Default for TomlConfigSource {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63#[cfg(not(target_arch = "wasm32"))]
64impl ConfigSource for TomlConfigSource {
65 fn load(&self) -> Result<AppConfig, AptuError> {
66 let config_path = config_file_path();
67
68 let config = Config::builder()
69 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
71 .add_source(
73 Environment::with_prefix("APTU")
74 .prefix_separator("_")
75 .separator("__")
76 .try_parsing(true),
77 )
78 .build()?;
79
80 let app_config: AppConfig = config.try_deserialize()?;
81
82 app_config
84 .cache
85 .validate()
86 .map_err(|e| AptuError::Config { message: e })?;
87
88 for warning in app_config.review.validate_consistency() {
90 tracing::warn!("{}", warning);
91 }
92
93 Ok(app_config)
94 }
95}
96
97#[derive(Debug, Deserialize, Serialize, Default, Clone)]
99#[serde(default)]
100pub struct UserConfig {
101 pub default_repo: Option<String>,
103}
104
105#[derive(Debug, Deserialize, Serialize, Clone)]
107#[serde(default)]
108pub struct GitHubConfig {
109 pub api_timeout_seconds: u64,
111}
112
113impl Default for GitHubConfig {
114 fn default() -> Self {
115 Self {
116 api_timeout_seconds: 10,
117 }
118 }
119}
120
121#[derive(Debug, Deserialize, Serialize, Clone)]
123#[serde(default)]
124pub struct UiConfig {
125 pub color: bool,
127 pub progress_bars: bool,
129 pub confirm_before_post: bool,
131}
132
133impl Default for UiConfig {
134 fn default() -> Self {
135 Self {
136 color: true,
137 progress_bars: true,
138 confirm_before_post: true,
139 }
140 }
141}
142
143#[derive(Debug, Deserialize, Serialize, Clone)]
147#[serde(default)]
148pub struct PromptConfig {
149 pub max_issue_body_bytes: usize,
156 pub max_diff_bytes: usize,
163 pub max_commit_message_bytes: usize,
169}
170
171impl Default for PromptConfig {
172 fn default() -> Self {
173 Self {
174 max_issue_body_bytes: 32_768,
175 max_diff_bytes: 524_288,
176 max_commit_message_bytes: 4_096,
177 }
178 }
179}
180
181#[derive(Debug, Default, Deserialize, Serialize, Clone)]
183#[serde(default)]
184pub struct AppConfig {
185 pub user: UserConfig,
187 pub ai: AiConfig,
189 pub github: GitHubConfig,
191 pub ui: UiConfig,
193 pub cache: CacheConfig,
195 pub repos: ReposConfig,
197 #[serde(default)]
199 pub review: ReviewConfig,
200 #[serde(default)]
202 pub graph: crate::config::GraphConfig,
203 #[serde(default)]
205 pub prompt: PromptConfig,
206}
207
208#[must_use]
213pub fn config_dir() -> PathBuf {
214 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
215 && !xdg_config.is_empty()
216 {
217 return PathBuf::from(xdg_config).join("aptu");
218 }
219 dirs::home_dir()
220 .expect("Could not determine home directory - is HOME set?")
221 .join(".config")
222 .join("aptu")
223}
224
225#[must_use]
230pub fn data_dir() -> PathBuf {
231 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
232 && !xdg_data.is_empty()
233 {
234 return PathBuf::from(xdg_data).join("aptu");
235 }
236 dirs::home_dir()
237 .expect("Could not determine home directory - is HOME set?")
238 .join(".local")
239 .join("share")
240 .join("aptu")
241}
242
243#[must_use]
251pub fn prompts_dir() -> PathBuf {
252 config_dir().join("prompts")
253}
254
255#[must_use]
257pub fn config_file_path() -> PathBuf {
258 config_dir().join("config.toml")
259}
260
261#[cfg(not(target_arch = "wasm32"))]
273pub fn load_config() -> Result<AppConfig, AptuError> {
274 TomlConfigSource::new().load()
275}
276
277#[cfg(test)]
278mod tests {
279 #![allow(unsafe_code)]
280 use super::*;
281 use serial_test::serial;
282
283 #[test]
284 #[serial]
285 fn test_load_config_defaults() {
286 let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
290 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
291 unsafe {
293 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
294 }
295 let config = load_config().expect("should load with defaults");
296 unsafe {
297 std::env::remove_var("XDG_CONFIG_HOME");
298 }
299
300 assert_eq!(config.ai.provider, "openrouter");
301 assert_eq!(config.ai.model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
302 assert_eq!(config.ai.timeout_seconds, 30);
303 assert_eq!(config.ai.max_tokens, 4096);
304 assert!(config.ai.allow_paid_models);
305 #[allow(clippy::float_cmp)]
306 {
307 assert_eq!(config.ai.temperature, 0.3);
308 }
309 assert_eq!(config.github.api_timeout_seconds, 10);
310 assert!(config.ui.color);
311 assert!(config.ui.confirm_before_post);
312 assert_eq!(config.cache.issue_ttl_minutes, 60);
313 }
314
315 #[test]
316 fn test_config_dir_exists() {
317 let dir = config_dir();
318 assert!(dir.ends_with("aptu"));
319 }
320
321 #[test]
322 fn test_data_dir_exists() {
323 let dir = data_dir();
324 assert!(dir.ends_with("aptu"));
325 }
326
327 #[test]
328 fn test_config_file_path() {
329 let path = config_file_path();
330 assert!(path.ends_with("config.toml"));
331 }
332
333 #[test]
334 fn test_config_with_task_triage_override() {
335 let config_str = r#"
337[ai]
338provider = "gemini"
339model = "gemini-3.1-flash-lite"
340
341[ai.tasks.triage]
342model = "gemini-3.1-flash-lite"
343"#;
344
345 let config = Config::builder()
346 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
347 .build()
348 .expect("should build config");
349
350 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
351
352 assert_eq!(app_config.ai.provider, "gemini");
353 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
354 assert!(app_config.ai.tasks.is_some());
355
356 let tasks = app_config.ai.tasks.unwrap();
357 assert!(tasks.triage.is_some());
358 assert!(tasks.review.is_none());
359 assert!(tasks.create.is_none());
360
361 let triage = tasks.triage.unwrap();
362 assert_eq!(triage.provider, None);
363 assert_eq!(
364 triage.model,
365 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
366 );
367 }
368
369 #[test]
370 fn test_config_with_multiple_task_overrides() {
371 let config_str = r#"
373[ai]
374provider = "openrouter"
375model = "mistralai/mistral-small-2603"
376
377[ai.tasks.triage]
378model = "mistralai/mistral-small-2603"
379
380[ai.tasks.review]
381provider = "openrouter"
382model = "anthropic/claude-haiku-4.5"
383
384[ai.tasks.create]
385model = "anthropic/claude-sonnet-4.6"
386"#;
387
388 let config = Config::builder()
389 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
390 .build()
391 .expect("should build config");
392
393 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
394
395 let tasks = app_config.ai.tasks.expect("tasks should exist");
396
397 let triage = tasks.triage.expect("triage should exist");
399 assert_eq!(triage.provider, None);
400 assert_eq!(
401 triage.model,
402 Some(super::super::ai::DEFAULT_OPENROUTER_MODEL.to_string())
403 );
404
405 let review = tasks.review.expect("review should exist");
407 assert_eq!(review.provider, Some("openrouter".to_string()));
408 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
409
410 let create = tasks.create.expect("create should exist");
412 assert_eq!(create.provider, None);
413 assert_eq!(
414 create.model,
415 Some("anthropic/claude-sonnet-4.6".to_string())
416 );
417 }
418
419 #[test]
420 fn test_config_with_partial_task_overrides() {
421 let config_str = r#"
423[ai]
424provider = "gemini"
425model = "gemini-3.1-flash-lite"
426
427[ai.tasks.triage]
428provider = "gemini"
429
430[ai.tasks.review]
431model = "gemini-3.1-flash-lite"
432"#;
433
434 let config = Config::builder()
435 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
436 .build()
437 .expect("should build config");
438
439 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
440
441 let tasks = app_config.ai.tasks.expect("tasks should exist");
442
443 let triage = tasks.triage.expect("triage should exist");
445 assert_eq!(triage.provider, Some("gemini".to_string()));
446 assert_eq!(triage.model, None);
447
448 let review = tasks.review.expect("review should exist");
450 assert_eq!(review.provider, None);
451 assert_eq!(
452 review.model,
453 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
454 );
455 }
456
457 #[test]
458 fn test_config_without_tasks_section() {
459 let config_str = r#"
461[ai]
462provider = "gemini"
463model = "gemini-3.1-flash-lite"
464"#;
465
466 let config = Config::builder()
467 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
468 .build()
469 .expect("should build config");
470
471 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
472
473 assert_eq!(app_config.ai.provider, "gemini");
474 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
475 assert!(app_config.ai.tasks.is_none());
477 }
478
479 #[test]
480 fn test_resolve_for_task_with_defaults() {
481 let ai_config = AiConfig::default();
483
484 let (provider, model) =
486 ai_config.resolve_for_task(super::super::ai::TaskType::Triage, None);
487 assert_eq!(provider, "openrouter");
488 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
489 assert!(ai_config.allow_paid_models);
490
491 let (provider, model) =
492 ai_config.resolve_for_task(super::super::ai::TaskType::Review, None);
493 assert_eq!(provider, "openrouter");
494 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
495 assert!(ai_config.allow_paid_models);
496
497 let (provider, model) =
498 ai_config.resolve_for_task(super::super::ai::TaskType::Create, None);
499 assert_eq!(provider, "openrouter");
500 assert_eq!(model, "mistralai/mistral-small-2603");
501 assert!(ai_config.allow_paid_models);
502 }
503
504 #[test]
505 fn test_resolve_for_task_with_triage_override() {
506 let config_str = r#"
508[ai]
509provider = "gemini"
510model = "gemini-3.1-flash-lite"
511
512[ai.tasks.triage]
513model = "gemini-3.1-flash-lite"
514"#;
515
516 let config = Config::builder()
517 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
518 .build()
519 .expect("should build config");
520
521 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
522
523 let (provider, model) = app_config
525 .ai
526 .resolve_for_task(super::super::ai::TaskType::Triage, None);
527 assert_eq!(provider, "gemini");
528 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
529
530 let (provider, model) = app_config
532 .ai
533 .resolve_for_task(super::super::ai::TaskType::Review, None);
534 assert_eq!(provider, "gemini");
535 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
536
537 let (provider, model) = app_config
538 .ai
539 .resolve_for_task(super::super::ai::TaskType::Create, None);
540 assert_eq!(provider, "gemini");
541 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
542 }
543
544 #[test]
545 fn test_config_with_provider_override() {
546 let config_str = r#"
548[ai]
549provider = "gemini"
550model = "gemini-3.1-flash-lite"
551
552[ai.tasks.review]
553provider = "openrouter"
554"#;
555
556 let config = Config::builder()
557 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
558 .build()
559 .expect("should build config");
560
561 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
562
563 let (provider, model) = app_config
565 .ai
566 .resolve_for_task(super::super::ai::TaskType::Review, None);
567 assert_eq!(provider, "openrouter");
568 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
569
570 let (provider, model) = app_config
572 .ai
573 .resolve_for_task(super::super::ai::TaskType::Triage, None);
574 assert_eq!(provider, "gemini");
575 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
576
577 let (provider, model) = app_config
578 .ai
579 .resolve_for_task(super::super::ai::TaskType::Create, None);
580 assert_eq!(provider, "gemini");
581 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
582 }
583
584 #[test]
585 fn test_config_with_full_overrides() {
586 let config_str = r#"
588[ai]
589provider = "gemini"
590model = "gemini-3.1-flash-lite"
591
592[ai.tasks.triage]
593provider = "openrouter"
594model = "mistralai/mistral-small-2603"
595
596[ai.tasks.review]
597provider = "openrouter"
598model = "anthropic/claude-haiku-4.5"
599
600[ai.tasks.create]
601provider = "gemini"
602model = "gemini-3.1-flash-lite"
603"#;
604
605 let config = Config::builder()
606 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
607 .build()
608 .expect("should build config");
609
610 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
611
612 let (provider, model) = app_config
614 .ai
615 .resolve_for_task(super::super::ai::TaskType::Triage, None);
616 assert_eq!(provider, "openrouter");
617 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
618
619 let (provider, model) = app_config
621 .ai
622 .resolve_for_task(super::super::ai::TaskType::Review, None);
623 assert_eq!(provider, "openrouter");
624 assert_eq!(model, "anthropic/claude-haiku-4.5");
625
626 let (provider, model) = app_config
628 .ai
629 .resolve_for_task(super::super::ai::TaskType::Create, None);
630 assert_eq!(provider, "gemini");
631 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
632 }
633
634 #[test]
635 fn test_resolve_for_task_partial_overrides() {
636 let config_str = r#"
638[ai]
639provider = "openrouter"
640model = "mistralai/mistral-small-2603"
641
642[ai.tasks.triage]
643model = "mistralai/mistral-small-2603"
644
645[ai.tasks.review]
646provider = "openrouter"
647
648[ai.tasks.create]
649"#;
650
651 let config = Config::builder()
652 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
653 .build()
654 .expect("should build config");
655
656 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
657
658 let (provider, model) = app_config
660 .ai
661 .resolve_for_task(super::super::ai::TaskType::Triage, None);
662 assert_eq!(provider, "openrouter");
663 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
664
665 let (provider, model) = app_config
667 .ai
668 .resolve_for_task(super::super::ai::TaskType::Review, None);
669 assert_eq!(provider, "openrouter");
670 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
671
672 let (provider, model) = app_config
674 .ai
675 .resolve_for_task(super::super::ai::TaskType::Create, None);
676 assert_eq!(provider, "openrouter");
677 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
678 }
679
680 #[test]
681 fn test_fallback_config_toml_parsing() {
682 let config_str = r#"
684[ai]
685provider = "gemini"
686model = "gemini-3.1-flash-lite"
687
688[ai.fallback]
689chain = ["openrouter", "anthropic"]
690"#;
691
692 let config = Config::builder()
693 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
694 .build()
695 .expect("should build config");
696
697 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
698
699 assert_eq!(app_config.ai.provider, "gemini");
700 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite");
701 assert!(app_config.ai.fallback.is_some());
702
703 let fallback = app_config.ai.fallback.unwrap();
704 assert_eq!(fallback.chain.len(), 2);
705 assert_eq!(fallback.chain[0].provider, "openrouter");
706 assert_eq!(fallback.chain[1].provider, "anthropic");
707 }
708
709 #[test]
710 fn test_fallback_config_empty_chain() {
711 let config_str = r#"
713[ai]
714provider = "gemini"
715model = "gemini-3.1-flash-lite"
716
717[ai.fallback]
718chain = []
719"#;
720
721 let config = Config::builder()
722 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
723 .build()
724 .expect("should build config");
725
726 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
727
728 assert!(app_config.ai.fallback.is_some());
729 let fallback = app_config.ai.fallback.unwrap();
730 assert_eq!(fallback.chain.len(), 0);
731 }
732
733 #[test]
734 fn test_fallback_config_single_provider() {
735 let config_str = r#"
737[ai]
738provider = "gemini"
739model = "gemini-3.1-flash-lite"
740
741[ai.fallback]
742chain = ["openrouter"]
743"#;
744
745 let config = Config::builder()
746 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
747 .build()
748 .expect("should build config");
749
750 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
751
752 assert!(app_config.ai.fallback.is_some());
753 let fallback = app_config.ai.fallback.unwrap();
754 assert_eq!(fallback.chain.len(), 1);
755 assert_eq!(fallback.chain[0].provider, "openrouter");
756 }
757
758 #[test]
759 fn test_fallback_config_without_fallback_section() {
760 let config_str = r#"
762[ai]
763provider = "gemini"
764model = "gemini-3.1-flash-lite"
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_none());
775 }
776
777 #[test]
778 fn test_fallback_config_default() {
779 let ai_config = AiConfig::default();
781 assert!(ai_config.fallback.is_none());
782 }
783
784 #[test]
785 #[serial]
786 fn test_load_config_env_var_override() {
787 let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
789 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
790 unsafe {
792 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
793 std::env::set_var("APTU_AI__MODEL", "test-model-override");
794 std::env::set_var("APTU_AI__PROVIDER", "openrouter");
795 }
796 let config = load_config().expect("should load with env overrides");
797 unsafe {
798 std::env::remove_var("XDG_CONFIG_HOME");
799 std::env::remove_var("APTU_AI__MODEL");
800 std::env::remove_var("APTU_AI__PROVIDER");
801 }
802
803 assert_eq!(config.ai.model, "test-model-override");
804 assert_eq!(config.ai.provider, "openrouter");
805 }
806
807 #[test]
808 fn test_review_config_defaults() {
809 let review_config = ReviewConfig::default();
811
812 assert_eq!(
814 review_config.max_prompt_chars, 120_000,
815 "max_prompt_chars should default to 120_000"
816 );
817 assert_eq!(
818 review_config.max_full_content_files, 10,
819 "max_full_content_files should default to 10"
820 );
821 assert_eq!(
822 review_config.max_chars_per_file, 32_000,
823 "max_chars_per_file should default to 32_000"
824 );
825
826 let app_config = AppConfig::default();
828 assert_eq!(
829 app_config.review.max_prompt_chars, review_config.max_prompt_chars,
830 "AppConfig review defaults should match ReviewConfig defaults"
831 );
832 assert_eq!(
833 app_config.review.max_full_content_files, review_config.max_full_content_files,
834 "AppConfig review defaults should match ReviewConfig defaults"
835 );
836 assert_eq!(
837 app_config.review.max_chars_per_file, review_config.max_chars_per_file,
838 "AppConfig review defaults should match ReviewConfig defaults"
839 );
840 }
841
842 #[test]
843 fn test_graph_config_deserializes_from_toml_with_defaults() {
844 let toml_str = "[graph]\n";
846
847 let app_config: AppConfig = toml::from_str(toml_str).unwrap();
849
850 assert!(
852 !app_config.graph.enabled,
853 "graph should default to disabled"
854 );
855 assert_eq!(app_config.graph.cache_ttl_hours, 24);
856 assert_eq!(app_config.graph.max_nodes, 50_000);
857 }
858
859 #[test]
860 fn test_in_memory_config_source_loads_defaults() {
861 let default_config = AppConfig::default();
862 let source = InMemoryConfigSource(default_config.clone());
863 let loaded = source.load().expect("load should succeed");
864 assert_eq!(loaded.ai.provider, default_config.ai.provider);
865 assert_eq!(loaded.ai.model, default_config.ai.model);
866 assert_eq!(loaded.ai.timeout_seconds, default_config.ai.timeout_seconds);
867 assert_eq!(loaded.ai.max_tokens, default_config.ai.max_tokens);
868 assert_eq!(
869 loaded.github.api_timeout_seconds,
870 default_config.github.api_timeout_seconds
871 );
872 }
873
874 #[test]
875 fn test_resolve_for_task_routing_small_model() {
876 let config_str = r#"
878[ai]
879provider = "gemini"
880model = "gemini-3.1-flash-lite"
881
882[ai.tasks.review]
883small_model = "gemini-3.1-flash-lite"
884large_model = "gemini-3.1-flash-lite"
885routing_threshold_chars = 60000
886"#;
887
888 let config = Config::builder()
889 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
890 .build()
891 .expect("should build config");
892
893 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
894
895 let (provider, model) = app_config
896 .ai
897 .resolve_for_task(super::super::ai::TaskType::Review, Some(5000));
898 assert_eq!(provider, "gemini");
899 assert_eq!(model, "gemini-3.1-flash-lite");
900 }
901
902 #[test]
903 fn test_resolve_for_task_routing_large_model() {
904 let config_str = r#"
906[ai]
907provider = "openrouter"
908model = "mistralai/mistral-small-2603"
909
910[ai.tasks.review]
911small_model = "mistralai/mistral-small-2603"
912large_model = "anthropic/claude-sonnet-4.6"
913routing_threshold_chars = 60000
914"#;
915
916 let config = Config::builder()
917 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
918 .build()
919 .expect("should build config");
920
921 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
922
923 let (provider, model) = app_config
924 .ai
925 .resolve_for_task(super::super::ai::TaskType::Review, Some(70000));
926 assert_eq!(provider, "openrouter");
927 assert_eq!(model, "anthropic/claude-sonnet-4.6");
928 }
929
930 #[test]
931 fn test_resolve_for_task_routing_single_field_fallback() {
932 let config_str = r#"
934[ai]
935provider = "openrouter"
936model = "mistralai/mistral-small-2603"
937
938[ai.tasks.review]
939small_model = "mistralai/mistral-small-2603"
940"#;
941
942 let config = Config::builder()
943 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
944 .build()
945 .expect("should build config");
946
947 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
948
949 let (provider, model) = app_config
950 .ai
951 .resolve_for_task(super::super::ai::TaskType::Review, Some(5000));
952 assert_eq!(provider, "openrouter");
953 assert_eq!(model, "mistralai/mistral-small-2603");
954 }
955
956 #[test]
957 fn test_resolve_for_task_routing_skipped_when_size_none() {
958 let config_str = r#"
960[ai]
961provider = "openrouter"
962model = "mistralai/mistral-small-2603"
963
964[ai.tasks.review]
965small_model = "mistralai/mistral-small-2603"
966large_model = "anthropic/claude-sonnet-4.6"
967"#;
968
969 let config = Config::builder()
970 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
971 .build()
972 .expect("should build config");
973
974 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
975
976 let (provider, model) = app_config
977 .ai
978 .resolve_for_task(super::super::ai::TaskType::Review, None);
979 assert_eq!(provider, "openrouter");
980 assert_eq!(model, "mistralai/mistral-small-2603");
981 }
982
983 #[test]
984 fn test_resolve_for_task_model_override_bypasses_routing() {
985 let config_str = r#"
987[ai]
988provider = "openrouter"
989model = "mistralai/mistral-small-2603"
990
991[ai.tasks.review]
992model = "anthropic/claude-haiku-4.5"
993small_model = "mistralai/mistral-small-2603"
994large_model = "anthropic/claude-sonnet-4.6"
995"#;
996
997 let config = Config::builder()
998 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
999 .build()
1000 .expect("should build config");
1001
1002 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
1003
1004 let (provider, model) = app_config
1005 .ai
1006 .resolve_for_task(super::super::ai::TaskType::Review, Some(5000));
1007 assert_eq!(provider, "openrouter");
1008 assert_eq!(model, "anthropic/claude-haiku-4.5");
1009 }
1010}