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    /// PR review prompt settings.
61    #[serde(default)]
62    pub review: ReviewConfig,
63}
64
65/// User preferences.
66#[derive(Debug, Deserialize, Serialize, Default, Clone)]
67#[serde(default)]
68pub struct UserConfig {
69    /// Default repository to use (skip repo selection).
70    pub default_repo: Option<String>,
71}
72
73/// Task-specific AI model override.
74#[derive(Debug, Deserialize, Serialize, Default, Clone)]
75#[serde(default)]
76pub struct TaskOverride {
77    /// Optional provider override for this task.
78    pub provider: Option<String>,
79    /// Optional model override for this task.
80    pub model: Option<String>,
81}
82
83/// Task-specific AI configuration.
84#[derive(Debug, Deserialize, Serialize, Default, Clone)]
85#[serde(default)]
86pub struct TasksConfig {
87    /// Triage task configuration.
88    pub triage: Option<TaskOverride>,
89    /// Review task configuration.
90    pub review: Option<TaskOverride>,
91    /// Create task configuration.
92    pub create: Option<TaskOverride>,
93}
94
95/// Single entry in the fallback provider chain.
96#[derive(Debug, Clone, Serialize)]
97pub struct FallbackEntry {
98    /// Provider name (e.g., "openrouter", "anthropic", "gemini").
99    pub provider: String,
100    /// Optional model override for this specific provider.
101    pub model: Option<String>,
102}
103
104impl<'de> Deserialize<'de> for FallbackEntry {
105    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
106    where
107        D: serde::Deserializer<'de>,
108    {
109        #[derive(Deserialize)]
110        #[serde(untagged)]
111        enum EntryVariant {
112            String(String),
113            Struct {
114                provider: String,
115                model: Option<String>,
116            },
117        }
118
119        match EntryVariant::deserialize(deserializer)? {
120            EntryVariant::String(provider) => Ok(FallbackEntry {
121                provider,
122                model: None,
123            }),
124            EntryVariant::Struct { provider, model } => Ok(FallbackEntry { provider, model }),
125        }
126    }
127}
128
129/// Fallback provider chain configuration.
130#[derive(Debug, Deserialize, Serialize, Clone, Default)]
131#[serde(default)]
132pub struct FallbackConfig {
133    /// Chain of fallback entries to try in order when primary fails.
134    pub chain: Vec<FallbackEntry>,
135}
136
137/// Default value for `retry_max_attempts`.
138fn default_retry_max_attempts() -> u32 {
139    3
140}
141
142/// AI provider settings.
143#[derive(Debug, Deserialize, Serialize, Clone)]
144#[serde(default)]
145pub struct AiConfig {
146    /// AI provider: one of `"gemini"`, `"openrouter"`, `"groq"`, `"cerebras"`, `"zenmux"`, or `"zai"`.
147    pub provider: String,
148    /// Model identifier.
149    pub model: String,
150    /// Request timeout in seconds.
151    pub timeout_seconds: u64,
152    /// Allow paid models (default: true).
153    pub allow_paid_models: bool,
154    /// Maximum tokens for API responses.
155    pub max_tokens: u32,
156    /// Temperature for API requests (0.0-1.0).
157    pub temperature: f32,
158    /// Circuit breaker failure threshold before opening (default: 3).
159    pub circuit_breaker_threshold: u32,
160    /// Circuit breaker reset timeout in seconds (default: 60).
161    pub circuit_breaker_reset_seconds: u64,
162    /// Maximum retry attempts for rate-limited requests (default: 3).
163    #[serde(default = "default_retry_max_attempts")]
164    pub retry_max_attempts: u32,
165    /// Task-specific model overrides.
166    pub tasks: Option<TasksConfig>,
167    /// Fallback provider chain for resilience.
168    pub fallback: Option<FallbackConfig>,
169    /// Custom guidance to override or extend default best practices.
170    ///
171    /// Allows users to provide project-specific tooling recommendations
172    /// that will be appended to the default best practices context.
173    /// Useful for enforcing project-specific choices (e.g., poetry instead of uv).
174    pub custom_guidance: Option<String>,
175    /// Enable pre-flight model validation with fuzzy matching (default: true).
176    ///
177    /// When enabled, validates that the configured model ID exists in the
178    /// cached model registry before creating an AI client. Provides helpful
179    /// suggestions if an invalid model ID is detected.
180    pub validation_enabled: bool,
181}
182
183impl Default for AiConfig {
184    fn default() -> Self {
185        Self {
186            provider: "openrouter".to_string(),
187            model: DEFAULT_OPENROUTER_MODEL.to_string(),
188            timeout_seconds: 30,
189            allow_paid_models: true,
190            max_tokens: 4096,
191            temperature: 0.3,
192            circuit_breaker_threshold: 3,
193            circuit_breaker_reset_seconds: 60,
194            retry_max_attempts: default_retry_max_attempts(),
195            tasks: None,
196            fallback: None,
197            custom_guidance: None,
198            validation_enabled: true,
199        }
200    }
201}
202
203impl AiConfig {
204    /// Resolve provider and model for a specific task type.
205    ///
206    /// Returns a tuple of (provider, model) by checking task-specific overrides first,
207    /// then falling back to the default provider and model.
208    ///
209    /// # Arguments
210    ///
211    /// * `task` - The task type to resolve configuration for
212    ///
213    /// # Returns
214    ///
215    /// A tuple of (`provider_name`, `model_name`) strings
216    #[must_use]
217    pub fn resolve_for_task(&self, task: TaskType) -> (String, String) {
218        let task_override = match task {
219            TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
220            TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
221            TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
222        };
223
224        let provider = task_override
225            .and_then(|o| o.provider.clone())
226            .unwrap_or_else(|| self.provider.clone());
227
228        let model = task_override
229            .and_then(|o| o.model.clone())
230            .unwrap_or_else(|| self.model.clone());
231
232        (provider, model)
233    }
234}
235
236/// GitHub API settings.
237#[derive(Debug, Deserialize, Serialize, Clone)]
238#[serde(default)]
239pub struct GitHubConfig {
240    /// API request timeout in seconds.
241    pub api_timeout_seconds: u64,
242}
243
244impl Default for GitHubConfig {
245    fn default() -> Self {
246        Self {
247            api_timeout_seconds: 10,
248        }
249    }
250}
251
252/// UI preferences.
253#[derive(Debug, Deserialize, Serialize, Clone)]
254#[serde(default)]
255pub struct UiConfig {
256    /// Enable colored output.
257    pub color: bool,
258    /// Show progress bars.
259    pub progress_bars: bool,
260    /// Always confirm before posting comments.
261    pub confirm_before_post: bool,
262}
263
264impl Default for UiConfig {
265    fn default() -> Self {
266        Self {
267            color: true,
268            progress_bars: true,
269            confirm_before_post: true,
270        }
271    }
272}
273
274/// Cache settings.
275#[derive(Debug, Deserialize, Serialize, Clone)]
276#[serde(default)]
277pub struct CacheConfig {
278    /// Issue cache TTL in minutes.
279    pub issue_ttl_minutes: i64,
280    /// Repository metadata cache TTL in hours.
281    pub repo_ttl_hours: i64,
282    /// URL to fetch curated repositories from.
283    pub curated_repos_url: String,
284}
285
286impl Default for CacheConfig {
287    fn default() -> Self {
288        Self {
289            issue_ttl_minutes: crate::cache::DEFAULT_ISSUE_TTL_MINS,
290            repo_ttl_hours: crate::cache::DEFAULT_REPO_TTL_HOURS,
291            curated_repos_url:
292                "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
293                    .to_string(),
294        }
295    }
296}
297
298/// Repository settings.
299#[derive(Debug, Deserialize, Serialize, Clone)]
300#[serde(default)]
301pub struct ReposConfig {
302    /// Include curated repositories (default: true).
303    pub curated: bool,
304}
305
306impl Default for ReposConfig {
307    fn default() -> Self {
308        Self { curated: true }
309    }
310}
311
312/// PR review prompt configuration.
313///
314/// Controls prompt token budgets and GitHub API constraints for PR reviews:
315///
316/// - `max_prompt_chars`: 120,000 chars is a conservative budget below common LLM context
317///   window limits (e.g., 128k token models), accounting for system prompt and response overhead.
318/// - `max_full_content_files`: 10 files caps GitHub Contents API calls per review to limit
319///   latency and rate limit usage.
320/// - `max_chars_per_file`: 4,000 chars per file keeps individual file snippets readable
321///   without dominating the prompt budget.
322#[derive(Debug, Deserialize, Serialize, Clone)]
323#[serde(default)]
324pub struct ReviewConfig {
325    /// Maximum total prompt character budget (default: `120_000`).
326    pub max_prompt_chars: usize,
327    /// Maximum number of files to fetch full content for (default: 10).
328    pub max_full_content_files: usize,
329    /// Maximum characters per file's full content (default: `4_000`).
330    pub max_chars_per_file: usize,
331}
332
333impl Default for ReviewConfig {
334    fn default() -> Self {
335        Self {
336            max_prompt_chars: 120_000, // Conservative budget for LLM context windows with overhead
337            max_full_content_files: 10, // Cap GitHub Contents API calls to limit latency and rate limits
338            max_chars_per_file: 4_000, // Keep individual file snippets readable without overwhelming prompt
339        }
340    }
341}
342
343/// Returns the Aptu configuration directory.
344///
345/// Respects the `XDG_CONFIG_HOME` environment variable if set,
346/// otherwise defaults to `~/.config/aptu`.
347#[must_use]
348pub fn config_dir() -> PathBuf {
349    if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
350        && !xdg_config.is_empty()
351    {
352        return PathBuf::from(xdg_config).join("aptu");
353    }
354    dirs::home_dir()
355        .expect("Could not determine home directory - is HOME set?")
356        .join(".config")
357        .join("aptu")
358}
359
360/// Returns the Aptu data directory.
361///
362/// Respects the `XDG_DATA_HOME` environment variable if set,
363/// otherwise defaults to `~/.local/share/aptu`.
364#[must_use]
365pub fn data_dir() -> PathBuf {
366    if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
367        && !xdg_data.is_empty()
368    {
369        return PathBuf::from(xdg_data).join("aptu");
370    }
371    dirs::home_dir()
372        .expect("Could not determine home directory - is HOME set?")
373        .join(".local")
374        .join("share")
375        .join("aptu")
376}
377
378/// Returns the Aptu prompts configuration directory.
379///
380/// Prompt override files are loaded from this directory at runtime.
381/// Place a `<name>.md` file here to override the compiled-in prompt.
382///
383/// Respects the `XDG_CONFIG_HOME` environment variable if set,
384/// otherwise defaults to `~/.config/aptu/prompts`.
385#[must_use]
386pub fn prompts_dir() -> PathBuf {
387    config_dir().join("prompts")
388}
389
390/// Returns the path to the configuration file.
391#[must_use]
392pub fn config_file_path() -> PathBuf {
393    config_dir().join("config.toml")
394}
395
396/// Load application configuration.
397///
398/// Loads from config file (if exists) and environment variables.
399/// Environment variables use the prefix `APTU_` and double underscore
400/// for nested keys (e.g., `APTU_AI__MODEL`).
401///
402/// # Errors
403///
404/// Returns `AptuError::Config` if the config file exists but is invalid.
405pub fn load_config() -> Result<AppConfig, AptuError> {
406    let config_path = config_file_path();
407
408    let config = Config::builder()
409        // Load from config file (optional - may not exist)
410        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
411        // Override with environment variables
412        .add_source(
413            Environment::with_prefix("APTU")
414                .prefix_separator("_")
415                .separator("__")
416                .try_parsing(true),
417        )
418        .build()?;
419
420    let app_config: AppConfig = config.try_deserialize()?;
421
422    Ok(app_config)
423}
424
425#[cfg(test)]
426mod tests {
427    #![allow(unsafe_code)]
428    use super::*;
429    use serial_test::serial;
430
431    #[test]
432    #[serial]
433    fn test_load_config_defaults() {
434        // Without any config file or env vars, should return defaults.
435        // Point XDG_CONFIG_HOME to a guaranteed-empty temp dir so the real
436        // user config (~/.config/aptu/config.toml) is not loaded.
437        let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
438        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
439        // SAFETY: single-threaded test process; no concurrent env reads.
440        unsafe {
441            std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
442        }
443        let config = load_config().expect("should load with defaults");
444        unsafe {
445            std::env::remove_var("XDG_CONFIG_HOME");
446        }
447
448        assert_eq!(config.ai.provider, "openrouter");
449        assert_eq!(config.ai.model, DEFAULT_OPENROUTER_MODEL);
450        assert_eq!(config.ai.timeout_seconds, 30);
451        assert_eq!(config.ai.max_tokens, 4096);
452        assert_eq!(config.ai.allow_paid_models, true);
453        #[allow(clippy::float_cmp)]
454        {
455            assert_eq!(config.ai.temperature, 0.3);
456        }
457        assert_eq!(config.github.api_timeout_seconds, 10);
458        assert!(config.ui.color);
459        assert!(config.ui.confirm_before_post);
460        assert_eq!(config.cache.issue_ttl_minutes, 60);
461    }
462
463    #[test]
464    fn test_config_dir_exists() {
465        let dir = config_dir();
466        assert!(dir.ends_with("aptu"));
467    }
468
469    #[test]
470    fn test_data_dir_exists() {
471        let dir = data_dir();
472        assert!(dir.ends_with("aptu"));
473    }
474
475    #[test]
476    fn test_config_file_path() {
477        let path = config_file_path();
478        assert!(path.ends_with("config.toml"));
479    }
480
481    #[test]
482    fn test_config_with_task_triage_override() {
483        // Test that config with [ai.tasks.triage] parses correctly
484        let config_str = r#"
485[ai]
486provider = "gemini"
487model = "gemini-3.1-flash-lite-preview"
488
489[ai.tasks.triage]
490model = "gemini-3.1-flash-lite-preview"
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        assert_eq!(app_config.ai.provider, "gemini");
501        assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
502        assert!(app_config.ai.tasks.is_some());
503
504        let tasks = app_config.ai.tasks.unwrap();
505        assert!(tasks.triage.is_some());
506        assert!(tasks.review.is_none());
507        assert!(tasks.create.is_none());
508
509        let triage = tasks.triage.unwrap();
510        assert_eq!(triage.provider, None);
511        assert_eq!(triage.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
512    }
513
514    #[test]
515    fn test_config_with_multiple_task_overrides() {
516        // Test that config with multiple task overrides parses correctly
517        let config_str = r#"
518[ai]
519provider = "openrouter"
520model = "mistralai/mistral-small-2603"
521
522[ai.tasks.triage]
523model = "mistralai/mistral-small-2603"
524
525[ai.tasks.review]
526provider = "openrouter"
527model = "anthropic/claude-haiku-4.5"
528
529[ai.tasks.create]
530model = "anthropic/claude-sonnet-4.6"
531"#;
532
533        let config = Config::builder()
534            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
535            .build()
536            .expect("should build config");
537
538        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
539
540        let tasks = app_config.ai.tasks.expect("tasks should exist");
541
542        // Triage: only model override
543        let triage = tasks.triage.expect("triage should exist");
544        assert_eq!(triage.provider, None);
545        assert_eq!(triage.model, Some(DEFAULT_OPENROUTER_MODEL.to_string()));
546
547        // Review: both provider and model override
548        let review = tasks.review.expect("review should exist");
549        assert_eq!(review.provider, Some("openrouter".to_string()));
550        assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
551
552        // Create: only model override
553        let create = tasks.create.expect("create should exist");
554        assert_eq!(create.provider, None);
555        assert_eq!(
556            create.model,
557            Some("anthropic/claude-sonnet-4.6".to_string())
558        );
559    }
560
561    #[test]
562    fn test_config_with_partial_task_overrides() {
563        // Test that partial task configs (only provider or only model) parse correctly
564        let config_str = r#"
565[ai]
566provider = "gemini"
567model = "gemini-3.1-flash-lite-preview"
568
569[ai.tasks.triage]
570provider = "gemini"
571
572[ai.tasks.review]
573model = "gemini-3.1-flash-lite-preview"
574"#;
575
576        let config = Config::builder()
577            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
578            .build()
579            .expect("should build config");
580
581        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
582
583        let tasks = app_config.ai.tasks.expect("tasks should exist");
584
585        // Triage: only provider
586        let triage = tasks.triage.expect("triage should exist");
587        assert_eq!(triage.provider, Some("gemini".to_string()));
588        assert_eq!(triage.model, None);
589
590        // Review: only model
591        let review = tasks.review.expect("review should exist");
592        assert_eq!(review.provider, None);
593        assert_eq!(review.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
594    }
595
596    #[test]
597    fn test_config_without_tasks_section() {
598        // Test that config without explicit tasks section uses defaults
599        let config_str = r#"
600[ai]
601provider = "gemini"
602model = "gemini-3.1-flash-lite-preview"
603"#;
604
605        let config = Config::builder()
606            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
607            .build()
608            .expect("should build config");
609
610        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
611
612        assert_eq!(app_config.ai.provider, "gemini");
613        assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
614        // When no tasks section is provided, defaults are used (tasks: None)
615        assert!(app_config.ai.tasks.is_none());
616    }
617
618    #[test]
619    fn test_resolve_for_task_with_defaults() {
620        // Test that resolve_for_task returns correct defaults (all tasks use openrouter)
621        let ai_config = AiConfig::default();
622
623        // All tasks use global defaults (openrouter/mistralai/mistral-small-2603)
624        let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
625        assert_eq!(provider, "openrouter");
626        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
627        assert_eq!(ai_config.allow_paid_models, true);
628
629        let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
630        assert_eq!(provider, "openrouter");
631        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
632        assert_eq!(ai_config.allow_paid_models, true);
633
634        let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
635        assert_eq!(provider, "openrouter");
636        assert_eq!(model, "mistralai/mistral-small-2603");
637        assert_eq!(ai_config.allow_paid_models, true);
638    }
639
640    #[test]
641    fn test_resolve_for_task_with_triage_override() {
642        // Test that resolve_for_task returns triage override when present
643        let config_str = r#"
644[ai]
645provider = "gemini"
646model = "gemini-3.1-flash-lite-preview"
647
648[ai.tasks.triage]
649model = "gemini-3.1-flash-lite-preview"
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        // Triage should use override
660        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
661        assert_eq!(provider, "gemini");
662        assert_eq!(model, DEFAULT_GEMINI_MODEL);
663
664        // Review and Create should use defaults
665        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
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_provider_override() {
676        // Test that resolve_for_task returns provider override when present
677        let config_str = r#"
678[ai]
679provider = "gemini"
680model = "gemini-3.1-flash-lite-preview"
681
682[ai.tasks.review]
683provider = "openrouter"
684"#;
685
686        let config = Config::builder()
687            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
688            .build()
689            .expect("should build config");
690
691        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
692
693        // Review should use provider override but default model
694        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
695        assert_eq!(provider, "openrouter");
696        assert_eq!(model, DEFAULT_GEMINI_MODEL);
697
698        // Triage and Create should use defaults
699        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
700        assert_eq!(provider, "gemini");
701        assert_eq!(model, DEFAULT_GEMINI_MODEL);
702
703        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
704        assert_eq!(provider, "gemini");
705        assert_eq!(model, DEFAULT_GEMINI_MODEL);
706    }
707
708    #[test]
709    fn test_resolve_for_task_with_full_overrides() {
710        // Test that resolve_for_task returns both provider and model overrides
711        let config_str = r#"
712[ai]
713provider = "gemini"
714model = "gemini-3.1-flash-lite-preview"
715
716[ai.tasks.triage]
717provider = "openrouter"
718model = "mistralai/mistral-small-2603"
719
720[ai.tasks.review]
721provider = "openrouter"
722model = "anthropic/claude-haiku-4.5"
723
724[ai.tasks.create]
725provider = "gemini"
726model = "gemini-3.1-flash-lite-preview"
727"#;
728
729        let config = Config::builder()
730            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
731            .build()
732            .expect("should build config");
733
734        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
735
736        // Triage
737        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
738        assert_eq!(provider, "openrouter");
739        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
740
741        // Review
742        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
743        assert_eq!(provider, "openrouter");
744        assert_eq!(model, "anthropic/claude-haiku-4.5");
745
746        // Create
747        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
748        assert_eq!(provider, "gemini");
749        assert_eq!(model, DEFAULT_GEMINI_MODEL);
750    }
751
752    #[test]
753    fn test_resolve_for_task_partial_overrides() {
754        // Test that resolve_for_task handles partial overrides correctly
755        let config_str = r#"
756[ai]
757provider = "openrouter"
758model = "mistralai/mistral-small-2603"
759
760[ai.tasks.triage]
761model = "mistralai/mistral-small-2603"
762
763[ai.tasks.review]
764provider = "openrouter"
765
766[ai.tasks.create]
767"#;
768
769        let config = Config::builder()
770            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
771            .build()
772            .expect("should build config");
773
774        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
775
776        // Triage: model override, provider from default
777        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
778        assert_eq!(provider, "openrouter");
779        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
780
781        // Review: provider override, model from default
782        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
783        assert_eq!(provider, "openrouter");
784        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
785
786        // Create: empty override, both from default
787        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
788        assert_eq!(provider, "openrouter");
789        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
790    }
791
792    #[test]
793    fn test_fallback_config_toml_parsing() {
794        // Test that FallbackConfig deserializes from TOML correctly
795        let config_str = r#"
796[ai]
797provider = "gemini"
798model = "gemini-3.1-flash-lite-preview"
799
800[ai.fallback]
801chain = ["openrouter", "anthropic"]
802"#;
803
804        let config = Config::builder()
805            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
806            .build()
807            .expect("should build config");
808
809        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
810
811        assert_eq!(app_config.ai.provider, "gemini");
812        assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
813        assert!(app_config.ai.fallback.is_some());
814
815        let fallback = app_config.ai.fallback.unwrap();
816        assert_eq!(fallback.chain.len(), 2);
817        assert_eq!(fallback.chain[0].provider, "openrouter");
818        assert_eq!(fallback.chain[1].provider, "anthropic");
819    }
820
821    #[test]
822    fn test_fallback_config_empty_chain() {
823        // Test that FallbackConfig with empty chain parses correctly
824        let config_str = r#"
825[ai]
826provider = "gemini"
827model = "gemini-3.1-flash-lite-preview"
828
829[ai.fallback]
830chain = []
831"#;
832
833        let config = Config::builder()
834            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
835            .build()
836            .expect("should build config");
837
838        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
839
840        assert!(app_config.ai.fallback.is_some());
841        let fallback = app_config.ai.fallback.unwrap();
842        assert_eq!(fallback.chain.len(), 0);
843    }
844
845    #[test]
846    fn test_fallback_config_single_provider() {
847        // Test that FallbackConfig with single provider parses correctly
848        let config_str = r#"
849[ai]
850provider = "gemini"
851model = "gemini-3.1-flash-lite-preview"
852
853[ai.fallback]
854chain = ["openrouter"]
855"#;
856
857        let config = Config::builder()
858            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
859            .build()
860            .expect("should build config");
861
862        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
863
864        assert!(app_config.ai.fallback.is_some());
865        let fallback = app_config.ai.fallback.unwrap();
866        assert_eq!(fallback.chain.len(), 1);
867        assert_eq!(fallback.chain[0].provider, "openrouter");
868    }
869
870    #[test]
871    fn test_fallback_config_without_fallback_section() {
872        // Test that config without fallback section has None
873        let config_str = r#"
874[ai]
875provider = "gemini"
876model = "gemini-3.1-flash-lite-preview"
877"#;
878
879        let config = Config::builder()
880            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
881            .build()
882            .expect("should build config");
883
884        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
885
886        assert!(app_config.ai.fallback.is_none());
887    }
888
889    #[test]
890    fn test_fallback_config_default() {
891        // Test that AiConfig::default() has fallback: None
892        let ai_config = AiConfig::default();
893        assert!(ai_config.fallback.is_none());
894    }
895
896    #[test]
897    #[serial]
898    fn test_load_config_env_var_override() {
899        // Test that APTU_AI__MODEL and APTU_AI__PROVIDER env vars override defaults.
900        let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
901        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
902        // SAFETY: single-threaded test process; no concurrent env reads.
903        unsafe {
904            std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
905            std::env::set_var("APTU_AI__MODEL", "test-model-override");
906            std::env::set_var("APTU_AI__PROVIDER", "openrouter");
907        }
908        let config = load_config().expect("should load with env overrides");
909        unsafe {
910            std::env::remove_var("XDG_CONFIG_HOME");
911            std::env::remove_var("APTU_AI__MODEL");
912            std::env::remove_var("APTU_AI__PROVIDER");
913        }
914
915        assert_eq!(config.ai.model, "test-model-override");
916        assert_eq!(config.ai.provider, "openrouter");
917    }
918
919    #[test]
920    fn test_review_config_defaults() {
921        // Arrange / Act: construct ReviewConfig with defaults
922        let review_config = ReviewConfig::default();
923
924        // Assert: defaults match specification
925        assert_eq!(
926            review_config.max_prompt_chars, 120_000,
927            "max_prompt_chars should default to 120_000"
928        );
929        assert_eq!(
930            review_config.max_full_content_files, 10,
931            "max_full_content_files should default to 10"
932        );
933        assert_eq!(
934            review_config.max_chars_per_file, 4_000,
935            "max_chars_per_file should default to 4_000"
936        );
937
938        // Assert: AppConfig::default().review equals ReviewConfig::default()
939        let app_config = AppConfig::default();
940        assert_eq!(
941            app_config.review.max_prompt_chars, review_config.max_prompt_chars,
942            "AppConfig review defaults should match ReviewConfig defaults"
943        );
944        assert_eq!(
945            app_config.review.max_full_content_files, review_config.max_full_content_files,
946            "AppConfig review defaults should match ReviewConfig defaults"
947        );
948        assert_eq!(
949            app_config.review.max_chars_per_file, review_config.max_chars_per_file,
950            "AppConfig review defaults should match ReviewConfig defaults"
951        );
952    }
953}