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: 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, 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, 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, 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, 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        let config = load_config().expect("should load with defaults");
381
382        assert_eq!(config.ai.provider, "gemini");
383        assert_eq!(config.ai.model, "gemini-3-flash-preview");
384        assert_eq!(config.ai.timeout_seconds, 30);
385        assert_eq!(config.ai.max_tokens, 4096);
386        #[allow(clippy::float_cmp)]
387        {
388            assert_eq!(config.ai.temperature, 0.3);
389        }
390        assert_eq!(config.github.api_timeout_seconds, 10);
391        assert!(config.ui.color);
392        assert!(config.ui.confirm_before_post);
393        assert_eq!(config.cache.issue_ttl_minutes, 60);
394    }
395
396    #[test]
397    fn test_config_dir_exists() {
398        let dir = config_dir();
399        assert!(dir.ends_with("aptu"));
400    }
401
402    #[test]
403    fn test_data_dir_exists() {
404        let dir = data_dir();
405        assert!(dir.ends_with("aptu"));
406    }
407
408    #[test]
409    fn test_config_file_path() {
410        let path = config_file_path();
411        assert!(path.ends_with("config.toml"));
412    }
413
414    #[test]
415    fn test_config_with_task_triage_override() {
416        // Test that config with [ai.tasks.triage] parses correctly
417        let config_str = r#"
418[ai]
419provider = "gemini"
420model = "gemini-3-flash-preview"
421
422[ai.tasks.triage]
423model = "gemini-2.5-flash-lite-preview-09-2025"
424"#;
425
426        let config = Config::builder()
427            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
428            .build()
429            .expect("should build config");
430
431        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
432
433        assert_eq!(app_config.ai.provider, "gemini");
434        assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
435        assert!(app_config.ai.tasks.is_some());
436
437        let tasks = app_config.ai.tasks.unwrap();
438        assert!(tasks.triage.is_some());
439        assert!(tasks.review.is_none());
440        assert!(tasks.create.is_none());
441
442        let triage = tasks.triage.unwrap();
443        assert_eq!(triage.provider, None);
444        assert_eq!(
445            triage.model,
446            Some("gemini-2.5-flash-lite-preview-09-2025".to_string())
447        );
448    }
449
450    #[test]
451    fn test_config_with_multiple_task_overrides() {
452        // Test that config with multiple task overrides parses correctly
453        let config_str = r#"
454[ai]
455provider = "openrouter"
456model = "mistralai/devstral-2512:free"
457
458[ai.tasks.triage]
459model = "mistralai/devstral-2512:free"
460
461[ai.tasks.review]
462provider = "openrouter"
463model = "anthropic/claude-haiku-4.5"
464
465[ai.tasks.create]
466model = "anthropic/claude-sonnet-4.5"
467"#;
468
469        let config = Config::builder()
470            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
471            .build()
472            .expect("should build config");
473
474        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
475
476        let tasks = app_config.ai.tasks.expect("tasks should exist");
477
478        // Triage: only model override
479        let triage = tasks.triage.expect("triage should exist");
480        assert_eq!(triage.provider, None);
481        assert_eq!(
482            triage.model,
483            Some("mistralai/devstral-2512:free".to_string())
484        );
485
486        // Review: both provider and model override
487        let review = tasks.review.expect("review should exist");
488        assert_eq!(review.provider, Some("openrouter".to_string()));
489        assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
490
491        // Create: only model override
492        let create = tasks.create.expect("create should exist");
493        assert_eq!(create.provider, None);
494        assert_eq!(
495            create.model,
496            Some("anthropic/claude-sonnet-4.5".to_string())
497        );
498    }
499
500    #[test]
501    fn test_config_with_partial_task_overrides() {
502        // Test that partial task configs (only provider or only model) parse correctly
503        let config_str = r#"
504[ai]
505provider = "gemini"
506model = "gemini-3-flash-preview"
507
508[ai.tasks.triage]
509provider = "gemini"
510
511[ai.tasks.review]
512model = "gemini-3-pro-preview"
513"#;
514
515        let config = Config::builder()
516            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
517            .build()
518            .expect("should build config");
519
520        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
521
522        let tasks = app_config.ai.tasks.expect("tasks should exist");
523
524        // Triage: only provider
525        let triage = tasks.triage.expect("triage should exist");
526        assert_eq!(triage.provider, Some("gemini".to_string()));
527        assert_eq!(triage.model, None);
528
529        // Review: only model
530        let review = tasks.review.expect("review should exist");
531        assert_eq!(review.provider, None);
532        assert_eq!(review.model, Some("gemini-3-pro-preview".to_string()));
533    }
534
535    #[test]
536    fn test_config_without_tasks_section() {
537        // Test that default config loads without tasks section
538        let config_str = r#"
539[ai]
540provider = "gemini"
541model = "gemini-3-flash-preview"
542"#;
543
544        let config = Config::builder()
545            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
546            .build()
547            .expect("should build config");
548
549        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
550
551        assert_eq!(app_config.ai.provider, "gemini");
552        assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
553        assert!(app_config.ai.tasks.is_none());
554    }
555
556    #[test]
557    fn test_resolve_for_task_no_overrides() {
558        // Test that resolve_for_task returns defaults when no task overrides exist
559        let ai_config = AiConfig::default();
560
561        let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
562        assert_eq!(provider, "gemini");
563        assert_eq!(model, "gemini-3-flash-preview");
564
565        let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
566        assert_eq!(provider, "gemini");
567        assert_eq!(model, "gemini-3-flash-preview");
568
569        let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
570        assert_eq!(provider, "gemini");
571        assert_eq!(model, "gemini-3-flash-preview");
572    }
573
574    #[test]
575    fn test_resolve_for_task_with_triage_override() {
576        // Test that resolve_for_task returns triage override when present
577        let config_str = r#"
578[ai]
579provider = "gemini"
580model = "gemini-3-flash-preview"
581
582[ai.tasks.triage]
583model = "gemini-2.5-flash-lite-preview-09-2025"
584"#;
585
586        let config = Config::builder()
587            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
588            .build()
589            .expect("should build config");
590
591        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
592
593        // Triage should use override
594        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
595        assert_eq!(provider, "gemini");
596        assert_eq!(model, "gemini-2.5-flash-lite-preview-09-2025");
597
598        // Review and Create should use defaults
599        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
600        assert_eq!(provider, "gemini");
601        assert_eq!(model, "gemini-3-flash-preview");
602
603        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
604        assert_eq!(provider, "gemini");
605        assert_eq!(model, "gemini-3-flash-preview");
606    }
607
608    #[test]
609    fn test_resolve_for_task_with_provider_override() {
610        // Test that resolve_for_task returns provider override when present
611        let config_str = r#"
612[ai]
613provider = "gemini"
614model = "gemini-3-flash-preview"
615
616[ai.tasks.review]
617provider = "openrouter"
618"#;
619
620        let config = Config::builder()
621            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
622            .build()
623            .expect("should build config");
624
625        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
626
627        // Review should use provider override but default model
628        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
629        assert_eq!(provider, "openrouter");
630        assert_eq!(model, "gemini-3-flash-preview");
631
632        // Triage and Create should use defaults
633        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
634        assert_eq!(provider, "gemini");
635        assert_eq!(model, "gemini-3-flash-preview");
636
637        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
638        assert_eq!(provider, "gemini");
639        assert_eq!(model, "gemini-3-flash-preview");
640    }
641
642    #[test]
643    fn test_resolve_for_task_with_full_overrides() {
644        // Test that resolve_for_task returns both provider and model overrides
645        let config_str = r#"
646[ai]
647provider = "gemini"
648model = "gemini-3-flash-preview"
649
650[ai.tasks.triage]
651provider = "openrouter"
652model = "mistralai/devstral-2512:free"
653
654[ai.tasks.review]
655provider = "openrouter"
656model = "anthropic/claude-haiku-4.5"
657
658[ai.tasks.create]
659provider = "gemini"
660model = "gemini-3-pro-preview"
661"#;
662
663        let config = Config::builder()
664            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
665            .build()
666            .expect("should build config");
667
668        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
669
670        // Triage
671        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
672        assert_eq!(provider, "openrouter");
673        assert_eq!(model, "mistralai/devstral-2512:free");
674
675        // Review
676        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
677        assert_eq!(provider, "openrouter");
678        assert_eq!(model, "anthropic/claude-haiku-4.5");
679
680        // Create
681        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
682        assert_eq!(provider, "gemini");
683        assert_eq!(model, "gemini-3-pro-preview");
684    }
685
686    #[test]
687    fn test_resolve_for_task_partial_overrides() {
688        // Test that resolve_for_task handles partial overrides correctly
689        let config_str = r#"
690[ai]
691provider = "openrouter"
692model = "mistralai/devstral-2512:free"
693
694[ai.tasks.triage]
695model = "mistralai/devstral-2512:free"
696
697[ai.tasks.review]
698provider = "openrouter"
699
700[ai.tasks.create]
701"#;
702
703        let config = Config::builder()
704            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
705            .build()
706            .expect("should build config");
707
708        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
709
710        // Triage: model override, provider from default
711        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
712        assert_eq!(provider, "openrouter");
713        assert_eq!(model, "mistralai/devstral-2512:free");
714
715        // Review: provider override, model from default
716        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
717        assert_eq!(provider, "openrouter");
718        assert_eq!(model, "mistralai/devstral-2512:free");
719
720        // Create: empty override, both from default
721        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
722        assert_eq!(provider, "openrouter");
723        assert_eq!(model, "mistralai/devstral-2512:free");
724    }
725
726    #[test]
727    fn test_fallback_config_toml_parsing() {
728        // Test that FallbackConfig deserializes from TOML correctly
729        let config_str = r#"
730[ai]
731provider = "gemini"
732model = "gemini-3-flash-preview"
733
734[ai.fallback]
735chain = ["openrouter", "anthropic"]
736"#;
737
738        let config = Config::builder()
739            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
740            .build()
741            .expect("should build config");
742
743        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
744
745        assert_eq!(app_config.ai.provider, "gemini");
746        assert_eq!(app_config.ai.model, "gemini-3-flash-preview");
747        assert!(app_config.ai.fallback.is_some());
748
749        let fallback = app_config.ai.fallback.unwrap();
750        assert_eq!(fallback.chain.len(), 2);
751        assert_eq!(fallback.chain[0].provider, "openrouter");
752        assert_eq!(fallback.chain[1].provider, "anthropic");
753    }
754
755    #[test]
756    fn test_fallback_config_empty_chain() {
757        // Test that FallbackConfig with empty chain parses correctly
758        let config_str = r#"
759[ai]
760provider = "gemini"
761model = "gemini-3-flash-preview"
762
763[ai.fallback]
764chain = []
765"#;
766
767        let config = Config::builder()
768            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
769            .build()
770            .expect("should build config");
771
772        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
773
774        assert!(app_config.ai.fallback.is_some());
775        let fallback = app_config.ai.fallback.unwrap();
776        assert_eq!(fallback.chain.len(), 0);
777    }
778
779    #[test]
780    fn test_fallback_config_single_provider() {
781        // Test that FallbackConfig with single provider parses correctly
782        let config_str = r#"
783[ai]
784provider = "gemini"
785model = "gemini-3-flash-preview"
786
787[ai.fallback]
788chain = ["openrouter"]
789"#;
790
791        let config = Config::builder()
792            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
793            .build()
794            .expect("should build config");
795
796        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
797
798        assert!(app_config.ai.fallback.is_some());
799        let fallback = app_config.ai.fallback.unwrap();
800        assert_eq!(fallback.chain.len(), 1);
801        assert_eq!(fallback.chain[0].provider, "openrouter");
802    }
803
804    #[test]
805    fn test_fallback_config_without_fallback_section() {
806        // Test that config without fallback section has None
807        let config_str = r#"
808[ai]
809provider = "gemini"
810model = "gemini-3-flash-preview"
811"#;
812
813        let config = Config::builder()
814            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
815            .build()
816            .expect("should build config");
817
818        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
819
820        assert!(app_config.ai.fallback.is_none());
821    }
822
823    #[test]
824    fn test_fallback_config_default() {
825        // Test that AiConfig::default() has fallback: None
826        let ai_config = AiConfig::default();
827        assert!(ai_config.fallback.is_none());
828    }
829}