Skip to main content

aptu_core/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Configuration management for the Aptu CLI.
4//!
5//! Provides layered configuration from files and environment variables.
6//! Uses XDG-compliant paths via the `dirs` crate.
7//!
8//! # Configuration Sources (in priority order)
9//!
10//! 1. Environment variables (prefix: `APTU_`)
11//! 2. Config file: `~/.config/aptu/config.toml`
12//! 3. Built-in defaults
13//!
14//! # Examples
15//!
16//! ```bash
17//! # Override AI model via environment variable
18//! APTU_AI__MODEL=mistral-small cargo run
19//! ```
20
21use std::path::PathBuf;
22
23use config::{Config, Environment, File};
24use serde::{Deserialize, Serialize};
25
26use crate::error::AptuError;
27
28/// Task type for model selection.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum TaskType {
31    /// Issue triage task.
32    Triage,
33    /// Pull request review task.
34    Review,
35    /// Label creation task.
36    Create,
37}
38
39/// Application configuration.
40#[derive(Debug, Default, Deserialize, Serialize, Clone)]
41#[serde(default)]
42pub struct AppConfig {
43    /// User preferences.
44    pub user: UserConfig,
45    /// AI provider settings.
46    pub ai: AiConfig,
47    /// GitHub API settings.
48    pub github: GitHubConfig,
49    /// UI preferences.
50    pub ui: UiConfig,
51    /// Cache settings.
52    pub cache: CacheConfig,
53    /// Repository settings.
54    pub repos: ReposConfig,
55}
56
57/// User preferences.
58#[derive(Debug, Deserialize, Serialize, Default, Clone)]
59#[serde(default)]
60pub struct UserConfig {
61    /// Default repository to use (skip repo selection).
62    pub default_repo: Option<String>,
63}
64
65/// Task-specific AI model override.
66#[derive(Debug, Deserialize, Serialize, Default, Clone)]
67#[serde(default)]
68pub struct TaskOverride {
69    /// Optional provider override for this task.
70    pub provider: Option<String>,
71    /// Optional model override for this task.
72    pub model: Option<String>,
73}
74
75/// Task-specific AI configuration.
76#[derive(Debug, Deserialize, Serialize, Default, Clone)]
77#[serde(default)]
78pub struct TasksConfig {
79    /// Triage task configuration.
80    pub triage: Option<TaskOverride>,
81    /// Review task configuration.
82    pub review: Option<TaskOverride>,
83    /// Create task configuration.
84    pub create: Option<TaskOverride>,
85}
86
87/// Single entry in the fallback provider chain.
88#[derive(Debug, Clone, Serialize)]
89pub struct FallbackEntry {
90    /// Provider name (e.g., "openrouter", "anthropic", "gemini").
91    pub provider: String,
92    /// Optional model override for this specific provider.
93    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/// Fallback provider chain configuration.
122#[derive(Debug, Deserialize, Serialize, Clone, Default)]
123#[serde(default)]
124pub struct FallbackConfig {
125    /// Chain of fallback entries to try in order when primary fails.
126    pub chain: Vec<FallbackEntry>,
127}
128
129/// Default value for `retry_max_attempts`.
130fn default_retry_max_attempts() -> u32 {
131    3
132}
133
134/// AI provider settings.
135#[derive(Debug, Deserialize, Serialize, Clone)]
136#[serde(default)]
137pub struct AiConfig {
138    /// AI provider: "openrouter" or "ollama".
139    pub provider: String,
140    /// Model identifier.
141    pub model: String,
142    /// Request timeout in seconds.
143    pub timeout_seconds: u64,
144    /// Allow paid models (default: false for cost control).
145    pub allow_paid_models: bool,
146    /// Maximum tokens for API responses.
147    pub max_tokens: u32,
148    /// Temperature for API requests (0.0-1.0).
149    pub temperature: f32,
150    /// Circuit breaker failure threshold before opening (default: 3).
151    pub circuit_breaker_threshold: u32,
152    /// Circuit breaker reset timeout in seconds (default: 60).
153    pub circuit_breaker_reset_seconds: u64,
154    /// Maximum retry attempts for rate-limited requests (default: 3).
155    #[serde(default = "default_retry_max_attempts")]
156    pub retry_max_attempts: u32,
157    /// Task-specific model overrides.
158    pub tasks: Option<TasksConfig>,
159    /// Fallback provider chain for resilience.
160    pub fallback: Option<FallbackConfig>,
161    /// Custom guidance to override or extend default best practices.
162    ///
163    /// Allows users to provide project-specific tooling recommendations
164    /// that will be appended to the default best practices context.
165    /// Useful for enforcing project-specific choices (e.g., poetry instead of uv).
166    pub custom_guidance: Option<String>,
167    /// Enable pre-flight model validation with fuzzy matching (default: true).
168    ///
169    /// When enabled, validates that the configured model ID exists in the
170    /// cached model registry before creating an AI client. Provides helpful
171    /// suggestions if an invalid model ID is detected.
172    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    /// Resolve provider and model for a specific task type.
197    ///
198    /// Returns a tuple of (provider, model) by checking task-specific overrides first,
199    /// then falling back to the default provider and model.
200    ///
201    /// # Arguments
202    ///
203    /// * `task` - The task type to resolve configuration for
204    ///
205    /// # Returns
206    ///
207    /// A tuple of (`provider_name`, `model_name`) strings
208    #[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/// GitHub API settings.
229#[derive(Debug, Deserialize, Serialize, Clone)]
230#[serde(default)]
231pub struct GitHubConfig {
232    /// API request timeout in seconds.
233    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/// UI preferences.
245#[derive(Debug, Deserialize, Serialize, Clone)]
246#[serde(default)]
247pub struct UiConfig {
248    /// Enable colored output.
249    pub color: bool,
250    /// Show progress bars.
251    pub progress_bars: bool,
252    /// Always confirm before posting comments.
253    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/// Cache settings.
267#[derive(Debug, Deserialize, Serialize, Clone)]
268#[serde(default)]
269pub struct CacheConfig {
270    /// Issue cache TTL in minutes.
271    pub issue_ttl_minutes: i64,
272    /// Repository metadata cache TTL in hours.
273    pub repo_ttl_hours: i64,
274    /// URL to fetch curated repositories from.
275    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/// Repository settings.
291#[derive(Debug, Deserialize, Serialize, Clone)]
292#[serde(default)]
293pub struct ReposConfig {
294    /// Include curated repositories (default: true).
295    pub curated: bool,
296}
297
298impl Default for ReposConfig {
299    fn default() -> Self {
300        Self { curated: true }
301    }
302}
303
304/// Returns the Aptu configuration directory.
305///
306/// Respects the `XDG_CONFIG_HOME` environment variable if set,
307/// otherwise defaults to `~/.config/aptu`.
308#[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/// Returns the Aptu data directory.
322///
323/// Respects the `XDG_DATA_HOME` environment variable if set,
324/// otherwise defaults to `~/.local/share/aptu`.
325#[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/// Returns the path to the configuration file.
340#[must_use]
341pub fn config_file_path() -> PathBuf {
342    config_dir().join("config.toml")
343}
344
345/// Load application configuration.
346///
347/// Loads from config file (if exists) and environment variables.
348/// Environment variables use the prefix `APTU_` and double underscore
349/// for nested keys (e.g., `APTU_AI__MODEL`).
350///
351/// # Errors
352///
353/// Returns `AptuError::Config` if the config file exists but is invalid.
354pub fn load_config() -> Result<AppConfig, AptuError> {
355    let config_path = config_file_path();
356
357    let config = Config::builder()
358        // Load from config file (optional - may not exist)
359        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
360        // Override with environment variables
361        .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        // Without any config file or env vars, should return defaults.
380        // Point XDG_CONFIG_HOME to a guaranteed-empty temp dir so the real
381        // user config (~/.config/aptu/config.toml) is not loaded.
382        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        // SAFETY: single-threaded test process; no concurrent env reads.
385        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        // Test that config with [ai.tasks.triage] parses correctly
429        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        // Test that config with multiple task overrides parses correctly
465        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        // Triage: only model override
491        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        // Review: both provider and model override
499        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        // Create: only model override
504        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        // Test that partial task configs (only provider or only model) parse correctly
515        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        // Triage: only provider
537        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        // Review: only model
542        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        // Test that config without explicit tasks section uses defaults
553        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        // When no tasks section is provided, defaults are used (tasks: None)
569        assert!(app_config.ai.tasks.is_none());
570    }
571
572    #[test]
573    fn test_resolve_for_task_with_defaults() {
574        // Test that resolve_for_task returns correct defaults (all tasks use openrouter)
575        let ai_config = AiConfig::default();
576
577        // All tasks use global defaults (openrouter/mistral/mistral-small-2603)
578        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        // Test that resolve_for_task returns triage override when present
597        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        // Triage should use override
614        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        // Review and Create should use defaults
619        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        // Test that resolve_for_task returns provider override when present
631        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        // Review should use provider override but default model
648        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        // Triage and Create should use defaults
653        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        // Test that resolve_for_task returns both provider and model overrides
665        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        // Triage
691        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        // Review
696        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        // Create
701        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        // Test that resolve_for_task handles partial overrides correctly
709        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        // Triage: model override, provider from default
731        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        // Review: provider override, model from default
736        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        // Create: empty override, both from default
741        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        // Test that FallbackConfig deserializes from TOML correctly
749        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        // Test that FallbackConfig with empty chain parses correctly
778        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        // Test that FallbackConfig with single provider parses correctly
802        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        // Test that config without fallback section has None
827        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        // Test that AiConfig::default() has fallback: None
846        let ai_config = AiConfig::default();
847        assert!(ai_config.fallback.is_none());
848    }
849}