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