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