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    /// DCO sign-off on commits (default: false).
305    #[serde(default)]
306    pub dco_signoff: bool,
307}
308
309impl Default for ReposConfig {
310    fn default() -> Self {
311        Self {
312            curated: true,
313            dco_signoff: false,
314        }
315    }
316}
317
318/// PR review prompt configuration.
319///
320/// Controls prompt token budgets and GitHub API constraints for PR reviews:
321///
322/// - `max_prompt_chars`: 120,000 chars is a conservative budget below common LLM context
323///   window limits (e.g., 128k token models), accounting for system prompt and response overhead.
324/// - `max_full_content_files`: 10 files caps GitHub Contents API calls per review to limit
325///   latency and rate limit usage.
326/// - `max_chars_per_file`: 4,000 chars per file keeps individual file snippets readable
327///   without dominating the prompt budget.
328#[derive(Debug, Deserialize, Serialize, Clone)]
329#[serde(default)]
330pub struct ReviewConfig {
331    /// Maximum total prompt character budget (default: `120_000`).
332    pub max_prompt_chars: usize,
333    /// Maximum number of files to fetch full content for (default: 10).
334    pub max_full_content_files: usize,
335    /// Maximum characters per file's full content (default: `4_000`).
336    pub max_chars_per_file: usize,
337}
338
339impl Default for ReviewConfig {
340    fn default() -> Self {
341        Self {
342            max_prompt_chars: 120_000, // Conservative budget for LLM context windows with overhead
343            max_full_content_files: 10, // Cap GitHub Contents API calls to limit latency and rate limits
344            max_chars_per_file: 4_000, // Keep individual file snippets readable without overwhelming prompt
345        }
346    }
347}
348
349/// Returns the Aptu configuration directory.
350///
351/// Respects the `XDG_CONFIG_HOME` environment variable if set,
352/// otherwise defaults to `~/.config/aptu`.
353#[must_use]
354pub fn config_dir() -> PathBuf {
355    if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
356        && !xdg_config.is_empty()
357    {
358        return PathBuf::from(xdg_config).join("aptu");
359    }
360    dirs::home_dir()
361        .expect("Could not determine home directory - is HOME set?")
362        .join(".config")
363        .join("aptu")
364}
365
366/// Returns the Aptu data directory.
367///
368/// Respects the `XDG_DATA_HOME` environment variable if set,
369/// otherwise defaults to `~/.local/share/aptu`.
370#[must_use]
371pub fn data_dir() -> PathBuf {
372    if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
373        && !xdg_data.is_empty()
374    {
375        return PathBuf::from(xdg_data).join("aptu");
376    }
377    dirs::home_dir()
378        .expect("Could not determine home directory - is HOME set?")
379        .join(".local")
380        .join("share")
381        .join("aptu")
382}
383
384/// Returns the Aptu prompts configuration directory.
385///
386/// Prompt override files are loaded from this directory at runtime.
387/// Place a `<name>.md` file here to override the compiled-in prompt.
388///
389/// Respects the `XDG_CONFIG_HOME` environment variable if set,
390/// otherwise defaults to `~/.config/aptu/prompts`.
391#[must_use]
392pub fn prompts_dir() -> PathBuf {
393    config_dir().join("prompts")
394}
395
396/// Returns the path to the configuration file.
397#[must_use]
398pub fn config_file_path() -> PathBuf {
399    config_dir().join("config.toml")
400}
401
402/// Load application configuration.
403///
404/// Loads from config file (if exists) and environment variables.
405/// Environment variables use the prefix `APTU_` and double underscore
406/// for nested keys (e.g., `APTU_AI__MODEL`).
407///
408/// # Errors
409///
410/// Returns `AptuError::Config` if the config file exists but is invalid.
411pub fn load_config() -> Result<AppConfig, AptuError> {
412    let config_path = config_file_path();
413
414    let config = Config::builder()
415        // Load from config file (optional - may not exist)
416        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
417        // Override with environment variables
418        .add_source(
419            Environment::with_prefix("APTU")
420                .prefix_separator("_")
421                .separator("__")
422                .try_parsing(true),
423        )
424        .build()?;
425
426    let app_config: AppConfig = config.try_deserialize()?;
427
428    Ok(app_config)
429}
430
431#[cfg(test)]
432mod tests {
433    #![allow(unsafe_code)]
434    use super::*;
435    use serial_test::serial;
436
437    #[test]
438    #[serial]
439    fn test_load_config_defaults() {
440        // Without any config file or env vars, should return defaults.
441        // Point XDG_CONFIG_HOME to a guaranteed-empty temp dir so the real
442        // user config (~/.config/aptu/config.toml) is not loaded.
443        let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
444        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
445        // SAFETY: single-threaded test process; no concurrent env reads.
446        unsafe {
447            std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
448        }
449        let config = load_config().expect("should load with defaults");
450        unsafe {
451            std::env::remove_var("XDG_CONFIG_HOME");
452        }
453
454        assert_eq!(config.ai.provider, "openrouter");
455        assert_eq!(config.ai.model, DEFAULT_OPENROUTER_MODEL);
456        assert_eq!(config.ai.timeout_seconds, 30);
457        assert_eq!(config.ai.max_tokens, 4096);
458        assert_eq!(config.ai.allow_paid_models, true);
459        #[allow(clippy::float_cmp)]
460        {
461            assert_eq!(config.ai.temperature, 0.3);
462        }
463        assert_eq!(config.github.api_timeout_seconds, 10);
464        assert!(config.ui.color);
465        assert!(config.ui.confirm_before_post);
466        assert_eq!(config.cache.issue_ttl_minutes, 60);
467    }
468
469    #[test]
470    fn test_config_dir_exists() {
471        let dir = config_dir();
472        assert!(dir.ends_with("aptu"));
473    }
474
475    #[test]
476    fn test_data_dir_exists() {
477        let dir = data_dir();
478        assert!(dir.ends_with("aptu"));
479    }
480
481    #[test]
482    fn test_config_file_path() {
483        let path = config_file_path();
484        assert!(path.ends_with("config.toml"));
485    }
486
487    #[test]
488    fn test_config_with_task_triage_override() {
489        // Test that config with [ai.tasks.triage] parses correctly
490        let config_str = r#"
491[ai]
492provider = "gemini"
493model = "gemini-3.1-flash-lite-preview"
494
495[ai.tasks.triage]
496model = "gemini-3.1-flash-lite-preview"
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        assert_eq!(app_config.ai.provider, "gemini");
507        assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
508        assert!(app_config.ai.tasks.is_some());
509
510        let tasks = app_config.ai.tasks.unwrap();
511        assert!(tasks.triage.is_some());
512        assert!(tasks.review.is_none());
513        assert!(tasks.create.is_none());
514
515        let triage = tasks.triage.unwrap();
516        assert_eq!(triage.provider, None);
517        assert_eq!(triage.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
518    }
519
520    #[test]
521    fn test_config_with_multiple_task_overrides() {
522        // Test that config with multiple task overrides parses correctly
523        let config_str = r#"
524[ai]
525provider = "openrouter"
526model = "mistralai/mistral-small-2603"
527
528[ai.tasks.triage]
529model = "mistralai/mistral-small-2603"
530
531[ai.tasks.review]
532provider = "openrouter"
533model = "anthropic/claude-haiku-4.5"
534
535[ai.tasks.create]
536model = "anthropic/claude-sonnet-4.6"
537"#;
538
539        let config = Config::builder()
540            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
541            .build()
542            .expect("should build config");
543
544        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
545
546        let tasks = app_config.ai.tasks.expect("tasks should exist");
547
548        // Triage: only model override
549        let triage = tasks.triage.expect("triage should exist");
550        assert_eq!(triage.provider, None);
551        assert_eq!(triage.model, Some(DEFAULT_OPENROUTER_MODEL.to_string()));
552
553        // Review: both provider and model override
554        let review = tasks.review.expect("review should exist");
555        assert_eq!(review.provider, Some("openrouter".to_string()));
556        assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
557
558        // Create: only model override
559        let create = tasks.create.expect("create should exist");
560        assert_eq!(create.provider, None);
561        assert_eq!(
562            create.model,
563            Some("anthropic/claude-sonnet-4.6".to_string())
564        );
565    }
566
567    #[test]
568    fn test_config_with_partial_task_overrides() {
569        // Test that partial task configs (only provider or only model) parse correctly
570        let config_str = r#"
571[ai]
572provider = "gemini"
573model = "gemini-3.1-flash-lite-preview"
574
575[ai.tasks.triage]
576provider = "gemini"
577
578[ai.tasks.review]
579model = "gemini-3.1-flash-lite-preview"
580"#;
581
582        let config = Config::builder()
583            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
584            .build()
585            .expect("should build config");
586
587        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
588
589        let tasks = app_config.ai.tasks.expect("tasks should exist");
590
591        // Triage: only provider
592        let triage = tasks.triage.expect("triage should exist");
593        assert_eq!(triage.provider, Some("gemini".to_string()));
594        assert_eq!(triage.model, None);
595
596        // Review: only model
597        let review = tasks.review.expect("review should exist");
598        assert_eq!(review.provider, None);
599        assert_eq!(review.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
600    }
601
602    #[test]
603    fn test_config_without_tasks_section() {
604        // Test that config without explicit tasks section uses defaults
605        let config_str = r#"
606[ai]
607provider = "gemini"
608model = "gemini-3.1-flash-lite-preview"
609"#;
610
611        let config = Config::builder()
612            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
613            .build()
614            .expect("should build config");
615
616        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
617
618        assert_eq!(app_config.ai.provider, "gemini");
619        assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
620        // When no tasks section is provided, defaults are used (tasks: None)
621        assert!(app_config.ai.tasks.is_none());
622    }
623
624    #[test]
625    fn test_resolve_for_task_with_defaults() {
626        // Test that resolve_for_task returns correct defaults (all tasks use openrouter)
627        let ai_config = AiConfig::default();
628
629        // All tasks use global defaults (openrouter/mistralai/mistral-small-2603)
630        let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
631        assert_eq!(provider, "openrouter");
632        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
633        assert_eq!(ai_config.allow_paid_models, true);
634
635        let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
636        assert_eq!(provider, "openrouter");
637        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
638        assert_eq!(ai_config.allow_paid_models, true);
639
640        let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
641        assert_eq!(provider, "openrouter");
642        assert_eq!(model, "mistralai/mistral-small-2603");
643        assert_eq!(ai_config.allow_paid_models, true);
644    }
645
646    #[test]
647    fn test_resolve_for_task_with_triage_override() {
648        // Test that resolve_for_task returns triage override when present
649        let config_str = r#"
650[ai]
651provider = "gemini"
652model = "gemini-3.1-flash-lite-preview"
653
654[ai.tasks.triage]
655model = "gemini-3.1-flash-lite-preview"
656"#;
657
658        let config = Config::builder()
659            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
660            .build()
661            .expect("should build config");
662
663        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
664
665        // Triage should use override
666        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
667        assert_eq!(provider, "gemini");
668        assert_eq!(model, DEFAULT_GEMINI_MODEL);
669
670        // Review and Create should use defaults
671        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
672        assert_eq!(provider, "gemini");
673        assert_eq!(model, DEFAULT_GEMINI_MODEL);
674
675        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
676        assert_eq!(provider, "gemini");
677        assert_eq!(model, DEFAULT_GEMINI_MODEL);
678    }
679
680    #[test]
681    fn test_resolve_for_task_with_provider_override() {
682        // Test that resolve_for_task returns provider override when present
683        let config_str = r#"
684[ai]
685provider = "gemini"
686model = "gemini-3.1-flash-lite-preview"
687
688[ai.tasks.review]
689provider = "openrouter"
690"#;
691
692        let config = Config::builder()
693            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
694            .build()
695            .expect("should build config");
696
697        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
698
699        // Review should use provider override but default model
700        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
701        assert_eq!(provider, "openrouter");
702        assert_eq!(model, DEFAULT_GEMINI_MODEL);
703
704        // Triage and Create should use defaults
705        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
706        assert_eq!(provider, "gemini");
707        assert_eq!(model, DEFAULT_GEMINI_MODEL);
708
709        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
710        assert_eq!(provider, "gemini");
711        assert_eq!(model, DEFAULT_GEMINI_MODEL);
712    }
713
714    #[test]
715    fn test_resolve_for_task_with_full_overrides() {
716        // Test that resolve_for_task returns both provider and model overrides
717        let config_str = r#"
718[ai]
719provider = "gemini"
720model = "gemini-3.1-flash-lite-preview"
721
722[ai.tasks.triage]
723provider = "openrouter"
724model = "mistralai/mistral-small-2603"
725
726[ai.tasks.review]
727provider = "openrouter"
728model = "anthropic/claude-haiku-4.5"
729
730[ai.tasks.create]
731provider = "gemini"
732model = "gemini-3.1-flash-lite-preview"
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
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
748        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
749        assert_eq!(provider, "openrouter");
750        assert_eq!(model, "anthropic/claude-haiku-4.5");
751
752        // Create
753        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
754        assert_eq!(provider, "gemini");
755        assert_eq!(model, DEFAULT_GEMINI_MODEL);
756    }
757
758    #[test]
759    fn test_resolve_for_task_partial_overrides() {
760        // Test that resolve_for_task handles partial overrides correctly
761        let config_str = r#"
762[ai]
763provider = "openrouter"
764model = "mistralai/mistral-small-2603"
765
766[ai.tasks.triage]
767model = "mistralai/mistral-small-2603"
768
769[ai.tasks.review]
770provider = "openrouter"
771
772[ai.tasks.create]
773"#;
774
775        let config = Config::builder()
776            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
777            .build()
778            .expect("should build config");
779
780        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
781
782        // Triage: model override, provider from default
783        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
784        assert_eq!(provider, "openrouter");
785        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
786
787        // Review: provider override, model from default
788        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
789        assert_eq!(provider, "openrouter");
790        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
791
792        // Create: empty override, both from default
793        let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
794        assert_eq!(provider, "openrouter");
795        assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
796    }
797
798    #[test]
799    fn test_fallback_config_toml_parsing() {
800        // Test that FallbackConfig deserializes from TOML correctly
801        let config_str = r#"
802[ai]
803provider = "gemini"
804model = "gemini-3.1-flash-lite-preview"
805
806[ai.fallback]
807chain = ["openrouter", "anthropic"]
808"#;
809
810        let config = Config::builder()
811            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
812            .build()
813            .expect("should build config");
814
815        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
816
817        assert_eq!(app_config.ai.provider, "gemini");
818        assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
819        assert!(app_config.ai.fallback.is_some());
820
821        let fallback = app_config.ai.fallback.unwrap();
822        assert_eq!(fallback.chain.len(), 2);
823        assert_eq!(fallback.chain[0].provider, "openrouter");
824        assert_eq!(fallback.chain[1].provider, "anthropic");
825    }
826
827    #[test]
828    fn test_fallback_config_empty_chain() {
829        // Test that FallbackConfig with empty chain parses correctly
830        let config_str = r#"
831[ai]
832provider = "gemini"
833model = "gemini-3.1-flash-lite-preview"
834
835[ai.fallback]
836chain = []
837"#;
838
839        let config = Config::builder()
840            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
841            .build()
842            .expect("should build config");
843
844        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
845
846        assert!(app_config.ai.fallback.is_some());
847        let fallback = app_config.ai.fallback.unwrap();
848        assert_eq!(fallback.chain.len(), 0);
849    }
850
851    #[test]
852    fn test_fallback_config_single_provider() {
853        // Test that FallbackConfig with single provider parses correctly
854        let config_str = r#"
855[ai]
856provider = "gemini"
857model = "gemini-3.1-flash-lite-preview"
858
859[ai.fallback]
860chain = ["openrouter"]
861"#;
862
863        let config = Config::builder()
864            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
865            .build()
866            .expect("should build config");
867
868        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
869
870        assert!(app_config.ai.fallback.is_some());
871        let fallback = app_config.ai.fallback.unwrap();
872        assert_eq!(fallback.chain.len(), 1);
873        assert_eq!(fallback.chain[0].provider, "openrouter");
874    }
875
876    #[test]
877    fn test_fallback_config_without_fallback_section() {
878        // Test that config without fallback section has None
879        let config_str = r#"
880[ai]
881provider = "gemini"
882model = "gemini-3.1-flash-lite-preview"
883"#;
884
885        let config = Config::builder()
886            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
887            .build()
888            .expect("should build config");
889
890        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
891
892        assert!(app_config.ai.fallback.is_none());
893    }
894
895    #[test]
896    fn test_fallback_config_default() {
897        // Test that AiConfig::default() has fallback: None
898        let ai_config = AiConfig::default();
899        assert!(ai_config.fallback.is_none());
900    }
901
902    #[test]
903    #[serial]
904    fn test_load_config_env_var_override() {
905        // Test that APTU_AI__MODEL and APTU_AI__PROVIDER env vars override defaults.
906        let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
907        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
908        // SAFETY: single-threaded test process; no concurrent env reads.
909        unsafe {
910            std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
911            std::env::set_var("APTU_AI__MODEL", "test-model-override");
912            std::env::set_var("APTU_AI__PROVIDER", "openrouter");
913        }
914        let config = load_config().expect("should load with env overrides");
915        unsafe {
916            std::env::remove_var("XDG_CONFIG_HOME");
917            std::env::remove_var("APTU_AI__MODEL");
918            std::env::remove_var("APTU_AI__PROVIDER");
919        }
920
921        assert_eq!(config.ai.model, "test-model-override");
922        assert_eq!(config.ai.provider, "openrouter");
923    }
924
925    #[test]
926    fn test_review_config_defaults() {
927        // Arrange / Act: construct ReviewConfig with defaults
928        let review_config = ReviewConfig::default();
929
930        // Assert: defaults match specification
931        assert_eq!(
932            review_config.max_prompt_chars, 120_000,
933            "max_prompt_chars should default to 120_000"
934        );
935        assert_eq!(
936            review_config.max_full_content_files, 10,
937            "max_full_content_files should default to 10"
938        );
939        assert_eq!(
940            review_config.max_chars_per_file, 4_000,
941            "max_chars_per_file should default to 4_000"
942        );
943
944        // Assert: AppConfig::default().review equals ReviewConfig::default()
945        let app_config = AppConfig::default();
946        assert_eq!(
947            app_config.review.max_prompt_chars, review_config.max_prompt_chars,
948            "AppConfig review defaults should match ReviewConfig defaults"
949        );
950        assert_eq!(
951            app_config.review.max_full_content_files, review_config.max_full_content_files,
952            "AppConfig review defaults should match ReviewConfig defaults"
953        );
954        assert_eq!(
955            app_config.review.max_chars_per_file, review_config.max_chars_per_file,
956            "AppConfig review defaults should match ReviewConfig defaults"
957        );
958    }
959}