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}
305
306impl Default for ReposConfig {
307 fn default() -> Self {
308 Self { curated: true }
309 }
310}
311
312#[derive(Debug, Deserialize, Serialize, Clone)]
323#[serde(default)]
324pub struct ReviewConfig {
325 pub max_prompt_chars: usize,
327 pub max_full_content_files: usize,
329 pub max_chars_per_file: usize,
331}
332
333impl Default for ReviewConfig {
334 fn default() -> Self {
335 Self {
336 max_prompt_chars: 120_000, max_full_content_files: 10, max_chars_per_file: 4_000, }
340 }
341}
342
343#[must_use]
348pub fn config_dir() -> PathBuf {
349 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
350 && !xdg_config.is_empty()
351 {
352 return PathBuf::from(xdg_config).join("aptu");
353 }
354 dirs::home_dir()
355 .expect("Could not determine home directory - is HOME set?")
356 .join(".config")
357 .join("aptu")
358}
359
360#[must_use]
365pub fn data_dir() -> PathBuf {
366 if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME")
367 && !xdg_data.is_empty()
368 {
369 return PathBuf::from(xdg_data).join("aptu");
370 }
371 dirs::home_dir()
372 .expect("Could not determine home directory - is HOME set?")
373 .join(".local")
374 .join("share")
375 .join("aptu")
376}
377
378#[must_use]
386pub fn prompts_dir() -> PathBuf {
387 config_dir().join("prompts")
388}
389
390#[must_use]
392pub fn config_file_path() -> PathBuf {
393 config_dir().join("config.toml")
394}
395
396pub fn load_config() -> Result<AppConfig, AptuError> {
406 let config_path = config_file_path();
407
408 let config = Config::builder()
409 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
411 .add_source(
413 Environment::with_prefix("APTU")
414 .prefix_separator("_")
415 .separator("__")
416 .try_parsing(true),
417 )
418 .build()?;
419
420 let app_config: AppConfig = config.try_deserialize()?;
421
422 Ok(app_config)
423}
424
425#[cfg(test)]
426mod tests {
427 #![allow(unsafe_code)]
428 use super::*;
429 use serial_test::serial;
430
431 #[test]
432 #[serial]
433 fn test_load_config_defaults() {
434 let tmp_dir = std::env::temp_dir().join("aptu_test_defaults_no_config");
438 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
439 unsafe {
441 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
442 }
443 let config = load_config().expect("should load with defaults");
444 unsafe {
445 std::env::remove_var("XDG_CONFIG_HOME");
446 }
447
448 assert_eq!(config.ai.provider, "openrouter");
449 assert_eq!(config.ai.model, DEFAULT_OPENROUTER_MODEL);
450 assert_eq!(config.ai.timeout_seconds, 30);
451 assert_eq!(config.ai.max_tokens, 4096);
452 assert_eq!(config.ai.allow_paid_models, true);
453 #[allow(clippy::float_cmp)]
454 {
455 assert_eq!(config.ai.temperature, 0.3);
456 }
457 assert_eq!(config.github.api_timeout_seconds, 10);
458 assert!(config.ui.color);
459 assert!(config.ui.confirm_before_post);
460 assert_eq!(config.cache.issue_ttl_minutes, 60);
461 }
462
463 #[test]
464 fn test_config_dir_exists() {
465 let dir = config_dir();
466 assert!(dir.ends_with("aptu"));
467 }
468
469 #[test]
470 fn test_data_dir_exists() {
471 let dir = data_dir();
472 assert!(dir.ends_with("aptu"));
473 }
474
475 #[test]
476 fn test_config_file_path() {
477 let path = config_file_path();
478 assert!(path.ends_with("config.toml"));
479 }
480
481 #[test]
482 fn test_config_with_task_triage_override() {
483 let config_str = r#"
485[ai]
486provider = "gemini"
487model = "gemini-3.1-flash-lite-preview"
488
489[ai.tasks.triage]
490model = "gemini-3.1-flash-lite-preview"
491"#;
492
493 let config = Config::builder()
494 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
495 .build()
496 .expect("should build config");
497
498 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
499
500 assert_eq!(app_config.ai.provider, "gemini");
501 assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
502 assert!(app_config.ai.tasks.is_some());
503
504 let tasks = app_config.ai.tasks.unwrap();
505 assert!(tasks.triage.is_some());
506 assert!(tasks.review.is_none());
507 assert!(tasks.create.is_none());
508
509 let triage = tasks.triage.unwrap();
510 assert_eq!(triage.provider, None);
511 assert_eq!(triage.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
512 }
513
514 #[test]
515 fn test_config_with_multiple_task_overrides() {
516 let config_str = r#"
518[ai]
519provider = "openrouter"
520model = "mistralai/mistral-small-2603"
521
522[ai.tasks.triage]
523model = "mistralai/mistral-small-2603"
524
525[ai.tasks.review]
526provider = "openrouter"
527model = "anthropic/claude-haiku-4.5"
528
529[ai.tasks.create]
530model = "anthropic/claude-sonnet-4.6"
531"#;
532
533 let config = Config::builder()
534 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
535 .build()
536 .expect("should build config");
537
538 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
539
540 let tasks = app_config.ai.tasks.expect("tasks should exist");
541
542 let triage = tasks.triage.expect("triage should exist");
544 assert_eq!(triage.provider, None);
545 assert_eq!(triage.model, Some(DEFAULT_OPENROUTER_MODEL.to_string()));
546
547 let review = tasks.review.expect("review should exist");
549 assert_eq!(review.provider, Some("openrouter".to_string()));
550 assert_eq!(review.model, Some("anthropic/claude-haiku-4.5".to_string()));
551
552 let create = tasks.create.expect("create should exist");
554 assert_eq!(create.provider, None);
555 assert_eq!(
556 create.model,
557 Some("anthropic/claude-sonnet-4.6".to_string())
558 );
559 }
560
561 #[test]
562 fn test_config_with_partial_task_overrides() {
563 let config_str = r#"
565[ai]
566provider = "gemini"
567model = "gemini-3.1-flash-lite-preview"
568
569[ai.tasks.triage]
570provider = "gemini"
571
572[ai.tasks.review]
573model = "gemini-3.1-flash-lite-preview"
574"#;
575
576 let config = Config::builder()
577 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
578 .build()
579 .expect("should build config");
580
581 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
582
583 let tasks = app_config.ai.tasks.expect("tasks should exist");
584
585 let triage = tasks.triage.expect("triage should exist");
587 assert_eq!(triage.provider, Some("gemini".to_string()));
588 assert_eq!(triage.model, None);
589
590 let review = tasks.review.expect("review should exist");
592 assert_eq!(review.provider, None);
593 assert_eq!(review.model, Some(DEFAULT_GEMINI_MODEL.to_string()));
594 }
595
596 #[test]
597 fn test_config_without_tasks_section() {
598 let config_str = r#"
600[ai]
601provider = "gemini"
602model = "gemini-3.1-flash-lite-preview"
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 assert_eq!(app_config.ai.provider, "gemini");
613 assert_eq!(app_config.ai.model, DEFAULT_GEMINI_MODEL);
614 assert!(app_config.ai.tasks.is_none());
616 }
617
618 #[test]
619 fn test_resolve_for_task_with_defaults() {
620 let ai_config = AiConfig::default();
622
623 let (provider, model) = ai_config.resolve_for_task(TaskType::Triage);
625 assert_eq!(provider, "openrouter");
626 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
627 assert_eq!(ai_config.allow_paid_models, true);
628
629 let (provider, model) = ai_config.resolve_for_task(TaskType::Review);
630 assert_eq!(provider, "openrouter");
631 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
632 assert_eq!(ai_config.allow_paid_models, true);
633
634 let (provider, model) = ai_config.resolve_for_task(TaskType::Create);
635 assert_eq!(provider, "openrouter");
636 assert_eq!(model, "mistralai/mistral-small-2603");
637 assert_eq!(ai_config.allow_paid_models, true);
638 }
639
640 #[test]
641 fn test_resolve_for_task_with_triage_override() {
642 let config_str = r#"
644[ai]
645provider = "gemini"
646model = "gemini-3.1-flash-lite-preview"
647
648[ai.tasks.triage]
649model = "gemini-3.1-flash-lite-preview"
650"#;
651
652 let config = Config::builder()
653 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
654 .build()
655 .expect("should build config");
656
657 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
658
659 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
661 assert_eq!(provider, "gemini");
662 assert_eq!(model, DEFAULT_GEMINI_MODEL);
663
664 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
666 assert_eq!(provider, "gemini");
667 assert_eq!(model, DEFAULT_GEMINI_MODEL);
668
669 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
670 assert_eq!(provider, "gemini");
671 assert_eq!(model, DEFAULT_GEMINI_MODEL);
672 }
673
674 #[test]
675 fn test_resolve_for_task_with_provider_override() {
676 let config_str = r#"
678[ai]
679provider = "gemini"
680model = "gemini-3.1-flash-lite-preview"
681
682[ai.tasks.review]
683provider = "openrouter"
684"#;
685
686 let config = Config::builder()
687 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
688 .build()
689 .expect("should build config");
690
691 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
692
693 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
695 assert_eq!(provider, "openrouter");
696 assert_eq!(model, DEFAULT_GEMINI_MODEL);
697
698 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
700 assert_eq!(provider, "gemini");
701 assert_eq!(model, DEFAULT_GEMINI_MODEL);
702
703 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
704 assert_eq!(provider, "gemini");
705 assert_eq!(model, DEFAULT_GEMINI_MODEL);
706 }
707
708 #[test]
709 fn test_resolve_for_task_with_full_overrides() {
710 let config_str = r#"
712[ai]
713provider = "gemini"
714model = "gemini-3.1-flash-lite-preview"
715
716[ai.tasks.triage]
717provider = "openrouter"
718model = "mistralai/mistral-small-2603"
719
720[ai.tasks.review]
721provider = "openrouter"
722model = "anthropic/claude-haiku-4.5"
723
724[ai.tasks.create]
725provider = "gemini"
726model = "gemini-3.1-flash-lite-preview"
727"#;
728
729 let config = Config::builder()
730 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
731 .build()
732 .expect("should build config");
733
734 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
735
736 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
738 assert_eq!(provider, "openrouter");
739 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
740
741 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
743 assert_eq!(provider, "openrouter");
744 assert_eq!(model, "anthropic/claude-haiku-4.5");
745
746 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
748 assert_eq!(provider, "gemini");
749 assert_eq!(model, DEFAULT_GEMINI_MODEL);
750 }
751
752 #[test]
753 fn test_resolve_for_task_partial_overrides() {
754 let config_str = r#"
756[ai]
757provider = "openrouter"
758model = "mistralai/mistral-small-2603"
759
760[ai.tasks.triage]
761model = "mistralai/mistral-small-2603"
762
763[ai.tasks.review]
764provider = "openrouter"
765
766[ai.tasks.create]
767"#;
768
769 let config = Config::builder()
770 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
771 .build()
772 .expect("should build config");
773
774 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
775
776 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Triage);
778 assert_eq!(provider, "openrouter");
779 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
780
781 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Review);
783 assert_eq!(provider, "openrouter");
784 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
785
786 let (provider, model) = app_config.ai.resolve_for_task(TaskType::Create);
788 assert_eq!(provider, "openrouter");
789 assert_eq!(model, DEFAULT_OPENROUTER_MODEL);
790 }
791
792 #[test]
793 fn test_fallback_config_toml_parsing() {
794 let config_str = r#"
796[ai]
797provider = "gemini"
798model = "gemini-3.1-flash-lite-preview"
799
800[ai.fallback]
801chain = ["openrouter", "anthropic"]
802"#;
803
804 let config = Config::builder()
805 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
806 .build()
807 .expect("should build config");
808
809 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
810
811 assert_eq!(app_config.ai.provider, "gemini");
812 assert_eq!(app_config.ai.model, "gemini-3.1-flash-lite-preview");
813 assert!(app_config.ai.fallback.is_some());
814
815 let fallback = app_config.ai.fallback.unwrap();
816 assert_eq!(fallback.chain.len(), 2);
817 assert_eq!(fallback.chain[0].provider, "openrouter");
818 assert_eq!(fallback.chain[1].provider, "anthropic");
819 }
820
821 #[test]
822 fn test_fallback_config_empty_chain() {
823 let config_str = r#"
825[ai]
826provider = "gemini"
827model = "gemini-3.1-flash-lite-preview"
828
829[ai.fallback]
830chain = []
831"#;
832
833 let config = Config::builder()
834 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
835 .build()
836 .expect("should build config");
837
838 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
839
840 assert!(app_config.ai.fallback.is_some());
841 let fallback = app_config.ai.fallback.unwrap();
842 assert_eq!(fallback.chain.len(), 0);
843 }
844
845 #[test]
846 fn test_fallback_config_single_provider() {
847 let config_str = r#"
849[ai]
850provider = "gemini"
851model = "gemini-3.1-flash-lite-preview"
852
853[ai.fallback]
854chain = ["openrouter"]
855"#;
856
857 let config = Config::builder()
858 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
859 .build()
860 .expect("should build config");
861
862 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
863
864 assert!(app_config.ai.fallback.is_some());
865 let fallback = app_config.ai.fallback.unwrap();
866 assert_eq!(fallback.chain.len(), 1);
867 assert_eq!(fallback.chain[0].provider, "openrouter");
868 }
869
870 #[test]
871 fn test_fallback_config_without_fallback_section() {
872 let config_str = r#"
874[ai]
875provider = "gemini"
876model = "gemini-3.1-flash-lite-preview"
877"#;
878
879 let config = Config::builder()
880 .add_source(config::File::from_str(config_str, config::FileFormat::Toml))
881 .build()
882 .expect("should build config");
883
884 let app_config: AppConfig = config.try_deserialize().expect("should deserialize");
885
886 assert!(app_config.ai.fallback.is_none());
887 }
888
889 #[test]
890 fn test_fallback_config_default() {
891 let ai_config = AiConfig::default();
893 assert!(ai_config.fallback.is_none());
894 }
895
896 #[test]
897 #[serial]
898 fn test_load_config_env_var_override() {
899 let tmp_dir = std::env::temp_dir().join("aptu_test_env_override");
901 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
902 unsafe {
904 std::env::set_var("XDG_CONFIG_HOME", &tmp_dir);
905 std::env::set_var("APTU_AI__MODEL", "test-model-override");
906 std::env::set_var("APTU_AI__PROVIDER", "openrouter");
907 }
908 let config = load_config().expect("should load with env overrides");
909 unsafe {
910 std::env::remove_var("XDG_CONFIG_HOME");
911 std::env::remove_var("APTU_AI__MODEL");
912 std::env::remove_var("APTU_AI__PROVIDER");
913 }
914
915 assert_eq!(config.ai.model, "test-model-override");
916 assert_eq!(config.ai.provider, "openrouter");
917 }
918
919 #[test]
920 fn test_review_config_defaults() {
921 let review_config = ReviewConfig::default();
923
924 assert_eq!(
926 review_config.max_prompt_chars, 120_000,
927 "max_prompt_chars should default to 120_000"
928 );
929 assert_eq!(
930 review_config.max_full_content_files, 10,
931 "max_full_content_files should default to 10"
932 );
933 assert_eq!(
934 review_config.max_chars_per_file, 4_000,
935 "max_chars_per_file should default to 4_000"
936 );
937
938 let app_config = AppConfig::default();
940 assert_eq!(
941 app_config.review.max_prompt_chars, review_config.max_prompt_chars,
942 "AppConfig review defaults should match ReviewConfig defaults"
943 );
944 assert_eq!(
945 app_config.review.max_full_content_files, review_config.max_full_content_files,
946 "AppConfig review defaults should match ReviewConfig defaults"
947 );
948 assert_eq!(
949 app_config.review.max_chars_per_file, review_config.max_chars_per_file,
950 "AppConfig review defaults should match ReviewConfig defaults"
951 );
952 }
953}