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 Aptu prompts configuration directory.
340///
341/// Prompt override files are loaded from this directory at runtime.
342/// Place a `<name>.md` file here to override the compiled-in prompt.
343///
344/// Respects the `XDG_CONFIG_HOME` environment variable if set,
345/// otherwise defaults to `~/.config/aptu/prompts`.
346#[must_use]
347pub fn prompts_dir() -> PathBuf {
348    config_dir().join("prompts")
349}
350
351/// Returns the path to the configuration file.
352#[must_use]
353pub fn config_file_path() -> PathBuf {
354    config_dir().join("config.toml")
355}
356
357/// Load application configuration.
358///
359/// Loads from config file (if exists) and environment variables.
360/// Environment variables use the prefix `APTU_` and double underscore
361/// for nested keys (e.g., `APTU_AI__MODEL`).
362///
363/// # Errors
364///
365/// Returns `AptuError::Config` if the config file exists but is invalid.
366pub fn load_config() -> Result<AppConfig, AptuError> {
367    let config_path = config_file_path();
368
369    let config = Config::builder()
370        // Load from config file (optional - may not exist)
371        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
372        // Override with environment variables
373        .add_source(
374            Environment::with_prefix("APTU")
375                .separator("__")
376                .try_parsing(true),
377        )
378        .build()?;
379
380    let app_config: AppConfig = config.try_deserialize()?;
381
382    Ok(app_config)
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_load_config_defaults() {
391        // Without any config file or env vars, should return defaults.
392        // Point XDG_CONFIG_HOME to a guaranteed-empty temp dir so the real
393        // user config (~/.config/aptu/config.toml) is not loaded.
394        let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
395        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
396        // SAFETY: single-threaded test process; no concurrent env reads.
397        unsafe {
398            std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
399        }
400        let config = load_config().expect("should load with defaults");
401        unsafe {
402            std::env::remove_var("XDG_CONFIG_HOME");
403        }
404
405        assert_eq!(config.ai.provider, "openrouter");
406        assert_eq!(config.ai.model, "mistral/mistral-small-2603");
407        assert_eq!(config.ai.timeout_seconds, 30);
408        assert_eq!(config.ai.max_tokens, 4096);
409        assert_eq!(config.ai.allow_paid_models, true);
410        #[allow(clippy::float_cmp)]
411        {
412            assert_eq!(config.ai.temperature, 0.3);
413        }
414        assert_eq!(config.github.api_timeout_seconds, 10);
415        assert!(config.ui.color);
416        assert!(config.ui.confirm_before_post);
417        assert_eq!(config.cache.issue_ttl_minutes, 60);
418    }
419
420    #[test]
421    fn test_config_dir_exists() {
422        let dir = config_dir();
423        assert!(dir.ends_with("aptu"));
424    }
425
426    #[test]
427    fn test_data_dir_exists() {
428        let dir = data_dir();
429        assert!(dir.ends_with("aptu"));
430    }
431
432    #[test]
433    fn test_config_file_path() {
434        let path = config_file_path();
435        assert!(path.ends_with("config.toml"));
436    }
437
438    #[test]
439    fn test_config_with_task_triage_override() {
440        // Test that config with [ai.tasks.triage] parses correctly
441        let config_str = r#"
442[ai]
443provider = "gemini"
444model = "gemini-3.1-flash-lite-preview"
445
446[ai.tasks.triage]
447model = "gemini-2.5-flash-lite-preview-09-2025"
448"#;
449
450        let config = Config::builder()
451            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
452            .build()
453            .expect("should build config");
454
455        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
456
457        assert_eq!(app_config.ai.provider, "gemini");
458        assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
459        assert!(app_config.ai.tasks.is_some());
460
461        let tasks = app_config.ai.tasks.unwrap();
462        assert!(tasks.triage.is_some());
463        assert!(tasks.review.is_none());
464        assert!(tasks.create.is_none());
465
466        let triage = tasks.triage.unwrap();
467        assert_eq!(triage.provider, None);
468        assert_eq!(
469            triage.model,
470            Some("gemini-2.5-flash-lite-preview-09-2025".to_string())
471        );
472    }
473
474    #[test]
475    fn test_config_with_multiple_task_overrides() {
476        // Test that config with multiple task overrides parses correctly
477        let config_str = r#"
478[ai]
479provider = "openrouter"
480model = "mistralai/devstral-2512:free"
481
482[ai.tasks.triage]
483model = "mistralai/devstral-2512:free"
484
485[ai.tasks.review]
486provider = "openrouter"
487model = "anthropic/claude-haiku-4.5"
488
489[ai.tasks.create]
490model = "anthropic/claude-sonnet-4.5"
491"#;
492
493        let config = Config::builder()
494            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
495            .build()
496            .expect("should build config");
497
498        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
499
500        let tasks = app_config.ai.tasks.expect("tasks should exist");
501
502        // Triage: only model override
503        let triage = tasks.triage.expect("triage should exist");
504        assert_eq!(triage.provider, None);
505        assert_eq!(
506            triage.model,
507            Some("mistralai/devstral-2512:free".to_string())
508        );
509
510        // Review: both provider and model override
511        let review = tasks.review.expect("review should exist");
512        assert_eq!(review.provider, Some("openrouter".to_string()));
513        assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
514
515        // Create: only model override
516        let create = tasks.create.expect("create should exist");
517        assert_eq!(create.provider, None);
518        assert_eq!(
519            create.model,
520            Some("anthropic/claude-sonnet-4.5".to_string())
521        );
522    }
523
524    #[test]
525    fn test_config_with_partial_task_overrides() {
526        // Test that partial task configs (only provider or only model) parse correctly
527        let config_str = r#"
528[ai]
529provider = "gemini"
530model = "gemini-3.1-flash-lite-preview"
531
532[ai.tasks.triage]
533provider = "gemini"
534
535[ai.tasks.review]
536model = "gemini-3.1-flash-lite-preview"
537"#;
538
539        let config = Config::builder()
540            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
541            .build()
542            .expect("should build config");
543
544        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
545
546        let tasks = app_config.ai.tasks.expect("tasks should exist");
547
548        // Triage: only provider
549        let triage = tasks.triage.expect("triage should exist");
550        assert_eq!(triage.provider, Some("gemini".to_string()));
551        assert_eq!(triage.model, None);
552
553        // Review: only model
554        let review = tasks.review.expect("review should exist");
555        assert_eq!(review.provider, None);
556        assert_eq!(
557            review.model,
558            Some("gemini-3.1-flash-lite-preview".to_string())
559        );
560    }
561
562    #[test]
563    fn test_config_without_tasks_section() {
564        // Test that config without explicit tasks section uses defaults
565        let config_str = r#"
566[ai]
567provider = "gemini"
568model = "gemini-3.1-flash-lite-preview"
569"#;
570
571        let config = Config::builder()
572            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
573            .build()
574            .expect("should build config");
575
576        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
577
578        assert_eq!(app_config.ai.provider, "gemini");
579        assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
580        // When no tasks section is provided, defaults are used (tasks: None)
581        assert!(app_config.ai.tasks.is_none());
582    }
583
584    #[test]
585    fn test_resolve_for_task_with_defaults() {
586        // Test that resolve_for_task returns correct defaults (all tasks use openrouter)
587        let ai_config = AiConfig::default();
588
589        // All tasks use global defaults (openrouter/mistral/mistral-small-2603)
590        let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
591        assert_eq!(provider, "openrouter");
592        assert_eq!(model, "mistral/mistral-small-2603");
593        assert_eq!(ai_config.allow_paid_models, true);
594
595        let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
596        assert_eq!(provider, "openrouter");
597        assert_eq!(model, "mistral/mistral-small-2603");
598        assert_eq!(ai_config.allow_paid_models, true);
599
600        let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
601        assert_eq!(provider, "openrouter");
602        assert_eq!(model, "mistral/mistral-small-2603");
603        assert_eq!(ai_config.allow_paid_models, true);
604    }
605
606    #[test]
607    fn test_resolve_for_task_with_triage_override() {
608        // Test that resolve_for_task returns triage override when present
609        let config_str = r#"
610[ai]
611provider = "gemini"
612model = "gemini-3.1-flash-lite-preview"
613
614[ai.tasks.triage]
615model = "gemini-2.5-flash-lite-preview-09-2025"
616"#;
617
618        let config = Config::builder()
619            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
620            .build()
621            .expect("should build config");
622
623        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
624
625        // Triage should use override
626        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
627        assert_eq!(provider, "gemini");
628        assert_eq!(model, "gemini-2.5-flash-lite-preview-09-2025");
629
630        // Review and Create should use defaults
631        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
632        assert_eq!(provider, "gemini");
633        assert_eq!(model, "gemini-3.1-flash-lite-preview");
634
635        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
636        assert_eq!(provider, "gemini");
637        assert_eq!(model, "gemini-3.1-flash-lite-preview");
638    }
639
640    #[test]
641    fn test_resolve_for_task_with_provider_override() {
642        // Test that resolve_for_task returns provider override when present
643        let config_str = r#"
644[ai]
645provider = "gemini"
646model = "gemini-3.1-flash-lite-preview"
647
648[ai.tasks.review]
649provider = "openrouter"
650"#;
651
652        let config = Config::builder()
653            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
654            .build()
655            .expect("should build config");
656
657        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
658
659        // Review should use provider override but default model
660        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
661        assert_eq!(provider, "openrouter");
662        assert_eq!(model, "gemini-3.1-flash-lite-preview");
663
664        // Triage and Create should use defaults
665        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
666        assert_eq!(provider, "gemini");
667        assert_eq!(model, "gemini-3.1-flash-lite-preview");
668
669        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
670        assert_eq!(provider, "gemini");
671        assert_eq!(model, "gemini-3.1-flash-lite-preview");
672    }
673
674    #[test]
675    fn test_resolve_for_task_with_full_overrides() {
676        // Test that resolve_for_task returns both provider and model overrides
677        let config_str = r#"
678[ai]
679provider = "gemini"
680model = "gemini-3.1-flash-lite-preview"
681
682[ai.tasks.triage]
683provider = "openrouter"
684model = "mistralai/devstral-2512:free"
685
686[ai.tasks.review]
687provider = "openrouter"
688model = "anthropic/claude-haiku-4.5"
689
690[ai.tasks.create]
691provider = "gemini"
692model = "gemini-3.1-flash-lite-preview"
693"#;
694
695        let config = Config::builder()
696            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
697            .build()
698            .expect("should build config");
699
700        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
701
702        // Triage
703        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
704        assert_eq!(provider, "openrouter");
705        assert_eq!(model, "mistralai/devstral-2512:free");
706
707        // Review
708        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
709        assert_eq!(provider, "openrouter");
710        assert_eq!(model, "anthropic/claude-haiku-4.5");
711
712        // Create
713        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
714        assert_eq!(provider, "gemini");
715        assert_eq!(model, "gemini-3.1-flash-lite-preview");
716    }
717
718    #[test]
719    fn test_resolve_for_task_partial_overrides() {
720        // Test that resolve_for_task handles partial overrides correctly
721        let config_str = r#"
722[ai]
723provider = "openrouter"
724model = "mistralai/devstral-2512:free"
725
726[ai.tasks.triage]
727model = "mistralai/devstral-2512:free"
728
729[ai.tasks.review]
730provider = "openrouter"
731
732[ai.tasks.create]
733"#;
734
735        let config = Config::builder()
736            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
737            .build()
738            .expect("should build config");
739
740        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
741
742        // Triage: model override, provider from default
743        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
744        assert_eq!(provider, "openrouter");
745        assert_eq!(model, "mistralai/devstral-2512:free");
746
747        // Review: provider override, model from default
748        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
749        assert_eq!(provider, "openrouter");
750        assert_eq!(model, "mistralai/devstral-2512:free");
751
752        // Create: empty override, both from default
753        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
754        assert_eq!(provider, "openrouter");
755        assert_eq!(model, "mistralai/devstral-2512:free");
756    }
757
758    #[test]
759    fn test_fallback_config_toml_parsing() {
760        // Test that FallbackConfig deserializes from TOML correctly
761        let config_str = r#"
762[ai]
763provider = "gemini"
764model = "gemini-3.1-flash-lite-preview"
765
766[ai.fallback]
767chain = ["openrouter", "anthropic"]
768"#;
769
770        let config = Config::builder()
771            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
772            .build()
773            .expect("should build config");
774
775        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
776
777        assert_eq!(app_config.ai.provider, "gemini");
778        assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
779        assert!(app_config.ai.fallback.is_some());
780
781        let fallback = app_config.ai.fallback.unwrap();
782        assert_eq!(fallback.chain.len(), 2);
783        assert_eq!(fallback.chain[0].provider, "openrouter");
784        assert_eq!(fallback.chain[1].provider, "anthropic");
785    }
786
787    #[test]
788    fn test_fallback_config_empty_chain() {
789        // Test that FallbackConfig with empty chain parses correctly
790        let config_str = r#"
791[ai]
792provider = "gemini"
793model = "gemini-3.1-flash-lite-preview"
794
795[ai.fallback]
796chain = []
797"#;
798
799        let config = Config::builder()
800            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
801            .build()
802            .expect("should build config");
803
804        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
805
806        assert!(app_config.ai.fallback.is_some());
807        let fallback = app_config.ai.fallback.unwrap();
808        assert_eq!(fallback.chain.len(), 0);
809    }
810
811    #[test]
812    fn test_fallback_config_single_provider() {
813        // Test that FallbackConfig with single provider parses correctly
814        let config_str = r#"
815[ai]
816provider = "gemini"
817model = "gemini-3.1-flash-lite-preview"
818
819[ai.fallback]
820chain = ["openrouter"]
821"#;
822
823        let config = Config::builder()
824            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
825            .build()
826            .expect("should build config");
827
828        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
829
830        assert!(app_config.ai.fallback.is_some());
831        let fallback = app_config.ai.fallback.unwrap();
832        assert_eq!(fallback.chain.len(), 1);
833        assert_eq!(fallback.chain[0].provider, "openrouter");
834    }
835
836    #[test]
837    fn test_fallback_config_without_fallback_section() {
838        // Test that config without fallback section has None
839        let config_str = r#"
840[ai]
841provider = "gemini"
842model = "gemini-3.1-flash-lite-preview"
843"#;
844
845        let config = Config::builder()
846            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
847            .build()
848            .expect("should build config");
849
850        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
851
852        assert!(app_config.ai.fallback.is_none());
853    }
854
855    #[test]
856    fn test_fallback_config_default() {
857        // Test that AiConfig::default() has fallback: None
858        let ai_config = AiConfig::default();
859        assert!(ai_config.fallback.is_none());
860    }
861}