Skip to main content

aptu_core/config/
loader.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Configuration loading and path management.
4
5use std::path::PathBuf;
6
7use config::{Config, Environment, File};
8use serde::{Deserialize, Serialize};
9
10use crate::error::AptuError;
11
12use super::{AiConfig, CacheConfig, ReposConfig, ReviewConfig};
13
14/// Trait for loading application configuration from any source.
15///
16/// Decouples configuration loading from the filesystem, enabling
17/// file-based (TOML), in-memory (test/WASM), and future sources
18/// (e.g., iOS plist, remote config) to implement this trait.
19pub trait ConfigSource: Send + Sync {
20    /// Load and return the application configuration.
21    ///
22    /// # Errors
23    ///
24    /// Returns `AptuError::Config` if the source contains invalid data.
25    fn load(&self) -> Result<AppConfig, AptuError>;
26}
27
28/// In-memory configuration source for testing and WASM environments.
29///
30/// Holds a pre-built `AppConfig` and returns a clone on `load()`.
31/// Always available (no cfg gate).
32pub struct InMemoryConfigSource(pub AppConfig);
33
34impl ConfigSource for InMemoryConfigSource {
35    fn load(&self) -> Result<AppConfig, AptuError> {
36        Ok(self.0.clone())
37    }
38}
39
40/// TOML file-based configuration source.
41///
42/// Reads from the standard `config.toml` file in the XDG config directory
43/// and overlays environment variables with the `APTU_` prefix.
44#[cfg(not(target_arch = "wasm32"))]
45pub struct TomlConfigSource;
46
47#[cfg(not(target_arch = "wasm32"))]
48impl TomlConfigSource {
49    /// Create a new TOML config source.
50    #[must_use]
51    pub fn new() -> Self {
52        Self
53    }
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57impl Default for TomlConfigSource {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63#[cfg(not(target_arch = "wasm32"))]
64impl ConfigSource for TomlConfigSource {
65    fn load(&self) -> Result<AppConfig, AptuError> {
66        let config_path = config_file_path();
67
68        let config = Config::builder()
69            // Load from config file (optional - may not exist)
70            .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
71            // Override with environment variables
72            .add_source(
73                Environment::with_prefix("APTU")
74                    .prefix_separator("_")
75                    .separator("__")
76                    .try_parsing(true),
77            )
78            .build()?;
79
80        let app_config: AppConfig = config.try_deserialize()?;
81
82        // Validate cache configuration
83        app_config
84            .cache
85            .validate()
86            .map_err(|e| AptuError::Config { message: e })?;
87
88        // Validate review configuration consistency at load time (non-fatal warnings).
89        for warning in app_config.review.validate_consistency() {
90            tracing::warn!("{}", warning);
91        }
92
93        Ok(app_config)
94    }
95}
96
97/// User preferences.
98#[derive(Debug, Deserialize, Serialize, Default, Clone)]
99#[serde(default)]
100pub struct UserConfig {
101    /// Default repository to use (skip repo selection).
102    pub default_repo: Option<String>,
103}
104
105/// GitHub API settings.
106#[derive(Debug, Deserialize, Serialize, Clone)]
107#[serde(default)]
108pub struct GitHubConfig {
109    /// API request timeout in seconds.
110    pub api_timeout_seconds: u64,
111}
112
113impl Default for GitHubConfig {
114    fn default() -> Self {
115        Self {
116            api_timeout_seconds: 10,
117        }
118    }
119}
120
121/// UI preferences.
122#[derive(Debug, Deserialize, Serialize, Clone)]
123#[serde(default)]
124pub struct UiConfig {
125    /// Enable colored output.
126    pub color: bool,
127    /// Show progress bars.
128    pub progress_bars: bool,
129    /// Always confirm before posting comments.
130    pub confirm_before_post: bool,
131}
132
133impl Default for UiConfig {
134    fn default() -> Self {
135        Self {
136            color: true,
137            progress_bars: true,
138            confirm_before_post: true,
139        }
140    }
141}
142
143/// Per-field byte limits for user-supplied content before prompt assembly.
144/// These limits defend against prompt injection by enforcing a hard cap on
145/// how much user-controlled data can reach the AI model.
146#[derive(Debug, Deserialize, Serialize, Clone)]
147#[serde(default)]
148pub struct PromptConfig {
149    /// Maximum bytes for an issue body (default: 32 KiB).
150    ///
151    /// Limits the size of user-supplied issue body text before it is wrapped
152    /// in XML tags and sent to the AI model. Larger limits allow more context
153    /// but increase token usage and prompt injection surface area. The default
154    /// (32 KiB) balances context richness against cost and security.
155    pub max_issue_body_bytes: usize,
156    /// Maximum bytes for a PR diff (default: 512 KiB).
157    ///
158    /// Limits the total size of all file patches in a PR before they are
159    /// wrapped in XML tags and sent to the AI model. Raised from 128 KiB
160    /// to 512 KiB to accommodate large refactor PRs; injection defence is
161    /// provided by XML tag stripping which is independent of diff size.
162    pub max_diff_bytes: usize,
163    /// Maximum bytes for a commit message (default: 4 KiB).
164    ///
165    /// Limits the size of commit message text before wrapping. The default
166    /// (4 KiB) is conservative, as commit messages are typically short;
167    /// this prevents abuse via artificially large commit messages.
168    pub max_commit_message_bytes: usize,
169}
170
171impl Default for PromptConfig {
172    fn default() -> Self {
173        Self {
174            max_issue_body_bytes: 32_768,
175            max_diff_bytes: 524_288,
176            max_commit_message_bytes: 4_096,
177        }
178    }
179}
180
181/// Application configuration.
182#[derive(Debug, Default, Deserialize, Serialize, Clone)]
183#[serde(default)]
184pub struct AppConfig {
185    /// User preferences.
186    pub user: UserConfig,
187    /// AI provider settings.
188    pub ai: AiConfig,
189    /// GitHub API settings.
190    pub github: GitHubConfig,
191    /// UI preferences.
192    pub ui: UiConfig,
193    /// Cache settings.
194    pub cache: CacheConfig,
195    /// Repository settings.
196    pub repos: ReposConfig,
197    /// PR review prompt settings.
198    #[serde(default)]
199    pub review: ReviewConfig,
200    /// Prompt injection defence settings.
201    #[serde(default)]
202    pub prompt: PromptConfig,
203}
204
205/// Returns the Aptu configuration directory.
206///
207/// Respects the `XDG_CONFIG_HOME` environment variable if set,
208/// otherwise defaults to `~/.config/aptu`.
209#[must_use]
210pub fn config_dir() -> PathBuf {
211    if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
212        && !xdg_config.is_empty()
213    {
214        return PathBuf::from(xdg_config).join("aptu");
215    }
216    dirs::home_dir()
217        .expect("Could not determine home directory - is HOME set?")
218        .join(".config")
219        .join("aptu")
220}
221
222/// Returns the Aptu data directory.
223///
224/// Respects the `XDG_DATA_HOME` environment variable if set,
225/// otherwise defaults to `~/.local/share/aptu`.
226#[must_use]
227pub fn data_dir() -> PathBuf {
228    if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
229        && !xdg_data.is_empty()
230    {
231        return PathBuf::from(xdg_data).join("aptu");
232    }
233    dirs::home_dir()
234        .expect("Could not determine home directory - is HOME set?")
235        .join(".local")
236        .join("share")
237        .join("aptu")
238}
239
240/// Returns the Aptu prompts configuration directory.
241///
242/// Prompt override files are loaded from this directory at runtime.
243/// Place a `<name>.md` file here to override the compiled-in prompt.
244///
245/// Respects the `XDG_CONFIG_HOME` environment variable if set,
246/// otherwise defaults to `~/.config/aptu/prompts`.
247#[must_use]
248pub fn prompts_dir() -> PathBuf {
249    config_dir().join("prompts")
250}
251
252/// Returns the path to the configuration file.
253#[must_use]
254pub fn config_file_path() -> PathBuf {
255    config_dir().join("config.toml")
256}
257
258/// Load application configuration.
259///
260/// Loads from config file (if exists) and environment variables.
261/// Environment variables use the prefix `APTU_` and double underscore
262/// for nested keys (e.g., `APTU_AI__MODEL`).
263///
264/// This is a convenience shim that delegates to [`TomlConfigSource`].
265///
266/// # Errors
267///
268/// Returns `AptuError::Config` if the config file exists but is invalid.
269#[cfg(not(target_arch = "wasm32"))]
270pub fn load_config() -> Result<AppConfig, AptuError> {
271    TomlConfigSource::new().load()
272}
273
274#[cfg(test)]
275mod tests {
276    #![allow(unsafe_code)]
277    use super::*;
278    use serial_test::serial;
279
280    #[test]
281    #[serial]
282    fn test_load_config_defaults() {
283        // Without any config file or env vars, should return defaults.
284        // Point XDG_CONFIG_HOME to a guaranteed-empty temp dir so the real
285        // user config (~/.config/aptu/config.toml) is not loaded.
286        let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
287        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
288        // SAFETY: single-threaded test process; no concurrent env reads.
289        unsafe {
290            std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
291        }
292        let config = load_config().expect("should load with defaults");
293        unsafe {
294            std::env::remove_var("XDG_CONFIG_HOME");
295        }
296
297        assert_eq!(config.ai.provider, "openrouter");
298        assert_eq!(config.ai.model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
299        assert_eq!(config.ai.timeout_seconds, 30);
300        assert_eq!(config.ai.max_tokens, 4096);
301        assert!(config.ai.allow_paid_models);
302        #[allow(clippy::float_cmp)]
303        {
304            assert_eq!(config.ai.temperature, 0.3);
305        }
306        assert_eq!(config.github.api_timeout_seconds, 10);
307        assert!(config.ui.color);
308        assert!(config.ui.confirm_before_post);
309        assert_eq!(config.cache.issue_ttl_minutes, 60);
310    }
311
312    #[test]
313    fn test_config_dir_exists() {
314        let dir = config_dir();
315        assert!(dir.ends_with("aptu"));
316    }
317
318    #[test]
319    fn test_data_dir_exists() {
320        let dir = data_dir();
321        assert!(dir.ends_with("aptu"));
322    }
323
324    #[test]
325    fn test_config_file_path() {
326        let path = config_file_path();
327        assert!(path.ends_with("config.toml"));
328    }
329
330    #[test]
331    fn test_config_with_task_triage_override() {
332        // Test that config with [ai.tasks.triage] parses correctly
333        let config_str = r#"
334[ai]
335provider = "gemini"
336model = "gemini-3.1-flash-lite-preview"
337
338[ai.tasks.triage]
339model = "gemini-3.1-flash-lite-preview"
340"#;
341
342        let config = Config::builder()
343            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
344            .build()
345            .expect("should build config");
346
347        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
348
349        assert_eq!(app_config.ai.provider, "gemini");
350        assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
351        assert!(app_config.ai.tasks.is_some());
352
353        let tasks = app_config.ai.tasks.unwrap();
354        assert!(tasks.triage.is_some());
355        assert!(tasks.review.is_none());
356        assert!(tasks.create.is_none());
357
358        let triage = tasks.triage.unwrap();
359        assert_eq!(triage.provider, None);
360        assert_eq!(
361            triage.model,
362            Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
363        );
364    }
365
366    #[test]
367    fn test_config_with_multiple_task_overrides() {
368        // Test that config with multiple task overrides parses correctly
369        let config_str = r#"
370[ai]
371provider = "openrouter"
372model = "mistralai/mistral-small-2603"
373
374[ai.tasks.triage]
375model = "mistralai/mistral-small-2603"
376
377[ai.tasks.review]
378provider = "openrouter"
379model = "anthropic/claude-haiku-4.5"
380
381[ai.tasks.create]
382model = "anthropic/claude-sonnet-4.6"
383"#;
384
385        let config = Config::builder()
386            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
387            .build()
388            .expect("should build config");
389
390        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
391
392        let tasks = app_config.ai.tasks.expect("tasks should exist");
393
394        // Triage: only model override
395        let triage = tasks.triage.expect("triage should exist");
396        assert_eq!(triage.provider, None);
397        assert_eq!(
398            triage.model,
399            Some(super::super::ai::DEFAULT_OPENROUTER_MODEL.to_string())
400        );
401
402        // Review: both provider and model override
403        let review = tasks.review.expect("review should exist");
404        assert_eq!(review.provider, Some("openrouter".to_string()));
405        assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
406
407        // Create: only model override
408        let create = tasks.create.expect("create should exist");
409        assert_eq!(create.provider, None);
410        assert_eq!(
411            create.model,
412            Some("anthropic/claude-sonnet-4.6".to_string())
413        );
414    }
415
416    #[test]
417    fn test_config_with_partial_task_overrides() {
418        // Test that partial task configs (only provider or only model) parse correctly
419        let config_str = r#"
420[ai]
421provider = "gemini"
422model = "gemini-3.1-flash-lite-preview"
423
424[ai.tasks.triage]
425provider = "gemini"
426
427[ai.tasks.review]
428model = "gemini-3.1-flash-lite-preview"
429"#;
430
431        let config = Config::builder()
432            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
433            .build()
434            .expect("should build config");
435
436        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
437
438        let tasks = app_config.ai.tasks.expect("tasks should exist");
439
440        // Triage: only provider
441        let triage = tasks.triage.expect("triage should exist");
442        assert_eq!(triage.provider, Some("gemini".to_string()));
443        assert_eq!(triage.model, None);
444
445        // Review: only model
446        let review = tasks.review.expect("review should exist");
447        assert_eq!(review.provider, None);
448        assert_eq!(
449            review.model,
450            Some(super::super::ai::DEFAULT_GEMINI_MODEL.to_string())
451        );
452    }
453
454    #[test]
455    fn test_config_without_tasks_section() {
456        // Test that config without explicit tasks section uses defaults
457        let config_str = r#"
458[ai]
459provider = "gemini"
460model = "gemini-3.1-flash-lite-preview"
461"#;
462
463        let config = Config::builder()
464            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
465            .build()
466            .expect("should build config");
467
468        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
469
470        assert_eq!(app_config.ai.provider, "gemini");
471        assert_eq!(app_config.ai.model, super::super::ai::DEFAULT_GEMINI_MODEL);
472        // When no tasks section is provided, defaults are used (tasks: None)
473        assert!(app_config.ai.tasks.is_none());
474    }
475
476    #[test]
477    fn test_resolve_for_task_with_defaults() {
478        // Test that resolve_for_task returns correct defaults (all tasks use openrouter)
479        let ai_config = AiConfig::default();
480
481        // All tasks use global defaults (openrouter/mistralai/mistral-small-2603)
482        let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Triage);
483        assert_eq!(provider, "openrouter");
484        assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
485        assert!(ai_config.allow_paid_models);
486
487        let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Review);
488        assert_eq!(provider, "openrouter");
489        assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
490        assert!(ai_config.allow_paid_models);
491
492        let (provider, model) = ai_config.resolve_for_task(super::super::ai::TaskType::Create);
493        assert_eq!(provider, "openrouter");
494        assert_eq!(model, "mistralai/mistral-small-2603");
495        assert!(ai_config.allow_paid_models);
496    }
497
498    #[test]
499    fn test_resolve_for_task_with_triage_override() {
500        // Test that resolve_for_task returns triage override when present
501        let config_str = r#"
502[ai]
503provider = "gemini"
504model = "gemini-3.1-flash-lite-preview"
505
506[ai.tasks.triage]
507model = "gemini-3.1-flash-lite-preview"
508"#;
509
510        let config = Config::builder()
511            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
512            .build()
513            .expect("should build config");
514
515        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
516
517        // Triage should use override
518        let (provider, model) = app_config
519            .ai
520            .resolve_for_task(super::super::ai::TaskType::Triage);
521        assert_eq!(provider, "gemini");
522        assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
523
524        // Review and Create should use defaults
525        let (provider, model) = app_config
526            .ai
527            .resolve_for_task(super::super::ai::TaskType::Review);
528        assert_eq!(provider, "gemini");
529        assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
530
531        let (provider, model) = app_config
532            .ai
533            .resolve_for_task(super::super::ai::TaskType::Create);
534        assert_eq!(provider, "gemini");
535        assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
536    }
537
538    #[test]
539    fn test_config_with_provider_override() {
540        // Test that resolve_for_task returns provider override when present
541        let config_str = r#"
542[ai]
543provider = "gemini"
544model = "gemini-3.1-flash-lite-preview"
545
546[ai.tasks.review]
547provider = "openrouter"
548"#;
549
550        let config = Config::builder()
551            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
552            .build()
553            .expect("should build config");
554
555        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
556
557        // Review should use provider override but default model
558        let (provider, model) = app_config
559            .ai
560            .resolve_for_task(super::super::ai::TaskType::Review);
561        assert_eq!(provider, "openrouter");
562        assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
563
564        // Triage and Create should use defaults
565        let (provider, model) = app_config
566            .ai
567            .resolve_for_task(super::super::ai::TaskType::Triage);
568        assert_eq!(provider, "gemini");
569        assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
570
571        let (provider, model) = app_config
572            .ai
573            .resolve_for_task(super::super::ai::TaskType::Create);
574        assert_eq!(provider, "gemini");
575        assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
576    }
577
578    #[test]
579    fn test_config_with_full_overrides() {
580        // Test that resolve_for_task returns both provider and model overrides
581        let config_str = r#"
582[ai]
583provider = "gemini"
584model = "gemini-3.1-flash-lite-preview"
585
586[ai.tasks.triage]
587provider = "openrouter"
588model = "mistralai/mistral-small-2603"
589
590[ai.tasks.review]
591provider = "openrouter"
592model = "anthropic/claude-haiku-4.5"
593
594[ai.tasks.create]
595provider = "gemini"
596model = "gemini-3.1-flash-lite-preview"
597"#;
598
599        let config = Config::builder()
600            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
601            .build()
602            .expect("should build config");
603
604        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
605
606        // Triage
607        let (provider, model) = app_config
608            .ai
609            .resolve_for_task(super::super::ai::TaskType::Triage);
610        assert_eq!(provider, "openrouter");
611        assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
612
613        // Review
614        let (provider, model) = app_config
615            .ai
616            .resolve_for_task(super::super::ai::TaskType::Review);
617        assert_eq!(provider, "openrouter");
618        assert_eq!(model, "anthropic/claude-haiku-4.5");
619
620        // Create
621        let (provider, model) = app_config
622            .ai
623            .resolve_for_task(super::super::ai::TaskType::Create);
624        assert_eq!(provider, "gemini");
625        assert_eq!(model, super::super::ai::DEFAULT_GEMINI_MODEL);
626    }
627
628    #[test]
629    fn test_resolve_for_task_partial_overrides() {
630        // Test that resolve_for_task handles partial overrides correctly
631        let config_str = r#"
632[ai]
633provider = "openrouter"
634model = "mistralai/mistral-small-2603"
635
636[ai.tasks.triage]
637model = "mistralai/mistral-small-2603"
638
639[ai.tasks.review]
640provider = "openrouter"
641
642[ai.tasks.create]
643"#;
644
645        let config = Config::builder()
646            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
647            .build()
648            .expect("should build config");
649
650        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
651
652        // Triage: model override, provider from default
653        let (provider, model) = app_config
654            .ai
655            .resolve_for_task(super::super::ai::TaskType::Triage);
656        assert_eq!(provider, "openrouter");
657        assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
658
659        // Review: provider override, model from default
660        let (provider, model) = app_config
661            .ai
662            .resolve_for_task(super::super::ai::TaskType::Review);
663        assert_eq!(provider, "openrouter");
664        assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
665
666        // Create: empty override, both from default
667        let (provider, model) = app_config
668            .ai
669            .resolve_for_task(super::super::ai::TaskType::Create);
670        assert_eq!(provider, "openrouter");
671        assert_eq!(model, super::super::ai::DEFAULT_OPENROUTER_MODEL);
672    }
673
674    #[test]
675    fn test_fallback_config_toml_parsing() {
676        // Test that FallbackConfig deserializes from TOML correctly
677        let config_str = r#"
678[ai]
679provider = "gemini"
680model = "gemini-3.1-flash-lite-preview"
681
682[ai.fallback]
683chain = ["openrouter", "anthropic"]
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        assert_eq!(app_config.ai.provider, "gemini");
694        assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
695        assert!(app_config.ai.fallback.is_some());
696
697        let fallback = app_config.ai.fallback.unwrap();
698        assert_eq!(fallback.chain.len(), 2);
699        assert_eq!(fallback.chain[0].provider, "openrouter");
700        assert_eq!(fallback.chain[1].provider, "anthropic");
701    }
702
703    #[test]
704    fn test_fallback_config_empty_chain() {
705        // Test that FallbackConfig with empty chain parses correctly
706        let config_str = r#"
707[ai]
708provider = "gemini"
709model = "gemini-3.1-flash-lite-preview"
710
711[ai.fallback]
712chain = []
713"#;
714
715        let config = Config::builder()
716            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
717            .build()
718            .expect("should build config");
719
720        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
721
722        assert!(app_config.ai.fallback.is_some());
723        let fallback = app_config.ai.fallback.unwrap();
724        assert_eq!(fallback.chain.len(), 0);
725    }
726
727    #[test]
728    fn test_fallback_config_single_provider() {
729        // Test that FallbackConfig with single provider parses correctly
730        let config_str = r#"
731[ai]
732provider = "gemini"
733model = "gemini-3.1-flash-lite-preview"
734
735[ai.fallback]
736chain = ["openrouter"]
737"#;
738
739        let config = Config::builder()
740            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
741            .build()
742            .expect("should build config");
743
744        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
745
746        assert!(app_config.ai.fallback.is_some());
747        let fallback = app_config.ai.fallback.unwrap();
748        assert_eq!(fallback.chain.len(), 1);
749        assert_eq!(fallback.chain[0].provider, "openrouter");
750    }
751
752    #[test]
753    fn test_fallback_config_without_fallback_section() {
754        // Test that config without fallback section has None
755        let config_str = r#"
756[ai]
757provider = "gemini"
758model = "gemini-3.1-flash-lite-preview"
759"#;
760
761        let config = Config::builder()
762            .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
763            .build()
764            .expect("should build config");
765
766        let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
767
768        assert!(app_config.ai.fallback.is_none());
769    }
770
771    #[test]
772    fn test_fallback_config_default() {
773        // Test that AiConfig::default() has fallback: None
774        let ai_config = AiConfig::default();
775        assert!(ai_config.fallback.is_none());
776    }
777
778    #[test]
779    #[serial]
780    fn test_load_config_env_var_override() {
781        // Test that APTU_AI__MODEL and APTU_AI__PROVIDER env vars override defaults.
782        let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
783        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
784        // SAFETY: single-threaded test process; no concurrent env reads.
785        unsafe {
786            std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
787            std::env::set_var("APTU_AI__MODEL", "test-model-override");
788            std::env::set_var("APTU_AI__PROVIDER", "openrouter");
789        }
790        let config = load_config().expect("should load with env overrides");
791        unsafe {
792            std::env::remove_var("XDG_CONFIG_HOME");
793            std::env::remove_var("APTU_AI__MODEL");
794            std::env::remove_var("APTU_AI__PROVIDER");
795        }
796
797        assert_eq!(config.ai.model, "test-model-override");
798        assert_eq!(config.ai.provider, "openrouter");
799    }
800
801    #[test]
802    fn test_review_config_defaults() {
803        // Arrange / Act: construct ReviewConfig with defaults
804        let review_config = ReviewConfig::default();
805
806        // Assert: defaults match specification
807        assert_eq!(
808            review_config.max_prompt_chars, 120_000,
809            "max_prompt_chars should default to 120_000"
810        );
811        assert_eq!(
812            review_config.max_full_content_files, 10,
813            "max_full_content_files should default to 10"
814        );
815        assert_eq!(
816            review_config.max_chars_per_file, 16_000,
817            "max_chars_per_file should default to 16_000"
818        );
819
820        // Assert: AppConfig::default().review equals ReviewConfig::default()
821        let app_config = AppConfig::default();
822        assert_eq!(
823            app_config.review.max_prompt_chars, review_config.max_prompt_chars,
824            "AppConfig review defaults should match ReviewConfig defaults"
825        );
826        assert_eq!(
827            app_config.review.max_full_content_files, review_config.max_full_content_files,
828            "AppConfig review defaults should match ReviewConfig defaults"
829        );
830        assert_eq!(
831            app_config.review.max_chars_per_file, review_config.max_chars_per_file,
832            "AppConfig review defaults should match ReviewConfig defaults"
833        );
834    }
835
836    #[test]
837    fn test_in_memory_config_source_loads_defaults() {
838        let default_config = AppConfig::default();
839        let source = InMemoryConfigSource(default_config.clone());
840        let loaded = source.load().expect("load should succeed");
841        assert_eq!(loaded.ai.provider, default_config.ai.provider);
842        assert_eq!(loaded.ai.model, default_config.ai.model);
843        assert_eq!(loaded.ai.timeout_seconds, default_config.ai.timeout_seconds);
844        assert_eq!(loaded.ai.max_tokens, default_config.ai.max_tokens);
845        assert_eq!(
846            loaded.github.api_timeout_seconds,
847            default_config.github.api_timeout_seconds
848        );
849    }
850}