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 for warning in app_config.graph.validate_consistency() {
95 tracing::warn!("{}", warning);
96 }
97
98 Ok(app_config)
99 }
100}
101
102#[derive(Debug, Deserialize, Serialize, Default, Clone)]
104#[serde(default)]
105pub struct UserConfig {
106 pub default_repo: Option<String>,
108}
109
110#[derive(Debug, Deserialize, Serialize, Clone)]
112#[serde(default)]
113pub struct GitHubConfig {
114 pub api_timeout_seconds: u64,
116}
117
118impl Default for GitHubConfig {
119 fn default() -> Self {
120 Self {
121 api_timeout_seconds: 10,
122 }
123 }
124}
125
126#[derive(Debug, Deserialize, Serialize, Clone)]
128#[serde(default)]
129pub struct UiConfig {
130 pub color: bool,
132 pub progress_bars: bool,
134 pub confirm_before_post: bool,
136}
137
138impl Default for UiConfig {
139 fn default() -> Self {
140 Self {
141 color: true,
142 progress_bars: true,
143 confirm_before_post: true,
144 }
145 }
146}
147
148#[derive(Debug, Deserialize, Serialize, Clone)]
152#[serde(default)]
153pub struct PromptConfig {
154 pub max_issue_body_bytes: usize,
161 pub max_diff_bytes: usize,
168 pub max_commit_message_bytes: usize,
174}
175
176impl Default for PromptConfig {
177 fn default() -> Self {
178 Self {
179 max_issue_body_bytes: 32_768,
180 max_diff_bytes: 524_288,
181 max_commit_message_bytes: 4_096,
182 }
183 }
184}
185
186#[derive(Debug, Default, Deserialize, Serialize, Clone)]
188#[serde(default)]
189pub struct AppConfig {
190 pub user: UserConfig,
192 pub ai: AiConfig,
194 pub github: GitHubConfig,
196 pub ui: UiConfig,
198 pub cache: CacheConfig,
200 pub repos: ReposConfig,
202 #[serde(default)]
204 pub review: ReviewConfig,
205 #[serde(default)]
207 pub graph: crate::config::GraphConfig,
208 #[serde(default)]
210 pub prompt: PromptConfig,
211}
212
213#[must_use]
218pub fn config_dir() -> PathBuf {
219 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
220 && !xdg_config.is_empty()
221 {
222 return PathBuf::from(xdg_config).join("aptu");
223 }
224 dirs::home_dir()
225 .expect("Could not determine home directory - is HOME set?")
226 .join(".config")
227 .join("aptu")
228}
229
230#[must_use]
235pub fn data_dir() -> PathBuf {
236 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
237 && !xdg_data.is_empty()
238 {
239 return PathBuf::from(xdg_data).join("aptu");
240 }
241 dirs::home_dir()
242 .expect("Could not determine home directory - is HOME set?")
243 .join(".local")
244 .join("share")
245 .join("aptu")
246}
247
248#[must_use]
256pub fn prompts_dir() -> PathBuf {
257 config_dir().join("prompts")
258}
259
260#[must_use]
262pub fn config_file_path() -> PathBuf {
263 config_dir().join("config.toml")
264}
265
266#[cfg(not(target_arch = "wasm32"))]
278pub fn load_config() -> Result<AppConfig, AptuError> {
279 TomlConfigSource::new().load()
280}
281
282#[cfg(test)]
283mod tests {
284 #![allow(unsafe_code)]
285 use super::*;
286 use serial_test::serial;
287
288 #[test]
289 #[serial]
290 fn test_load_config_defaults() {
291 let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
295 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
296 unsafe {
298 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
299 }
300 let config = load_config().expect("should load with defaults");
301 unsafe {
302 std::env::remove_var("XDG_CONFIG_HOME");
303 }
304
305 assert_eq!(config.ai.provider, "openrouter");
306 assert_eq!(config.ai.model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
307 assert_eq!(config.ai.timeout_seconds, 30);
308 assert_eq!(config.ai.max_tokens, 4096);
309 assert!(config.ai.allow_paid_models);
310 #[allow(clippy::float_cmp)]
311 {
312 assert_eq!(config.ai.temperature, 0.3);
313 }
314 assert_eq!(config.github.api_timeout_seconds, 10);
315 assert!(config.ui.color);
316 assert!(config.ui.confirm_before_post);
317 assert_eq!(config.cache.issue_ttl_minutes, 60);
318 }
319
320 #[test]
321 fn test_config_dir_exists() {
322 let dir = config_dir();
323 assert!(dir.ends_with("aptu"));
324 }
325
326 #[test]
327 fn test_data_dir_exists() {
328 let dir = data_dir();
329 assert!(dir.ends_with("aptu"));
330 }
331
332 #[test]
333 fn test_config_file_path() {
334 let path = config_file_path();
335 assert!(path.ends_with("config.toml"));
336 }
337
338 #[test]
339 fn test_config_with_task_triage_override() {
340 let config_str = r#"
342[ai]
343provider = "gemini"
344model = "gemini-3.1-flash-lite"
345
346[ai.tasks.triage]
347model = "gemini-3.1-flash-lite"
348"#;
349
350 let config = Config::builder()
351 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
352 .build()
353 .expect("should build config");
354
355 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
356
357 assert_eq!(app_config.ai.provider, "gemini");
358 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
359 assert!(app_config.ai.tasks.is_some());
360
361 let tasks = app_config.ai.tasks.unwrap();
362 assert!(tasks.triage.is_some());
363 assert!(tasks.review.is_none());
364 assert!(tasks.create.is_none());
365
366 let triage = tasks.triage.unwrap();
367 assert_eq!(triage.provider, None);
368 assert_eq!(
369 triage.model,
370 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
371 );
372 }
373
374 #[test]
375 fn test_config_with_multiple_task_overrides() {
376 let config_str = r#"
378[ai]
379provider = "openrouter"
380model = "mistralai/mistral-small-2603"
381
382[ai.tasks.triage]
383model = "mistralai/mistral-small-2603"
384
385[ai.tasks.review]
386provider = "openrouter"
387model = "anthropic/claude-haiku-4.5"
388
389[ai.tasks.create]
390model = "anthropic/claude-sonnet-4.6"
391"#;
392
393 let config = Config::builder()
394 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
395 .build()
396 .expect("should build config");
397
398 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
399
400 let tasks = app_config.ai.tasks.expect("tasks should exist");
401
402 let triage = tasks.triage.expect("triage should exist");
404 assert_eq!(triage.provider, None);
405 assert_eq!(
406 triage.model,
407 Some(super::super::ai::DEFAULT_OPENROUTER_MODEL.to_string())
408 );
409
410 let review = tasks.review.expect("review should exist");
412 assert_eq!(review.provider, Some("openrouter".to_string()));
413 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
414
415 let create = tasks.create.expect("create should exist");
417 assert_eq!(create.provider, None);
418 assert_eq!(
419 create.model,
420 Some("anthropic/claude-sonnet-4.6".to_string())
421 );
422 }
423
424 #[test]
425 fn test_config_with_partial_task_overrides() {
426 let config_str = r#"
428[ai]
429provider = "gemini"
430model = "gemini-3.1-flash-lite"
431
432[ai.tasks.triage]
433provider = "gemini"
434
435[ai.tasks.review]
436model = "gemini-3.1-flash-lite"
437"#;
438
439 let config = Config::builder()
440 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
441 .build()
442 .expect("should build config");
443
444 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
445
446 let tasks = app_config.ai.tasks.expect("tasks should exist");
447
448 let triage = tasks.triage.expect("triage should exist");
450 assert_eq!(triage.provider, Some("gemini".to_string()));
451 assert_eq!(triage.model, None);
452
453 let review = tasks.review.expect("review should exist");
455 assert_eq!(review.provider, None);
456 assert_eq!(
457 review.model,
458 Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
459 );
460 }
461
462 #[test]
463 fn test_config_without_tasks_section() {
464 let config_str = r#"
466[ai]
467provider = "gemini"
468model = "gemini-3.1-flash-lite"
469"#;
470
471 let config = Config::builder()
472 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
473 .build()
474 .expect("should build config");
475
476 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
477
478 assert_eq!(app_config.ai.provider, "gemini");
479 assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
480 assert!(app_config.ai.tasks.is_none());
482 }
483
484 #[test]
485 fn test_resolve_for_task_with_defaults() {
486 let ai_config = AiConfig::default();
488
489 let (provider, model) =
491 ai_config.resolve_for_task(super::super::ai::TaskType::Triage, None);
492 assert_eq!(provider, "openrouter");
493 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
494 assert!(ai_config.allow_paid_models);
495
496 let (provider, model) =
497 ai_config.resolve_for_task(super::super::ai::TaskType::Review, None);
498 assert_eq!(provider, "openrouter");
499 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
500 assert!(ai_config.allow_paid_models);
501
502 let (provider, model) =
503 ai_config.resolve_for_task(super::super::ai::TaskType::Create, None);
504 assert_eq!(provider, "openrouter");
505 assert_eq!(model, "mistralai/mistral-small-2603");
506 assert!(ai_config.allow_paid_models);
507 }
508
509 #[test]
510 fn test_resolve_for_task_with_triage_override() {
511 let config_str = r#"
513[ai]
514provider = "gemini"
515model = "gemini-3.1-flash-lite"
516
517[ai.tasks.triage]
518model = "gemini-3.1-flash-lite"
519"#;
520
521 let config = Config::builder()
522 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
523 .build()
524 .expect("should build config");
525
526 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
527
528 let (provider, model) = app_config
530 .ai
531 .resolve_for_task(super::super::ai::TaskType::Triage, None);
532 assert_eq!(provider, "gemini");
533 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
534
535 let (provider, model) = app_config
537 .ai
538 .resolve_for_task(super::super::ai::TaskType::Review, None);
539 assert_eq!(provider, "gemini");
540 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
541
542 let (provider, model) = app_config
543 .ai
544 .resolve_for_task(super::super::ai::TaskType::Create, None);
545 assert_eq!(provider, "gemini");
546 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
547 }
548
549 #[test]
550 fn test_config_with_provider_override() {
551 let config_str = r#"
553[ai]
554provider = "gemini"
555model = "gemini-3.1-flash-lite"
556
557[ai.tasks.review]
558provider = "openrouter"
559"#;
560
561 let config = Config::builder()
562 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
563 .build()
564 .expect("should build config");
565
566 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
567
568 let (provider, model) = app_config
570 .ai
571 .resolve_for_task(super::super::ai::TaskType::Review, None);
572 assert_eq!(provider, "openrouter");
573 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
574
575 let (provider, model) = app_config
577 .ai
578 .resolve_for_task(super::super::ai::TaskType::Triage, None);
579 assert_eq!(provider, "gemini");
580 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
581
582 let (provider, model) = app_config
583 .ai
584 .resolve_for_task(super::super::ai::TaskType::Create, None);
585 assert_eq!(provider, "gemini");
586 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
587 }
588
589 #[test]
590 fn test_config_with_full_overrides() {
591 let config_str = r#"
593[ai]
594provider = "gemini"
595model = "gemini-3.1-flash-lite"
596
597[ai.tasks.triage]
598provider = "openrouter"
599model = "mistralai/mistral-small-2603"
600
601[ai.tasks.review]
602provider = "openrouter"
603model = "anthropic/claude-haiku-4.5"
604
605[ai.tasks.create]
606provider = "gemini"
607model = "gemini-3.1-flash-lite"
608"#;
609
610 let config = Config::builder()
611 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
612 .build()
613 .expect("should build config");
614
615 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
616
617 let (provider, model) = app_config
619 .ai
620 .resolve_for_task(super::super::ai::TaskType::Triage, None);
621 assert_eq!(provider, "openrouter");
622 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
623
624 let (provider, model) = app_config
626 .ai
627 .resolve_for_task(super::super::ai::TaskType::Review, None);
628 assert_eq!(provider, "openrouter");
629 assert_eq!(model, "anthropic/claude-haiku-4.5");
630
631 let (provider, model) = app_config
633 .ai
634 .resolve_for_task(super::super::ai::TaskType::Create, None);
635 assert_eq!(provider, "gemini");
636 assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
637 }
638
639 #[test]
640 fn test_resolve_for_task_partial_overrides() {
641 let config_str = r#"
643[ai]
644provider = "openrouter"
645model = "mistralai/mistral-small-2603"
646
647[ai.tasks.triage]
648model = "mistralai/mistral-small-2603"
649
650[ai.tasks.review]
651provider = "openrouter"
652
653[ai.tasks.create]
654"#;
655
656 let config = Config::builder()
657 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
658 .build()
659 .expect("should build config");
660
661 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
662
663 let (provider, model) = app_config
665 .ai
666 .resolve_for_task(super::super::ai::TaskType::Triage, None);
667 assert_eq!(provider, "openrouter");
668 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
669
670 let (provider, model) = app_config
672 .ai
673 .resolve_for_task(super::super::ai::TaskType::Review, None);
674 assert_eq!(provider, "openrouter");
675 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
676
677 let (provider, model) = app_config
679 .ai
680 .resolve_for_task(super::super::ai::TaskType::Create, None);
681 assert_eq!(provider, "openrouter");
682 assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
683 }
684
685 #[test]
686 fn test_fallback_config_toml_parsing() {
687 let config_str = r#"
689[ai]
690provider = "gemini"
691model = "gemini-3.1-flash-lite"
692
693[ai.fallback]
694chain = ["openrouter", "anthropic"]
695"#;
696
697 let config = Config::builder()
698 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
699 .build()
700 .expect("should build config");
701
702 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
703
704 assert_eq!(app_config.ai.provider, "gemini");
705 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite");
706 assert!(app_config.ai.fallback.is_some());
707
708 let fallback = app_config.ai.fallback.unwrap();
709 assert_eq!(fallback.chain.len(), 2);
710 assert_eq!(fallback.chain[0].provider, "openrouter");
711 assert_eq!(fallback.chain[1].provider, "anthropic");
712 }
713
714 #[test]
715 fn test_fallback_config_empty_chain() {
716 let config_str = r#"
718[ai]
719provider = "gemini"
720model = "gemini-3.1-flash-lite"
721
722[ai.fallback]
723chain = []
724"#;
725
726 let config = Config::builder()
727 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
728 .build()
729 .expect("should build config");
730
731 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
732
733 assert!(app_config.ai.fallback.is_some());
734 let fallback = app_config.ai.fallback.unwrap();
735 assert_eq!(fallback.chain.len(), 0);
736 }
737
738 #[test]
739 fn test_fallback_config_single_provider() {
740 let config_str = r#"
742[ai]
743provider = "gemini"
744model = "gemini-3.1-flash-lite"
745
746[ai.fallback]
747chain = ["openrouter"]
748"#;
749
750 let config = Config::builder()
751 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
752 .build()
753 .expect("should build config");
754
755 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
756
757 assert!(app_config.ai.fallback.is_some());
758 let fallback = app_config.ai.fallback.unwrap();
759 assert_eq!(fallback.chain.len(), 1);
760 assert_eq!(fallback.chain[0].provider, "openrouter");
761 }
762
763 #[test]
764 fn test_fallback_config_without_fallback_section() {
765 let config_str = r#"
767[ai]
768provider = "gemini"
769model = "gemini-3.1-flash-lite"
770"#;
771
772 let config = Config::builder()
773 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
774 .build()
775 .expect("should build config");
776
777 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
778
779 assert!(app_config.ai.fallback.is_none());
780 }
781
782 #[test]
783 fn test_fallback_config_default() {
784 let ai_config = AiConfig::default();
786 assert!(ai_config.fallback.is_none());
787 }
788
789 #[test]
790 #[serial]
791 fn test_load_config_env_var_override() {
792 let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
794 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
795 unsafe {
797 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
798 std::env::set_var("APTU_AI__MODEL", "test-model-override");
799 std::env::set_var("APTU_AI__PROVIDER", "openrouter");
800 }
801 let config = load_config().expect("should load with env overrides");
802 unsafe {
803 std::env::remove_var("XDG_CONFIG_HOME");
804 std::env::remove_var("APTU_AI__MODEL");
805 std::env::remove_var("APTU_AI__PROVIDER");
806 }
807
808 assert_eq!(config.ai.model, "test-model-override");
809 assert_eq!(config.ai.provider, "openrouter");
810 }
811
812 #[test]
813 fn test_review_config_defaults() {
814 let review_config = ReviewConfig::default();
816
817 assert_eq!(
819 review_config.max_prompt_chars, 120_000,
820 "max_prompt_chars should default to 120_000"
821 );
822 assert_eq!(
823 review_config.max_full_content_files, 10,
824 "max_full_content_files should default to 10"
825 );
826 assert_eq!(
827 review_config.max_chars_per_file, 32_000,
828 "max_chars_per_file should default to 32_000"
829 );
830
831 let app_config = AppConfig::default();
833 assert_eq!(
834 app_config.review.max_prompt_chars, review_config.max_prompt_chars,
835 "AppConfig review defaults should match ReviewConfig defaults"
836 );
837 assert_eq!(
838 app_config.review.max_full_content_files, review_config.max_full_content_files,
839 "AppConfig review defaults should match ReviewConfig defaults"
840 );
841 assert_eq!(
842 app_config.review.max_chars_per_file, review_config.max_chars_per_file,
843 "AppConfig review defaults should match ReviewConfig defaults"
844 );
845 }
846
847 #[test]
848 fn test_graph_config_deserializes_from_toml_with_defaults() {
849 let toml_str = "[graph]\n";
851
852 let app_config: AppConfig = toml::from_str(toml_str).unwrap();
854
855 assert!(
857 !app_config.graph.enabled,
858 "graph should default to disabled"
859 );
860 assert_eq!(app_config.graph.cache_ttl_hours, 24);
861 assert_eq!(app_config.graph.max_nodes, 50_000);
862 }
863
864 #[test]
865 fn test_in_memory_config_source_loads_defaults() {
866 let default_config = AppConfig::default();
867 let source = InMemoryConfigSource(default_config.clone());
868 let loaded = source.load().expect("load should succeed");
869 assert_eq!(loaded.ai.provider, default_config.ai.provider);
870 assert_eq!(loaded.ai.model, default_config.ai.model);
871 assert_eq!(loaded.ai.timeout_seconds, default_config.ai.timeout_seconds);
872 assert_eq!(loaded.ai.max_tokens, default_config.ai.max_tokens);
873 assert_eq!(
874 loaded.github.api_timeout_seconds,
875 default_config.github.api_timeout_seconds
876 );
877 }
878
879 #[test]
880 fn test_resolve_for_task_routing_small_model() {
881 let config_str = r#"
883[ai]
884provider = "gemini"
885model = "gemini-3.1-flash-lite"
886
887[ai.tasks.review]
888small_model = "gemini-3.1-flash-lite"
889large_model = "gemini-3.1-flash-lite"
890routing_threshold_chars = 60000
891"#;
892
893 let config = Config::builder()
894 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
895 .build()
896 .expect("should build config");
897
898 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
899
900 let (provider, model) = app_config
901 .ai
902 .resolve_for_task(super::super::ai::TaskType::Review, Some(5000));
903 assert_eq!(provider, "gemini");
904 assert_eq!(model, "gemini-3.1-flash-lite");
905 }
906
907 #[test]
908 fn test_resolve_for_task_routing_large_model() {
909 let config_str = r#"
911[ai]
912provider = "openrouter"
913model = "mistralai/mistral-small-2603"
914
915[ai.tasks.review]
916small_model = "mistralai/mistral-small-2603"
917large_model = "anthropic/claude-sonnet-4.6"
918routing_threshold_chars = 60000
919"#;
920
921 let config = Config::builder()
922 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
923 .build()
924 .expect("should build config");
925
926 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
927
928 let (provider, model) = app_config
929 .ai
930 .resolve_for_task(super::super::ai::TaskType::Review, Some(70000));
931 assert_eq!(provider, "openrouter");
932 assert_eq!(model, "anthropic/claude-sonnet-4.6");
933 }
934
935 #[test]
936 fn test_resolve_for_task_routing_single_field_fallback() {
937 let config_str = r#"
939[ai]
940provider = "openrouter"
941model = "mistralai/mistral-small-2603"
942
943[ai.tasks.review]
944small_model = "mistralai/mistral-small-2603"
945"#;
946
947 let config = Config::builder()
948 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
949 .build()
950 .expect("should build config");
951
952 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
953
954 let (provider, model) = app_config
955 .ai
956 .resolve_for_task(super::super::ai::TaskType::Review, Some(5000));
957 assert_eq!(provider, "openrouter");
958 assert_eq!(model, "mistralai/mistral-small-2603");
959 }
960
961 #[test]
962 fn test_resolve_for_task_routing_skipped_when_size_none() {
963 let config_str = r#"
965[ai]
966provider = "openrouter"
967model = "mistralai/mistral-small-2603"
968
969[ai.tasks.review]
970small_model = "mistralai/mistral-small-2603"
971large_model = "anthropic/claude-sonnet-4.6"
972"#;
973
974 let config = Config::builder()
975 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
976 .build()
977 .expect("should build config");
978
979 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
980
981 let (provider, model) = app_config
982 .ai
983 .resolve_for_task(super::super::ai::TaskType::Review, None);
984 assert_eq!(provider, "openrouter");
985 assert_eq!(model, "mistralai/mistral-small-2603");
986 }
987
988 #[test]
989 fn test_resolve_for_task_model_override_bypasses_routing() {
990 let config_str = r#"
992[ai]
993provider = "openrouter"
994model = "mistralai/mistral-small-2603"
995
996[ai.tasks.review]
997model = "anthropic/claude-haiku-4.5"
998small_model = "mistralai/mistral-small-2603"
999large_model = "anthropic/claude-sonnet-4.6"
1000"#;
1001
1002 let config = Config::builder()
1003 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
1004 .build()
1005 .expect("should build config");
1006
1007 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
1008
1009 let (provider, model) = app_config
1010 .ai
1011 .resolve_for_task(super::super::ai::TaskType::Review, Some(5000));
1012 assert_eq!(provider, "openrouter");
1013 assert_eq!(model, "anthropic/claude-haiku-4.5");
1014 }
1015}