1use std::path::PathBuf;
22
23use config::{Config, Environment, File};
24use serde::{Deserialize, Serialize};
25
26use crate::error::AptuError;
27
28pub const DEFAULT_OPENROUTER_MODEL: &str = "mistralai/mistral-small-2603";
30pub const DEFAULT_GEMINI_MODEL: &str = "gemini-3.1-flash-lite-preview";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TaskType {
36 Triage,
38 Review,
40 Create,
42}
43
44#[derive(Debug, Default, Deserialize, Serialize, Clone)]
46#[serde(default)]
47pub struct AppConfig {
48 pub user: UserConfig,
50 pub ai: AiConfig,
52 pub github: GitHubConfig,
54 pub ui: UiConfig,
56 pub cache: CacheConfig,
58 pub repos: ReposConfig,
60 #[serde(default)]
62 pub review: ReviewConfig,
63}
64
65#[derive(Debug, Deserialize, Serialize, Default, Clone)]
67#[serde(default)]
68pub struct UserConfig {
69 pub default_repo: Option<String>,
71}
72
73#[derive(Debug, Deserialize, Serialize, Default, Clone)]
75#[serde(default)]
76pub struct TaskOverride {
77 pub provider: Option<String>,
79 pub model: Option<String>,
81}
82
83#[derive(Debug, Deserialize, Serialize, Default, Clone)]
85#[serde(default)]
86pub struct TasksConfig {
87 pub triage: Option<TaskOverride>,
89 pub review: Option<TaskOverride>,
91 pub create: Option<TaskOverride>,
93}
94
95#[derive(Debug, Clone, Serialize)]
97pub struct FallbackEntry {
98 pub provider: String,
100 pub model: Option<String>,
102}
103
104impl<'de> Deserialize<'de> for FallbackEntry {
105 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
106 where
107 D: serde::Deserializer<'de>,
108 {
109 #[derive(Deserialize)]
110 #[serde(untagged)]
111 enum EntryVariant {
112 String(String),
113 Struct {
114 provider: String,
115 model: Option<String>,
116 },
117 }
118
119 match EntryVariant::deserialize(deserializer)? {
120 EntryVariant::String(provider) => Ok(FallbackEntry {
121 provider,
122 model: None,
123 }),
124 EntryVariant::Struct { provider, model } => Ok(FallbackEntry { provider, model }),
125 }
126 }
127}
128
129#[derive(Debug, Deserialize, Serialize, Clone, Default)]
131#[serde(default)]
132pub struct FallbackConfig {
133 pub chain: Vec<FallbackEntry>,
135}
136
137fn default_retry_max_attempts() -> u32 {
139 3
140}
141
142#[derive(Debug, Deserialize, Serialize, Clone)]
144#[serde(default)]
145pub struct AiConfig {
146 pub provider: String,
148 pub model: String,
150 pub timeout_seconds: u64,
152 pub allow_paid_models: bool,
154 pub max_tokens: u32,
156 pub temperature: f32,
158 pub circuit_breaker_threshold: u32,
160 pub circuit_breaker_reset_seconds: u64,
162 #[serde(default = "default_retry_max_attempts")]
164 pub retry_max_attempts: u32,
165 pub tasks: Option<TasksConfig>,
167 pub fallback: Option<FallbackConfig>,
169 pub custom_guidance: Option<String>,
175 pub validation_enabled: bool,
181}
182
183impl Default for AiConfig {
184 fn default() -> Self {
185 Self {
186 provider: "openrouter".to_string(),
187 model: DEFAULT_OPENROUTER_MODEL.to_string(),
188 timeout_seconds: 30,
189 allow_paid_models: true,
190 max_tokens: 4096,
191 temperature: 0.3,
192 circuit_breaker_threshold: 3,
193 circuit_breaker_reset_seconds: 60,
194 retry_max_attempts: default_retry_max_attempts(),
195 tasks: None,
196 fallback: None,
197 custom_guidance: None,
198 validation_enabled: true,
199 }
200 }
201}
202
203impl AiConfig {
204 #[must_use]
217 pub fn resolve_for_task(&self, task: TaskType) -> (String, String) {
218 let task_override = match task {
219 TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
220 TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
221 TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
222 };
223
224 let provider = task_override
225 .and_then(|o| o.provider.clone())
226 .unwrap_or_else(|| self.provider.clone());
227
228 let model = task_override
229 .and_then(|o| o.model.clone())
230 .unwrap_or_else(|| self.model.clone());
231
232 (provider, model)
233 }
234}
235
236#[derive(Debug, Deserialize, Serialize, Clone)]
238#[serde(default)]
239pub struct GitHubConfig {
240 pub api_timeout_seconds: u64,
242}
243
244impl Default for GitHubConfig {
245 fn default() -> Self {
246 Self {
247 api_timeout_seconds: 10,
248 }
249 }
250}
251
252#[derive(Debug, Deserialize, Serialize, Clone)]
254#[serde(default)]
255pub struct UiConfig {
256 pub color: bool,
258 pub progress_bars: bool,
260 pub confirm_before_post: bool,
262}
263
264impl Default for UiConfig {
265 fn default() -> Self {
266 Self {
267 color: true,
268 progress_bars: true,
269 confirm_before_post: true,
270 }
271 }
272}
273
274#[derive(Debug, Deserialize, Serialize, Clone)]
276#[serde(default)]
277pub struct CacheConfig {
278 pub issue_ttl_minutes: i64,
280 pub repo_ttl_hours: i64,
282 pub curated_repos_url: String,
284}
285
286impl Default for CacheConfig {
287 fn default() -> Self {
288 Self {
289 issue_ttl_minutes: crate::cache::DEFAULT_ISSUE_TTL_MINS,
290 repo_ttl_hours: crate::cache::DEFAULT_REPO_TTL_HOURS,
291 curated_repos_url:
292 "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
293 .to_string(),
294 }
295 }
296}
297
298#[derive(Debug, Deserialize, Serialize, Clone)]
300#[serde(default)]
301pub struct ReposConfig {
302 pub curated: bool,
304 #[serde(default)]
306 pub dco_signoff: bool,
307}
308
309impl Default for ReposConfig {
310 fn default() -> Self {
311 Self {
312 curated: true,
313 dco_signoff: false,
314 }
315 }
316}
317
318#[derive(Debug, Deserialize, Serialize, Clone)]
329#[serde(default)]
330pub struct ReviewConfig {
331 pub max_prompt_chars: usize,
333 pub max_full_content_files: usize,
335 pub max_chars_per_file: usize,
337}
338
339impl Default for ReviewConfig {
340 fn default() -> Self {
341 Self {
342 max_prompt_chars: 120_000, max_full_content_files: 10, max_chars_per_file: 4_000, }
346 }
347}
348
349#[must_use]
354pub fn config_dir() -> PathBuf {
355 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
356 && !xdg_config.is_empty()
357 {
358 return PathBuf::from(xdg_config).join("aptu");
359 }
360 dirs::home_dir()
361 .expect("Could not determine home directory - is HOME set?")
362 .join(".config")
363 .join("aptu")
364}
365
366#[must_use]
371pub fn data_dir() -> PathBuf {
372 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
373 && !xdg_data.is_empty()
374 {
375 return PathBuf::from(xdg_data).join("aptu");
376 }
377 dirs::home_dir()
378 .expect("Could not determine home directory - is HOME set?")
379 .join(".local")
380 .join("share")
381 .join("aptu")
382}
383
384#[must_use]
392pub fn prompts_dir() -> PathBuf {
393 config_dir().join("prompts")
394}
395
396#[must_use]
398pub fn config_file_path() -> PathBuf {
399 config_dir().join("config.toml")
400}
401
402pub fn load_config() -> Result<AppConfig, AptuError> {
412 let config_path = config_file_path();
413
414 let config = Config::builder()
415 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
417 .add_source(
419 Environment::with_prefix("APTU")
420 .prefix_separator("_")
421 .separator("__")
422 .try_parsing(true),
423 )
424 .build()?;
425
426 let app_config: AppConfig = config.try_deserialize()?;
427
428 Ok(app_config)
429}
430
431#[cfg(test)]
432mod tests {
433 #![allow(unsafe_code)]
434 use super::*;
435 use serial_test::serial;
436
437 #[test]
438 #[serial]
439 fn test_load_config_defaults() {
440 let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
444 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
445 unsafe {
447 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
448 }
449 let config = load_config().expect("should load with defaults");
450 unsafe {
451 std::env::remove_var("XDG_CONFIG_HOME");
452 }
453
454 assert_eq!(config.ai.provider, "openrouter");
455 assert_eq!(config.ai.model, DEFAULT_OPENROUTER_MODEL);
456 assert_eq!(config.ai.timeout_seconds, 30);
457 assert_eq!(config.ai.max_tokens, 4096);
458 assert_eq!(config.ai.allow_paid_models, true);
459 #[allow(clippy::float_cmp)]
460 {
461 assert_eq!(config.ai.temperature, 0.3);
462 }
463 assert_eq!(config.github.api_timeout_seconds, 10);
464 assert!(config.ui.color);
465 assert!(config.ui.confirm_before_post);
466 assert_eq!(config.cache.issue_ttl_minutes, 60);
467 }
468
469 #[test]
470 fn test_config_dir_exists() {
471 let dir = config_dir();
472 assert!(dir.ends_with("aptu"));
473 }
474
475 #[test]
476 fn test_data_dir_exists() {
477 let dir = data_dir();
478 assert!(dir.ends_with("aptu"));
479 }
480
481 #[test]
482 fn test_config_file_path() {
483 let path = config_file_path();
484 assert!(path.ends_with("config.toml"));
485 }
486
487 #[test]
488 fn test_config_with_task_triage_override() {
489 let config_str = r#"
491[ai]
492provider = "gemini"
493model = "gemini-3.1-flash-lite-preview"
494
495[ai.tasks.triage]
496model = "gemini-3.1-flash-lite-preview"
497"#;
498
499 let config = Config::builder()
500 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
501 .build()
502 .expect("should build config");
503
504 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
505
506 assert_eq!(app_config.ai.provider, "gemini");
507 assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
508 assert!(app_config.ai.tasks.is_some());
509
510 let tasks = app_config.ai.tasks.unwrap();
511 assert!(tasks.triage.is_some());
512 assert!(tasks.review.is_none());
513 assert!(tasks.create.is_none());
514
515 let triage = tasks.triage.unwrap();
516 assert_eq!(triage.provider, None);
517 assert_eq!(triage.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
518 }
519
520 #[test]
521 fn test_config_with_multiple_task_overrides() {
522 let config_str = r#"
524[ai]
525provider = "openrouter"
526model = "mistralai/mistral-small-2603"
527
528[ai.tasks.triage]
529model = "mistralai/mistral-small-2603"
530
531[ai.tasks.review]
532provider = "openrouter"
533model = "anthropic/claude-haiku-4.5"
534
535[ai.tasks.create]
536model = "anthropic/claude-sonnet-4.6"
537"#;
538
539 let config = Config::builder()
540 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
541 .build()
542 .expect("should build config");
543
544 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
545
546 let tasks = app_config.ai.tasks.expect("tasks should exist");
547
548 let triage = tasks.triage.expect("triage should exist");
550 assert_eq!(triage.provider, None);
551 assert_eq!(triage.model, Some(DEFAULT_OPENROUTER_MODEL.to_string()));
552
553 let review = tasks.review.expect("review should exist");
555 assert_eq!(review.provider, Some("openrouter".to_string()));
556 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
557
558 let create = tasks.create.expect("create should exist");
560 assert_eq!(create.provider, None);
561 assert_eq!(
562 create.model,
563 Some("anthropic/claude-sonnet-4.6".to_string())
564 );
565 }
566
567 #[test]
568 fn test_config_with_partial_task_overrides() {
569 let config_str = r#"
571[ai]
572provider = "gemini"
573model = "gemini-3.1-flash-lite-preview"
574
575[ai.tasks.triage]
576provider = "gemini"
577
578[ai.tasks.review]
579model = "gemini-3.1-flash-lite-preview"
580"#;
581
582 let config = Config::builder()
583 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
584 .build()
585 .expect("should build config");
586
587 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
588
589 let tasks = app_config.ai.tasks.expect("tasks should exist");
590
591 let triage = tasks.triage.expect("triage should exist");
593 assert_eq!(triage.provider, Some("gemini".to_string()));
594 assert_eq!(triage.model, None);
595
596 let review = tasks.review.expect("review should exist");
598 assert_eq!(review.provider, None);
599 assert_eq!(review.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
600 }
601
602 #[test]
603 fn test_config_without_tasks_section() {
604 let config_str = r#"
606[ai]
607provider = "gemini"
608model = "gemini-3.1-flash-lite-preview"
609"#;
610
611 let config = Config::builder()
612 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
613 .build()
614 .expect("should build config");
615
616 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
617
618 assert_eq!(app_config.ai.provider, "gemini");
619 assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
620 assert!(app_config.ai.tasks.is_none());
622 }
623
624 #[test]
625 fn test_resolve_for_task_with_defaults() {
626 let ai_config = AiConfig::default();
628
629 let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
631 assert_eq!(provider, "openrouter");
632 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
633 assert_eq!(ai_config.allow_paid_models, true);
634
635 let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
636 assert_eq!(provider, "openrouter");
637 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
638 assert_eq!(ai_config.allow_paid_models, true);
639
640 let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
641 assert_eq!(provider, "openrouter");
642 assert_eq!(model, "mistralai/mistral-small-2603");
643 assert_eq!(ai_config.allow_paid_models, true);
644 }
645
646 #[test]
647 fn test_resolve_for_task_with_triage_override() {
648 let config_str = r#"
650[ai]
651provider = "gemini"
652model = "gemini-3.1-flash-lite-preview"
653
654[ai.tasks.triage]
655model = "gemini-3.1-flash-lite-preview"
656"#;
657
658 let config = Config::builder()
659 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
660 .build()
661 .expect("should build config");
662
663 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
664
665 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
667 assert_eq!(provider, "gemini");
668 assert_eq!(model, DEFAULT_GEMINI_MODEL);
669
670 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
672 assert_eq!(provider, "gemini");
673 assert_eq!(model, DEFAULT_GEMINI_MODEL);
674
675 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
676 assert_eq!(provider, "gemini");
677 assert_eq!(model, DEFAULT_GEMINI_MODEL);
678 }
679
680 #[test]
681 fn test_resolve_for_task_with_provider_override() {
682 let config_str = r#"
684[ai]
685provider = "gemini"
686model = "gemini-3.1-flash-lite-preview"
687
688[ai.tasks.review]
689provider = "openrouter"
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 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
701 assert_eq!(provider, "openrouter");
702 assert_eq!(model, DEFAULT_GEMINI_MODEL);
703
704 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
706 assert_eq!(provider, "gemini");
707 assert_eq!(model, DEFAULT_GEMINI_MODEL);
708
709 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
710 assert_eq!(provider, "gemini");
711 assert_eq!(model, DEFAULT_GEMINI_MODEL);
712 }
713
714 #[test]
715 fn test_resolve_for_task_with_full_overrides() {
716 let config_str = r#"
718[ai]
719provider = "gemini"
720model = "gemini-3.1-flash-lite-preview"
721
722[ai.tasks.triage]
723provider = "openrouter"
724model = "mistralai/mistral-small-2603"
725
726[ai.tasks.review]
727provider = "openrouter"
728model = "anthropic/claude-haiku-4.5"
729
730[ai.tasks.create]
731provider = "gemini"
732model = "gemini-3.1-flash-lite-preview"
733"#;
734
735 let config = Config::builder()
736 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
737 .build()
738 .expect("should build config");
739
740 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
741
742 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
744 assert_eq!(provider, "openrouter");
745 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
746
747 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
749 assert_eq!(provider, "openrouter");
750 assert_eq!(model, "anthropic/claude-haiku-4.5");
751
752 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
754 assert_eq!(provider, "gemini");
755 assert_eq!(model, DEFAULT_GEMINI_MODEL);
756 }
757
758 #[test]
759 fn test_resolve_for_task_partial_overrides() {
760 let config_str = r#"
762[ai]
763provider = "openrouter"
764model = "mistralai/mistral-small-2603"
765
766[ai.tasks.triage]
767model = "mistralai/mistral-small-2603"
768
769[ai.tasks.review]
770provider = "openrouter"
771
772[ai.tasks.create]
773"#;
774
775 let config = Config::builder()
776 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
777 .build()
778 .expect("should build config");
779
780 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
781
782 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
784 assert_eq!(provider, "openrouter");
785 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
786
787 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
789 assert_eq!(provider, "openrouter");
790 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
791
792 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
794 assert_eq!(provider, "openrouter");
795 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
796 }
797
798 #[test]
799 fn test_fallback_config_toml_parsing() {
800 let config_str = r#"
802[ai]
803provider = "gemini"
804model = "gemini-3.1-flash-lite-preview"
805
806[ai.fallback]
807chain = ["openrouter", "anthropic"]
808"#;
809
810 let config = Config::builder()
811 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
812 .build()
813 .expect("should build config");
814
815 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
816
817 assert_eq!(app_config.ai.provider, "gemini");
818 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
819 assert!(app_config.ai.fallback.is_some());
820
821 let fallback = app_config.ai.fallback.unwrap();
822 assert_eq!(fallback.chain.len(), 2);
823 assert_eq!(fallback.chain[0].provider, "openrouter");
824 assert_eq!(fallback.chain[1].provider, "anthropic");
825 }
826
827 #[test]
828 fn test_fallback_config_empty_chain() {
829 let config_str = r#"
831[ai]
832provider = "gemini"
833model = "gemini-3.1-flash-lite-preview"
834
835[ai.fallback]
836chain = []
837"#;
838
839 let config = Config::builder()
840 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
841 .build()
842 .expect("should build config");
843
844 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
845
846 assert!(app_config.ai.fallback.is_some());
847 let fallback = app_config.ai.fallback.unwrap();
848 assert_eq!(fallback.chain.len(), 0);
849 }
850
851 #[test]
852 fn test_fallback_config_single_provider() {
853 let config_str = r#"
855[ai]
856provider = "gemini"
857model = "gemini-3.1-flash-lite-preview"
858
859[ai.fallback]
860chain = ["openrouter"]
861"#;
862
863 let config = Config::builder()
864 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
865 .build()
866 .expect("should build config");
867
868 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
869
870 assert!(app_config.ai.fallback.is_some());
871 let fallback = app_config.ai.fallback.unwrap();
872 assert_eq!(fallback.chain.len(), 1);
873 assert_eq!(fallback.chain[0].provider, "openrouter");
874 }
875
876 #[test]
877 fn test_fallback_config_without_fallback_section() {
878 let config_str = r#"
880[ai]
881provider = "gemini"
882model = "gemini-3.1-flash-lite-preview"
883"#;
884
885 let config = Config::builder()
886 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
887 .build()
888 .expect("should build config");
889
890 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
891
892 assert!(app_config.ai.fallback.is_none());
893 }
894
895 #[test]
896 fn test_fallback_config_default() {
897 let ai_config = AiConfig::default();
899 assert!(ai_config.fallback.is_none());
900 }
901
902 #[test]
903 #[serial]
904 fn test_load_config_env_var_override() {
905 let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
907 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
908 unsafe {
910 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
911 std::env::set_var("APTU_AI__MODEL", "test-model-override");
912 std::env::set_var("APTU_AI__PROVIDER", "openrouter");
913 }
914 let config = load_config().expect("should load with env overrides");
915 unsafe {
916 std::env::remove_var("XDG_CONFIG_HOME");
917 std::env::remove_var("APTU_AI__MODEL");
918 std::env::remove_var("APTU_AI__PROVIDER");
919 }
920
921 assert_eq!(config.ai.model, "test-model-override");
922 assert_eq!(config.ai.provider, "openrouter");
923 }
924
925 #[test]
926 fn test_review_config_defaults() {
927 let review_config = ReviewConfig::default();
929
930 assert_eq!(
932 review_config.max_prompt_chars, 120_000,
933 "max_prompt_chars should default to 120_000"
934 );
935 assert_eq!(
936 review_config.max_full_content_files, 10,
937 "max_full_content_files should default to 10"
938 );
939 assert_eq!(
940 review_config.max_chars_per_file, 4_000,
941 "max_chars_per_file should default to 4_000"
942 );
943
944 let app_config = AppConfig::default();
946 assert_eq!(
947 app_config.review.max_prompt_chars, review_config.max_prompt_chars,
948 "AppConfig review defaults should match ReviewConfig defaults"
949 );
950 assert_eq!(
951 app_config.review.max_full_content_files, review_config.max_full_content_files,
952 "AppConfig review defaults should match ReviewConfig defaults"
953 );
954 assert_eq!(
955 app_config.review.max_chars_per_file, review_config.max_chars_per_file,
956 "AppConfig review defaults should match ReviewConfig defaults"
957 );
958 }
959}