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