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/// Default `OpenRouter` model identifier.
29pub const DEFAULT_OPENROUTER_MODEL: &str = "mistralai/mistral-small-2603";
30/// Default `Gemini` model identifier.
31pub const DEFAULT_GEMINI_MODEL: &str = "gemini-3.1-flash-lite-preview";
32
33/// Task type for model selection.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TaskType {
36    /// Issue triage task.
37    Triage,
38    /// Pull request review task.
39    Review,
40    /// Label creation task.
41    Create,
42}
43
44/// Application configuration.
45#[derive(Debug, Default, Deserialize, Serialize, Clone)]
46#[serde(default)]
47pub struct AppConfig {
48    /// User preferences.
49    pub user: UserConfig,
50    /// AI provider settings.
51    pub ai: AiConfig,
52    /// GitHub API settings.
53    pub github: GitHubConfig,
54    /// UI preferences.
55    pub ui: UiConfig,
56    /// Cache settings.
57    pub cache: CacheConfig,
58    /// Repository settings.
59    pub repos: ReposConfig,
60}
61
62/// User preferences.
63#[derive(Debug, Deserialize, Serialize, Default, Clone)]
64#[serde(default)]
65pub struct UserConfig {
66    /// Default repository to use (skip repo selection).
67    pub default_repo: Option<String>,
68}
69
70/// Task-specific AI model override.
71#[derive(Debug, Deserialize, Serialize, Default, Clone)]
72#[serde(default)]
73pub struct TaskOverride {
74    /// Optional provider override for this task.
75    pub provider: Option<String>,
76    /// Optional model override for this task.
77    pub model: Option<String>,
78}
79
80/// Task-specific AI configuration.
81#[derive(Debug, Deserialize, Serialize, Default, Clone)]
82#[serde(default)]
83pub struct TasksConfig {
84    /// Triage task configuration.
85    pub triage: Option<TaskOverride>,
86    /// Review task configuration.
87    pub review: Option<TaskOverride>,
88    /// Create task configuration.
89    pub create: Option<TaskOverride>,
90}
91
92/// Single entry in the fallback provider chain.
93#[derive(Debug, Clone, Serialize)]
94pub struct FallbackEntry {
95    /// Provider name (e.g., "openrouter", "anthropic", "gemini").
96    pub provider: String,
97    /// Optional model override for this specific provider.
98    pub model: Option<String>,
99}
100
101impl<'de> Deserialize<'de> for FallbackEntry {
102    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
103    where
104        D: serde::Deserializer<'de>,
105    {
106        #[derive(Deserialize)]
107        #[serde(untagged)]
108        enum EntryVariant {
109            String(String),
110            Struct {
111                provider: String,
112                model: Option<String>,
113            },
114        }
115
116        match EntryVariant::deserialize(deserializer)? {
117            EntryVariant::String(provider) => Ok(FallbackEntry {
118                provider,
119                model: None,
120            }),
121            EntryVariant::Struct { provider, model } => Ok(FallbackEntry { provider, model }),
122        }
123    }
124}
125
126/// Fallback provider chain configuration.
127#[derive(Debug, Deserialize, Serialize, Clone, Default)]
128#[serde(default)]
129pub struct FallbackConfig {
130    /// Chain of fallback entries to try in order when primary fails.
131    pub chain: Vec<FallbackEntry>,
132}
133
134/// Default value for `retry_max_attempts`.
135fn default_retry_max_attempts() -> u32 {
136    3
137}
138
139/// AI provider settings.
140#[derive(Debug, Deserialize, Serialize, Clone)]
141#[serde(default)]
142pub struct AiConfig {
143    /// AI provider: "openrouter" or "ollama".
144    pub provider: String,
145    /// Model identifier.
146    pub model: String,
147    /// Request timeout in seconds.
148    pub timeout_seconds: u64,
149    /// Allow paid models (default: false for cost control).
150    pub allow_paid_models: bool,
151    /// Maximum tokens for API responses.
152    pub max_tokens: u32,
153    /// Temperature for API requests (0.0-1.0).
154    pub temperature: f32,
155    /// Circuit breaker failure threshold before opening (default: 3).
156    pub circuit_breaker_threshold: u32,
157    /// Circuit breaker reset timeout in seconds (default: 60).
158    pub circuit_breaker_reset_seconds: u64,
159    /// Maximum retry attempts for rate-limited requests (default: 3).
160    #[serde(default = "default_retry_max_attempts")]
161    pub retry_max_attempts: u32,
162    /// Task-specific model overrides.
163    pub tasks: Option<TasksConfig>,
164    /// Fallback provider chain for resilience.
165    pub fallback: Option<FallbackConfig>,
166    /// Custom guidance to override or extend default best practices.
167    ///
168    /// Allows users to provide project-specific tooling recommendations
169    /// that will be appended to the default best practices context.
170    /// Useful for enforcing project-specific choices (e.g., poetry instead of uv).
171    pub custom_guidance: Option<String>,
172    /// Enable pre-flight model validation with fuzzy matching (default: true).
173    ///
174    /// When enabled, validates that the configured model ID exists in the
175    /// cached model registry before creating an AI client. Provides helpful
176    /// suggestions if an invalid model ID is detected.
177    pub validation_enabled: bool,
178}
179
180impl Default for AiConfig {
181    fn default() -> Self {
182        Self {
183            provider: "openrouter".to_string(),
184            model: DEFAULT_OPENROUTER_MODEL.to_string(),
185            timeout_seconds: 30,
186            allow_paid_models: true,
187            max_tokens: 4096,
188            temperature: 0.3,
189            circuit_breaker_threshold: 3,
190            circuit_breaker_reset_seconds: 60,
191            retry_max_attempts: default_retry_max_attempts(),
192            tasks: None,
193            fallback: None,
194            custom_guidance: None,
195            validation_enabled: true,
196        }
197    }
198}
199
200impl AiConfig {
201    /// Resolve provider and model for a specific task type.
202    ///
203    /// Returns a tuple of (provider, model) by checking task-specific overrides first,
204    /// then falling back to the default provider and model.
205    ///
206    /// # Arguments
207    ///
208    /// * `task` - The task type to resolve configuration for
209    ///
210    /// # Returns
211    ///
212    /// A tuple of (`provider_name`, `model_name`) strings
213    #[must_use]
214    pub fn resolve_for_task(&self, task: TaskType) -> (String, String) {
215        let task_override = match task {
216            TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
217            TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
218            TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
219        };
220
221        let provider = task_override
222            .and_then(|o| o.provider.clone())
223            .unwrap_or_else(|| self.provider.clone());
224
225        let model = task_override
226            .and_then(|o| o.model.clone())
227            .unwrap_or_else(|| self.model.clone());
228
229        (provider, model)
230    }
231}
232
233/// GitHub API settings.
234#[derive(Debug, Deserialize, Serialize, Clone)]
235#[serde(default)]
236pub struct GitHubConfig {
237    /// API request timeout in seconds.
238    pub api_timeout_seconds: u64,
239}
240
241impl Default for GitHubConfig {
242    fn default() -> Self {
243        Self {
244            api_timeout_seconds: 10,
245        }
246    }
247}
248
249/// UI preferences.
250#[derive(Debug, Deserialize, Serialize, Clone)]
251#[serde(default)]
252pub struct UiConfig {
253    /// Enable colored output.
254    pub color: bool,
255    /// Show progress bars.
256    pub progress_bars: bool,
257    /// Always confirm before posting comments.
258    pub confirm_before_post: bool,
259}
260
261impl Default for UiConfig {
262    fn default() -> Self {
263        Self {
264            color: true,
265            progress_bars: true,
266            confirm_before_post: true,
267        }
268    }
269}
270
271/// Cache settings.
272#[derive(Debug, Deserialize, Serialize, Clone)]
273#[serde(default)]
274pub struct CacheConfig {
275    /// Issue cache TTL in minutes.
276    pub issue_ttl_minutes: i64,
277    /// Repository metadata cache TTL in hours.
278    pub repo_ttl_hours: i64,
279    /// URL to fetch curated repositories from.
280    pub curated_repos_url: String,
281}
282
283impl Default for CacheConfig {
284    fn default() -> Self {
285        Self {
286            issue_ttl_minutes: crate::cache::DEFAULT_ISSUE_TTL_MINS,
287            repo_ttl_hours: crate::cache::DEFAULT_REPO_TTL_HOURS,
288            curated_repos_url:
289                "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
290                    .to_string(),
291        }
292    }
293}
294
295/// Repository settings.
296#[derive(Debug, Deserialize, Serialize, Clone)]
297#[serde(default)]
298pub struct ReposConfig {
299    /// Include curated repositories (default: true).
300    pub curated: bool,
301}
302
303impl Default for ReposConfig {
304    fn default() -> Self {
305        Self { curated: true }
306    }
307}
308
309/// Returns the Aptu configuration directory.
310///
311/// Respects the `XDG_CONFIG_HOME` environment variable if set,
312/// otherwise defaults to `~/.config/aptu`.
313#[must_use]
314pub fn config_dir() -> PathBuf {
315    if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
316        && !xdg_config.is_empty()
317    {
318        return PathBuf::from(xdg_config).join("aptu");
319    }
320    dirs::home_dir()
321        .expect("Could not determine home directory - is HOME set?")
322        .join(".config")
323        .join("aptu")
324}
325
326/// Returns the Aptu data directory.
327///
328/// Respects the `XDG_DATA_HOME` environment variable if set,
329/// otherwise defaults to `~/.local/share/aptu`.
330#[must_use]
331pub fn data_dir() -> PathBuf {
332    if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
333        && !xdg_data.is_empty()
334    {
335        return PathBuf::from(xdg_data).join("aptu");
336    }
337    dirs::home_dir()
338        .expect("Could not determine home directory - is HOME set?")
339        .join(".local")
340        .join("share")
341        .join("aptu")
342}
343
344/// Returns the Aptu prompts configuration directory.
345///
346/// Prompt override files are loaded from this directory at runtime.
347/// Place a `<name>.md` file here to override the compiled-in prompt.
348///
349/// Respects the `XDG_CONFIG_HOME` environment variable if set,
350/// otherwise defaults to `~/.config/aptu/prompts`.
351#[must_use]
352pub fn prompts_dir() -> PathBuf {
353    config_dir().join("prompts")
354}
355
356/// Returns the path to the configuration file.
357#[must_use]
358pub fn config_file_path() -> PathBuf {
359    config_dir().join("config.toml")
360}
361
362/// Load application configuration.
363///
364/// Loads from config file (if exists) and environment variables.
365/// Environment variables use the prefix `APTU_` and double underscore
366/// for nested keys (e.g., `APTU_AI__MODEL`).
367///
368/// # Errors
369///
370/// Returns `AptuError::Config` if the config file exists but is invalid.
371pub fn load_config() -> Result<AppConfig, AptuError> {
372    let config_path = config_file_path();
373
374    let config = Config::builder()
375        // Load from config file (optional - may not exist)
376        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
377        // Override with environment variables
378        .add_source(
379            Environment::with_prefix("APTU")
380                .prefix_separator("_")
381                .separator("__")
382                .try_parsing(true),
383        )
384        .build()?;
385
386    let app_config: AppConfig = config.try_deserialize()?;
387
388    Ok(app_config)
389}
390
391#[cfg(test)]
392mod tests {
393    #![allow(unsafe_code)]
394    use super::*;
395    use serial_test::serial;
396
397    #[test]
398    #[serial]
399    fn test_load_config_defaults() {
400        // Without any config file or env vars, should return defaults.
401        // Point XDG_CONFIG_HOME to a guaranteed-empty temp dir so the real
402        // user config (~/.config/aptu/config.toml) is not loaded.
403        let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
404        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
405        // SAFETY: single-threaded test process; no concurrent env reads.
406        unsafe {
407            std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
408        }
409        let config = load_config().expect("should load with defaults");
410        unsafe {
411            std::env::remove_var("XDG_CONFIG_HOME");
412        }
413
414        assert_eq!(config.ai.provider, "openrouter");
415        assert_eq!(config.ai.model, DEFAULT_OPENROUTER_MODEL);
416        assert_eq!(config.ai.timeout_seconds, 30);
417        assert_eq!(config.ai.max_tokens, 4096);
418        assert_eq!(config.ai.allow_paid_models, true);
419        #[allow(clippy::float_cmp)]
420        {
421            assert_eq!(config.ai.temperature, 0.3);
422        }
423        assert_eq!(config.github.api_timeout_seconds, 10);
424        assert!(config.ui.color);
425        assert!(config.ui.confirm_before_post);
426        assert_eq!(config.cache.issue_ttl_minutes, 60);
427    }
428
429    #[test]
430    fn test_config_dir_exists() {
431        let dir = config_dir();
432        assert!(dir.ends_with("aptu"));
433    }
434
435    #[test]
436    fn test_data_dir_exists() {
437        let dir = data_dir();
438        assert!(dir.ends_with("aptu"));
439    }
440
441    #[test]
442    fn test_config_file_path() {
443        let path = config_file_path();
444        assert!(path.ends_with("config.toml"));
445    }
446
447    #[test]
448    fn test_config_with_task_triage_override() {
449        // Test that config with [ai.tasks.triage] parses correctly
450        let config_str = r#"
451[ai]
452provider = "gemini"
453model = "gemini-3.1-flash-lite-preview"
454
455[ai.tasks.triage]
456model = "gemini-3.1-flash-lite-preview"
457"#;
458
459        let config = Config::builder()
460            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
461            .build()
462            .expect("should build config");
463
464        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
465
466        assert_eq!(app_config.ai.provider, "gemini");
467        assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
468        assert!(app_config.ai.tasks.is_some());
469
470        let tasks = app_config.ai.tasks.unwrap();
471        assert!(tasks.triage.is_some());
472        assert!(tasks.review.is_none());
473        assert!(tasks.create.is_none());
474
475        let triage = tasks.triage.unwrap();
476        assert_eq!(triage.provider, None);
477        assert_eq!(triage.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
478    }
479
480    #[test]
481    fn test_config_with_multiple_task_overrides() {
482        // Test that config with multiple task overrides parses correctly
483        let config_str = r#"
484[ai]
485provider = "openrouter"
486model = "mistralai/mistral-small-2603"
487
488[ai.tasks.triage]
489model = "mistralai/mistral-small-2603"
490
491[ai.tasks.review]
492provider = "openrouter"
493model = "anthropic/claude-haiku-4.5"
494
495[ai.tasks.create]
496model = "anthropic/claude-sonnet-4.6"
497"#;
498
499        let config = Config::builder()
500            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
501            .build()
502            .expect("should build config");
503
504        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
505
506        let tasks = app_config.ai.tasks.expect("tasks should exist");
507
508        // Triage: only model override
509        let triage = tasks.triage.expect("triage should exist");
510        assert_eq!(triage.provider, None);
511        assert_eq!(triage.model, Some(DEFAULT_OPENROUTER_MODEL.to_string()));
512
513        // Review: both provider and model override
514        let review = tasks.review.expect("review should exist");
515        assert_eq!(review.provider, Some("openrouter".to_string()));
516        assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
517
518        // Create: only model override
519        let create = tasks.create.expect("create should exist");
520        assert_eq!(create.provider, None);
521        assert_eq!(
522            create.model,
523            Some("anthropic/claude-sonnet-4.6".to_string())
524        );
525    }
526
527    #[test]
528    fn test_config_with_partial_task_overrides() {
529        // Test that partial task configs (only provider or only model) parse correctly
530        let config_str = r#"
531[ai]
532provider = "gemini"
533model = "gemini-3.1-flash-lite-preview"
534
535[ai.tasks.triage]
536provider = "gemini"
537
538[ai.tasks.review]
539model = "gemini-3.1-flash-lite-preview"
540"#;
541
542        let config = Config::builder()
543            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
544            .build()
545            .expect("should build config");
546
547        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
548
549        let tasks = app_config.ai.tasks.expect("tasks should exist");
550
551        // Triage: only provider
552        let triage = tasks.triage.expect("triage should exist");
553        assert_eq!(triage.provider, Some("gemini".to_string()));
554        assert_eq!(triage.model, None);
555
556        // Review: only model
557        let review = tasks.review.expect("review should exist");
558        assert_eq!(review.provider, None);
559        assert_eq!(review.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
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, DEFAULT_GEMINI_MODEL);
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/mistralai/mistral-small-2603)
590        let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
591        assert_eq!(provider, "openrouter");
592        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
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, DEFAULT_OPENROUTER_MODEL);
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, "mistralai/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-3.1-flash-lite-preview"
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, DEFAULT_GEMINI_MODEL);
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, DEFAULT_GEMINI_MODEL);
634
635        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
636        assert_eq!(provider, "gemini");
637        assert_eq!(model, DEFAULT_GEMINI_MODEL);
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, DEFAULT_GEMINI_MODEL);
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, DEFAULT_GEMINI_MODEL);
668
669        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
670        assert_eq!(provider, "gemini");
671        assert_eq!(model, DEFAULT_GEMINI_MODEL);
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/mistral-small-2603"
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, DEFAULT_OPENROUTER_MODEL);
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, DEFAULT_GEMINI_MODEL);
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/mistral-small-2603"
725
726[ai.tasks.triage]
727model = "mistralai/mistral-small-2603"
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, DEFAULT_OPENROUTER_MODEL);
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, DEFAULT_OPENROUTER_MODEL);
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, DEFAULT_OPENROUTER_MODEL);
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
862    #[test]
863    #[serial]
864    fn test_load_config_env_var_override() {
865        // Test that APTU_AI__MODEL and APTU_AI__PROVIDER env vars override defaults.
866        let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
867        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
868        // SAFETY: single-threaded test process; no concurrent env reads.
869        unsafe {
870            std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
871            std::env::set_var("APTU_AI__MODEL", "test-model-override");
872            std::env::set_var("APTU_AI__PROVIDER", "openrouter");
873        }
874        let config = load_config().expect("should load with env overrides");
875        unsafe {
876            std::env::remove_var("XDG_CONFIG_HOME");
877            std::env::remove_var("APTU_AI__MODEL");
878            std::env::remove_var("APTU_AI__PROVIDER");
879        }
880
881        assert_eq!(config.ai.model, "test-model-override");
882        assert_eq!(config.ai.provider, "openrouter");
883    }
884}