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