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;
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, 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, 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, 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, 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)]
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, 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, 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: "gemini".to_string(),
179            model: "gemini-3-flash-preview".to_string(),
180            timeout_seconds: 30,
181            allow_paid_models: false,
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: Some(TasksConfig {
188                triage: None,
189                review: Some(TaskOverride {
190                    provider: Some("groq".to_string()),
191                    model: Some("openai/gpt-oss-120b".to_string()),
192                }),
193                create: None,
194            }),
195            fallback: None,
196            custom_guidance: None,
197            validation_enabled: true,
198        }
199    }
200}
201
202impl AiConfig {
203    /// Resolve provider and model for a specific task type.
204    ///
205    /// Returns a tuple of (provider, model) by checking task-specific overrides first,
206    /// then falling back to the default provider and model.
207    ///
208    /// # Arguments
209    ///
210    /// * `task` - The task type to resolve configuration for
211    ///
212    /// # Returns
213    ///
214    /// A tuple of (`provider_name`, `model_name`) strings
215    #[must_use]
216    pub fn resolve_for_task(&self, task: TaskType) -> (String, String) {
217        let task_override = match task {
218            TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
219            TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
220            TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
221        };
222
223        let provider = task_override
224            .and_then(|o| o.provider.clone())
225            .unwrap_or_else(|| self.provider.clone());
226
227        let model = task_override
228            .and_then(|o| o.model.clone())
229            .unwrap_or_else(|| self.model.clone());
230
231        (provider, model)
232    }
233}
234
235/// GitHub API settings.
236#[derive(Debug, Deserialize, Clone)]
237#[serde(default)]
238pub struct GitHubConfig {
239    /// API request timeout in seconds.
240    pub api_timeout_seconds: u64,
241}
242
243impl Default for GitHubConfig {
244    fn default() -> Self {
245        Self {
246            api_timeout_seconds: 10,
247        }
248    }
249}
250
251/// UI preferences.
252#[derive(Debug, Deserialize, Clone)]
253#[serde(default)]
254pub struct UiConfig {
255    /// Enable colored output.
256    pub color: bool,
257    /// Show progress bars.
258    pub progress_bars: bool,
259    /// Always confirm before posting comments.
260    pub confirm_before_post: bool,
261}
262
263impl Default for UiConfig {
264    fn default() -> Self {
265        Self {
266            color: true,
267            progress_bars: true,
268            confirm_before_post: true,
269        }
270    }
271}
272
273/// Cache settings.
274#[derive(Debug, Deserialize, Clone)]
275#[serde(default)]
276pub struct CacheConfig {
277    /// Issue cache TTL in minutes.
278    pub issue_ttl_minutes: i64,
279    /// Repository metadata cache TTL in hours.
280    pub repo_ttl_hours: i64,
281    /// URL to fetch curated repositories from.
282    pub curated_repos_url: String,
283}
284
285impl Default for CacheConfig {
286    fn default() -> Self {
287        Self {
288            issue_ttl_minutes: crate::cache::DEFAULT_ISSUE_TTL_MINS,
289            repo_ttl_hours: crate::cache::DEFAULT_REPO_TTL_HOURS,
290            curated_repos_url:
291                "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
292                    .to_string(),
293        }
294    }
295}
296
297/// Repository settings.
298#[derive(Debug, Deserialize, Clone)]
299#[serde(default)]
300pub struct ReposConfig {
301    /// Include curated repositories (default: true).
302    pub curated: bool,
303}
304
305impl Default for ReposConfig {
306    fn default() -> Self {
307        Self { curated: true }
308    }
309}
310
311/// Returns the Aptu configuration directory.
312///
313/// Respects the `XDG_CONFIG_HOME` environment variable if set,
314/// otherwise defaults to `~/.config/aptu`.
315#[must_use]
316pub fn config_dir() -> PathBuf {
317    if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
318        && !xdg_config.is_empty()
319    {
320        return PathBuf::from(xdg_config).join("aptu");
321    }
322    dirs::home_dir()
323        .expect("Could not determine home directory - is HOME set?")
324        .join(".config")
325        .join("aptu")
326}
327
328/// Returns the Aptu data directory.
329///
330/// Respects the `XDG_DATA_HOME` environment variable if set,
331/// otherwise defaults to `~/.local/share/aptu`.
332#[must_use]
333pub fn data_dir() -> PathBuf {
334    if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
335        && !xdg_data.is_empty()
336    {
337        return PathBuf::from(xdg_data).join("aptu");
338    }
339    dirs::home_dir()
340        .expect("Could not determine home directory - is HOME set?")
341        .join(".local")
342        .join("share")
343        .join("aptu")
344}
345
346/// Returns the path to the configuration file.
347#[must_use]
348pub fn config_file_path() -> PathBuf {
349    config_dir().join("config.toml")
350}
351
352/// Load application configuration.
353///
354/// Loads from config file (if exists) and environment variables.
355/// Environment variables use the prefix `APTU_` and double underscore
356/// for nested keys (e.g., `APTU_AI__MODEL`).
357///
358/// # Errors
359///
360/// Returns `AptuError::Config` if the config file exists but is invalid.
361pub fn load_config() -> Result<AppConfig, AptuError> {
362    let config_path = config_file_path();
363
364    let config = Config::builder()
365        // Load from config file (optional - may not exist)
366        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
367        // Override with environment variables
368        .add_source(
369            Environment::with_prefix("APTU")
370                .separator("__")
371                .try_parsing(true),
372        )
373        .build()?;
374
375    let app_config: AppConfig = config.try_deserialize()?;
376
377    Ok(app_config)
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_load_config_defaults() {
386        // Without any config file or env vars, should return defaults
387        let config = load_config().expect("should load with defaults");
388
389        assert_eq!(config.ai.provider, "gemini");
390        assert_eq!(config.ai.model, "gemini-3-flash-preview");
391        assert_eq!(config.ai.timeout_seconds, 30);
392        assert_eq!(config.ai.max_tokens, 4096);
393        #[allow(clippy::float_cmp)]
394        {
395            assert_eq!(config.ai.temperature, 0.3);
396        }
397        assert_eq!(config.github.api_timeout_seconds, 10);
398        assert!(config.ui.color);
399        assert!(config.ui.confirm_before_post);
400        assert_eq!(config.cache.issue_ttl_minutes, 60);
401    }
402
403    #[test]
404    fn test_config_dir_exists() {
405        let dir = config_dir();
406        assert!(dir.ends_with("aptu"));
407    }
408
409    #[test]
410    fn test_data_dir_exists() {
411        let dir = data_dir();
412        assert!(dir.ends_with("aptu"));
413    }
414
415    #[test]
416    fn test_config_file_path() {
417        let path = config_file_path();
418        assert!(path.ends_with("config.toml"));
419    }
420
421    #[test]
422    fn test_config_with_task_triage_override() {
423        // Test that config with [ai.tasks.triage] parses correctly
424        let config_str = r#"
425[ai]
426provider = "gemini"
427model = "gemini-3-flash-preview"
428
429[ai.tasks.triage]
430model = "gemini-2.5-flash-lite-preview-09-2025"
431"#;
432
433        let config = Config::builder()
434            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
435            .build()
436            .expect("should build config");
437
438        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
439
440        assert_eq!(app_config.ai.provider, "gemini");
441        assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
442        assert!(app_config.ai.tasks.is_some());
443
444        let tasks = app_config.ai.tasks.unwrap();
445        assert!(tasks.triage.is_some());
446        assert!(tasks.review.is_none());
447        assert!(tasks.create.is_none());
448
449        let triage = tasks.triage.unwrap();
450        assert_eq!(triage.provider, None);
451        assert_eq!(
452            triage.model,
453            Some("gemini-2.5-flash-lite-preview-09-2025".to_string())
454        );
455    }
456
457    #[test]
458    fn test_config_with_multiple_task_overrides() {
459        // Test that config with multiple task overrides parses correctly
460        let config_str = r#"
461[ai]
462provider = "openrouter"
463model = "mistralai/devstral-2512:free"
464
465[ai.tasks.triage]
466model = "mistralai/devstral-2512:free"
467
468[ai.tasks.review]
469provider = "openrouter"
470model = "anthropic/claude-haiku-4.5"
471
472[ai.tasks.create]
473model = "anthropic/claude-sonnet-4.5"
474"#;
475
476        let config = Config::builder()
477            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
478            .build()
479            .expect("should build config");
480
481        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
482
483        let tasks = app_config.ai.tasks.expect("tasks should exist");
484
485        // Triage: only model override
486        let triage = tasks.triage.expect("triage should exist");
487        assert_eq!(triage.provider, None);
488        assert_eq!(
489            triage.model,
490            Some("mistralai/devstral-2512:free".to_string())
491        );
492
493        // Review: both provider and model override
494        let review = tasks.review.expect("review should exist");
495        assert_eq!(review.provider, Some("openrouter".to_string()));
496        assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
497
498        // Create: only model override
499        let create = tasks.create.expect("create should exist");
500        assert_eq!(create.provider, None);
501        assert_eq!(
502            create.model,
503            Some("anthropic/claude-sonnet-4.5".to_string())
504        );
505    }
506
507    #[test]
508    fn test_config_with_partial_task_overrides() {
509        // Test that partial task configs (only provider or only model) parse correctly
510        let config_str = r#"
511[ai]
512provider = "gemini"
513model = "gemini-3-flash-preview"
514
515[ai.tasks.triage]
516provider = "gemini"
517
518[ai.tasks.review]
519model = "gemini-3-pro-preview"
520"#;
521
522        let config = Config::builder()
523            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
524            .build()
525            .expect("should build config");
526
527        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
528
529        let tasks = app_config.ai.tasks.expect("tasks should exist");
530
531        // Triage: only provider
532        let triage = tasks.triage.expect("triage should exist");
533        assert_eq!(triage.provider, Some("gemini".to_string()));
534        assert_eq!(triage.model, None);
535
536        // Review: only model
537        let review = tasks.review.expect("review should exist");
538        assert_eq!(review.provider, None);
539        assert_eq!(review.model, Some("gemini-3-pro-preview".to_string()));
540    }
541
542    #[test]
543    fn test_config_without_tasks_section() {
544        // Test that config without explicit tasks section uses defaults
545        let config_str = r#"
546[ai]
547provider = "gemini"
548model = "gemini-3-flash-preview"
549"#;
550
551        let config = Config::builder()
552            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
553            .build()
554            .expect("should build config");
555
556        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
557
558        assert_eq!(app_config.ai.provider, "gemini");
559        assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
560        // When no tasks section is provided, defaults are used (which include review override)
561        assert!(app_config.ai.tasks.is_some());
562        let tasks = app_config.ai.tasks.unwrap();
563        assert!(tasks.review.is_some());
564        let review = tasks.review.unwrap();
565        assert_eq!(review.provider, Some("groq".to_string()));
566        assert_eq!(review.model, Some("openai/gpt-oss-120b".to_string()));
567    }
568
569    #[test]
570    fn test_resolve_for_task_with_defaults() {
571        // Test that resolve_for_task returns correct defaults including review override
572        let ai_config = AiConfig::default();
573
574        // Triage and Create use global defaults
575        let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
576        assert_eq!(provider, "gemini");
577        assert_eq!(model, "gemini-3-flash-preview");
578
579        // Review uses task-specific override
580        let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
581        assert_eq!(provider, "groq");
582        assert_eq!(model, "openai/gpt-oss-120b");
583
584        let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
585        assert_eq!(provider, "gemini");
586        assert_eq!(model, "gemini-3-flash-preview");
587    }
588
589    #[test]
590    fn test_resolve_for_task_with_triage_override() {
591        // Test that resolve_for_task returns triage override when present
592        let config_str = r#"
593[ai]
594provider = "gemini"
595model = "gemini-3-flash-preview"
596
597[ai.tasks.triage]
598model = "gemini-2.5-flash-lite-preview-09-2025"
599"#;
600
601        let config = Config::builder()
602            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
603            .build()
604            .expect("should build config");
605
606        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
607
608        // Triage should use override
609        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
610        assert_eq!(provider, "gemini");
611        assert_eq!(model, "gemini-2.5-flash-lite-preview-09-2025");
612
613        // Review and Create should use defaults
614        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
615        assert_eq!(provider, "gemini");
616        assert_eq!(model, "gemini-3-flash-preview");
617
618        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
619        assert_eq!(provider, "gemini");
620        assert_eq!(model, "gemini-3-flash-preview");
621    }
622
623    #[test]
624    fn test_resolve_for_task_with_provider_override() {
625        // Test that resolve_for_task returns provider override when present
626        let config_str = r#"
627[ai]
628provider = "gemini"
629model = "gemini-3-flash-preview"
630
631[ai.tasks.review]
632provider = "openrouter"
633"#;
634
635        let config = Config::builder()
636            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
637            .build()
638            .expect("should build config");
639
640        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
641
642        // Review should use provider override but default model
643        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
644        assert_eq!(provider, "openrouter");
645        assert_eq!(model, "gemini-3-flash-preview");
646
647        // Triage and Create should use defaults
648        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
649        assert_eq!(provider, "gemini");
650        assert_eq!(model, "gemini-3-flash-preview");
651
652        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
653        assert_eq!(provider, "gemini");
654        assert_eq!(model, "gemini-3-flash-preview");
655    }
656
657    #[test]
658    fn test_resolve_for_task_with_full_overrides() {
659        // Test that resolve_for_task returns both provider and model overrides
660        let config_str = r#"
661[ai]
662provider = "gemini"
663model = "gemini-3-flash-preview"
664
665[ai.tasks.triage]
666provider = "openrouter"
667model = "mistralai/devstral-2512:free"
668
669[ai.tasks.review]
670provider = "openrouter"
671model = "anthropic/claude-haiku-4.5"
672
673[ai.tasks.create]
674provider = "gemini"
675model = "gemini-3-pro-preview"
676"#;
677
678        let config = Config::builder()
679            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
680            .build()
681            .expect("should build config");
682
683        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
684
685        // Triage
686        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
687        assert_eq!(provider, "openrouter");
688        assert_eq!(model, "mistralai/devstral-2512:free");
689
690        // Review
691        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
692        assert_eq!(provider, "openrouter");
693        assert_eq!(model, "anthropic/claude-haiku-4.5");
694
695        // Create
696        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
697        assert_eq!(provider, "gemini");
698        assert_eq!(model, "gemini-3-pro-preview");
699    }
700
701    #[test]
702    fn test_resolve_for_task_partial_overrides() {
703        // Test that resolve_for_task handles partial overrides correctly
704        let config_str = r#"
705[ai]
706provider = "openrouter"
707model = "mistralai/devstral-2512:free"
708
709[ai.tasks.triage]
710model = "mistralai/devstral-2512:free"
711
712[ai.tasks.review]
713provider = "openrouter"
714
715[ai.tasks.create]
716"#;
717
718        let config = Config::builder()
719            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
720            .build()
721            .expect("should build config");
722
723        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
724
725        // Triage: model override, provider from default
726        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
727        assert_eq!(provider, "openrouter");
728        assert_eq!(model, "mistralai/devstral-2512:free");
729
730        // Review: provider override, model from default
731        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
732        assert_eq!(provider, "openrouter");
733        assert_eq!(model, "mistralai/devstral-2512:free");
734
735        // Create: empty override, both from default
736        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
737        assert_eq!(provider, "openrouter");
738        assert_eq!(model, "mistralai/devstral-2512:free");
739    }
740
741    #[test]
742    fn test_fallback_config_toml_parsing() {
743        // Test that FallbackConfig deserializes from TOML correctly
744        let config_str = r#"
745[ai]
746provider = "gemini"
747model = "gemini-3-flash-preview"
748
749[ai.fallback]
750chain = ["openrouter", "anthropic"]
751"#;
752
753        let config = Config::builder()
754            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
755            .build()
756            .expect("should build config");
757
758        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
759
760        assert_eq!(app_config.ai.provider, "gemini");
761        assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
762        assert!(app_config.ai.fallback.is_some());
763
764        let fallback = app_config.ai.fallback.unwrap();
765        assert_eq!(fallback.chain.len(), 2);
766        assert_eq!(fallback.chain[0].provider, "openrouter");
767        assert_eq!(fallback.chain[1].provider, "anthropic");
768    }
769
770    #[test]
771    fn test_fallback_config_empty_chain() {
772        // Test that FallbackConfig with empty chain parses correctly
773        let config_str = r#"
774[ai]
775provider = "gemini"
776model = "gemini-3-flash-preview"
777
778[ai.fallback]
779chain = []
780"#;
781
782        let config = Config::builder()
783            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
784            .build()
785            .expect("should build config");
786
787        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
788
789        assert!(app_config.ai.fallback.is_some());
790        let fallback = app_config.ai.fallback.unwrap();
791        assert_eq!(fallback.chain.len(), 0);
792    }
793
794    #[test]
795    fn test_fallback_config_single_provider() {
796        // Test that FallbackConfig with single provider parses correctly
797        let config_str = r#"
798[ai]
799provider = "gemini"
800model = "gemini-3-flash-preview"
801
802[ai.fallback]
803chain = ["openrouter"]
804"#;
805
806        let config = Config::builder()
807            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
808            .build()
809            .expect("should build config");
810
811        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
812
813        assert!(app_config.ai.fallback.is_some());
814        let fallback = app_config.ai.fallback.unwrap();
815        assert_eq!(fallback.chain.len(), 1);
816        assert_eq!(fallback.chain[0].provider, "openrouter");
817    }
818
819    #[test]
820    fn test_fallback_config_without_fallback_section() {
821        // Test that config without fallback section has None
822        let config_str = r#"
823[ai]
824provider = "gemini"
825model = "gemini-3-flash-preview"
826"#;
827
828        let config = Config::builder()
829            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
830            .build()
831            .expect("should build config");
832
833        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
834
835        assert!(app_config.ai.fallback.is_none());
836    }
837
838    #[test]
839    fn test_fallback_config_default() {
840        // Test that AiConfig::default() has fallback: None
841        let ai_config = AiConfig::default();
842        assert!(ai_config.fallback.is_none());
843    }
844}