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