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