1use std::path::{Path, PathBuf};
4
5use autumn_web::config::Env;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19#[non_exhaustive]
20pub struct SearchConfig {
21 #[serde(default = "default_queue")]
26 pub queue: String,
27
28 #[serde(default = "default_batch_size")]
30 pub batch_size: usize,
31
32 #[serde(default = "default_enabled")]
36 pub enabled: bool,
37
38 #[serde(default)]
45 pub embedding_dimensions: Option<usize>,
46}
47
48fn default_queue() -> String {
49 "search".to_owned()
50}
51
52const fn default_batch_size() -> usize {
53 500
54}
55
56const fn default_enabled() -> bool {
57 true
58}
59
60impl Default for SearchConfig {
61 fn default() -> Self {
62 Self {
63 queue: default_queue(),
64 batch_size: default_batch_size(),
65 enabled: default_enabled(),
66 embedding_dimensions: None,
67 }
68 }
69}
70
71#[derive(Deserialize)]
78struct Document {
79 #[serde(default)]
80 search: Option<SearchConfig>,
81}
82
83#[derive(Debug, thiserror::Error)]
85#[non_exhaustive]
86pub enum SearchConfigError {
87 #[error("invalid [search] configuration: {0}")]
89 Invalid(String),
90}
91
92impl SearchConfig {
93 pub fn from_autumn_toml(path: impl AsRef<std::path::Path>) -> Result<Self, SearchConfigError> {
106 let path = path.as_ref();
107 let contents = std::fs::read_to_string(path).map_err(|e| {
108 SearchConfigError::Invalid(format!("cannot read {}: {e}", path.display()))
109 })?;
110 Self::from_toml_str(&contents)
111 }
112
113 pub fn from_toml_str(contents: &str) -> Result<Self, SearchConfigError> {
125 let document: Document =
126 toml_from_str(contents).map_err(|e| SearchConfigError::Invalid(e.to_string()))?;
127 let config = document.search.unwrap_or_default();
128 config.validate()?;
129 Ok(config)
130 }
131
132 pub fn resolve() -> Result<Self, SearchConfigError> {
144 autumn_web::dotenv::os_env_with_dotenv().map_or_else(
158 |_| Self::resolve_with_env(&autumn_web::config::OsEnv),
159 |env| Self::resolve_with_env(&env),
160 )
161 }
162
163 pub fn resolve_with_env(env: &dyn Env) -> Result<Self, SearchConfigError> {
206 let (selected_input, canonical) = resolve_active_profile(env);
207 let mut merged = toml::Value::Table(toml::map::Map::new());
208
209 let base = read_optional_toml(&find_config_file("autumn.toml", env))?;
211 if let Some(base) = &base {
212 deep_merge(&mut merged, base.clone());
213
214 for name in profile_inline_lookup_names(&canonical) {
216 if let Some(section) = profile_section(base, name) {
217 deep_merge(&mut merged, section);
218 }
219 }
220 }
221
222 for name in
224 autumn_web::config::profile_override_file_lookup_names(&canonical, &selected_input)
225 {
226 let path = find_config_file(&format!("autumn-{name}.toml"), env);
227 if let Some(overlay) = read_optional_toml(&path)? {
228 deep_merge(&mut merged, overlay);
229 break;
230 }
231 }
232
233 let document: Document = merged
234 .try_into()
235 .map_err(|e: toml::de::Error| SearchConfigError::Invalid(e.to_string()))?;
236 let mut config = document.search.unwrap_or_default();
237 config.apply_env_overrides(env);
240 config.validate()?;
241 Ok(config)
242 }
243
244 fn apply_env_overrides(&mut self, env: &dyn Env) {
250 if let Some(queue) = env_trimmed(env, "AUTUMN_SEARCH__QUEUE") {
251 self.queue = queue;
252 }
253 if let Some(value) = env_trimmed(env, "AUTUMN_SEARCH__BATCH_SIZE")
254 && let Ok(parsed) = value.parse::<usize>()
255 {
256 self.batch_size = parsed;
257 }
258 if let Some(value) = env_trimmed(env, "AUTUMN_SEARCH__ENABLED") {
259 match value.to_ascii_lowercase().as_str() {
260 "true" | "1" => self.enabled = true,
261 "false" | "0" => self.enabled = false,
262 _ => {}
263 }
264 }
265 if let Some(value) = env_trimmed(env, "AUTUMN_SEARCH__EMBEDDING_DIMENSIONS")
266 && let Ok(parsed) = value.parse::<usize>()
267 {
268 self.embedding_dimensions = Some(parsed);
269 }
270 }
271
272 pub fn validate(&self) -> Result<(), SearchConfigError> {
279 if self.batch_size == 0 {
280 return Err(SearchConfigError::Invalid(
281 "search.batch_size must be at least 1".to_owned(),
282 ));
283 }
284 if self.queue.trim().is_empty() {
285 return Err(SearchConfigError::Invalid(
286 "search.queue must not be empty".to_owned(),
287 ));
288 }
289 if self.embedding_dimensions == Some(0) {
290 return Err(SearchConfigError::Invalid(
291 "search.embedding_dimensions must be at least 1".to_owned(),
292 ));
293 }
294 Ok(())
295 }
296}
297
298fn toml_from_str<T: serde::de::DeserializeOwned>(contents: &str) -> Result<T, toml::de::Error> {
300 toml::from_str(contents)
301}
302
303fn env_trimmed(env: &dyn Env, key: &str) -> Option<String> {
305 env.var(key)
306 .ok()
307 .map(|value| value.trim().to_owned())
308 .filter(|value| !value.is_empty())
309}
310
311fn resolve_active_profile(env: &dyn Env) -> (String, String) {
318 let selected = resolve_profile_input(env);
319 let canonical =
320 autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
321 (selected, canonical)
322}
323
324fn resolve_profile_input(env: &dyn Env) -> String {
326 if let Some(value) = env_trimmed(env, "AUTUMN_ENV") {
327 return value;
328 }
329 if let Some(value) = env_trimmed(env, "AUTUMN_PROFILE") {
330 return value;
331 }
332 let args: Vec<String> = std::env::args().collect();
336 for (index, arg) in args.iter().enumerate() {
337 if arg == "--profile"
338 && let Some(profile) = args.get(index.saturating_add(1))
339 && !profile.trim().is_empty()
340 {
341 return profile.trim().to_owned();
342 }
343 if let Some(profile) = arg.strip_prefix("--profile=")
344 && !profile.trim().is_empty()
345 {
346 return profile.trim().to_owned();
347 }
348 }
349 if env_trimmed(env, "AUTUMN_IS_DEBUG").as_deref() == Some("0") {
350 return "prod".to_owned();
351 }
352 "dev".to_owned()
353}
354
355fn find_config_file(filename: &str, env: &dyn Env) -> PathBuf {
360 if let Some(dir) = env_trimmed(env, "AUTUMN_MANIFEST_DIR") {
361 let candidate = PathBuf::from(dir).join(filename);
362 if candidate.exists() {
363 return candidate;
364 }
365 }
366 PathBuf::from(filename)
367}
368
369fn read_optional_toml(path: &Path) -> Result<Option<toml::Value>, SearchConfigError> {
374 match std::fs::read_to_string(path) {
375 Ok(contents) => {
376 let table = toml::from_str::<toml::Table>(&contents).map_err(|e| {
377 SearchConfigError::Invalid(format!("cannot parse {}: {e}", path.display()))
378 })?;
379 Ok(Some(toml::Value::Table(table)))
380 }
381 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
382 Err(error) => Err(SearchConfigError::Invalid(format!(
383 "cannot read {}: {error}",
384 path.display()
385 ))),
386 }
387}
388
389fn profile_inline_lookup_names(canonical: &str) -> Vec<&str> {
393 match canonical {
394 "prod" => vec!["production", "prod"],
395 "dev" => vec!["development", "dev"],
396 other => vec![other],
397 }
398}
399
400fn profile_section(base: &toml::Value, profile: &str) -> Option<toml::Value> {
402 base.get("profile")
403 .and_then(toml::Value::as_table)
404 .and_then(|profiles| profiles.get(profile))
405 .and_then(toml::Value::as_table)
406 .map(|table| toml::Value::Table(table.clone()))
407}
408
409fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
413 deep_merge_at(base, overlay, 0);
414}
415
416fn deep_merge_at(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
417 const MAX_MERGE_DEPTH: usize = 16;
419 if depth > MAX_MERGE_DEPTH {
420 return;
421 }
422 let toml::Value::Table(overlay_table) = overlay else {
423 return;
424 };
425 let Some(base_table) = base.as_table_mut() else {
426 return;
427 };
428 for (key, overlay_value) in overlay_table {
429 let recurse =
430 overlay_value.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
431 if recurse {
432 if let Some(base_value) = base_table.get_mut(&key) {
433 deep_merge_at(base_value, overlay_value, depth.saturating_add(1));
434 }
435 } else {
436 base_table.insert(key, overlay_value);
437 }
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use autumn_web::config::MockEnv;
444
445 use super::*;
446
447 #[test]
448 fn defaults_route_indexing_to_its_own_queue() {
449 let config = SearchConfig::default();
450 assert_eq!(config.queue, "search");
451 assert_eq!(config.batch_size, 500);
452 assert!(config.enabled);
453 assert_eq!(config.embedding_dimensions, None);
454 }
455
456 #[test]
457 fn a_partial_section_keeps_the_other_defaults() {
458 let config =
459 SearchConfig::from_toml_str("[search]\nqueue = \"indexing\"\n").expect("parse");
460 assert_eq!(config.queue, "indexing");
461 assert_eq!(config.batch_size, 500);
462 assert!(config.enabled);
463 }
464
465 #[test]
466 fn an_unrelated_document_yields_the_defaults() {
467 assert_eq!(
468 SearchConfig::from_toml_str("[server]\nport = 3000\n").expect("parse"),
469 SearchConfig::default()
470 );
471 assert_eq!(
472 SearchConfig::from_toml_str("").expect("parse"),
473 SearchConfig::default()
474 );
475 }
476
477 #[test]
478 fn a_typo_is_an_error_rather_than_a_silent_default() {
479 assert!(SearchConfig::from_toml_str("[search]\nqueu = \"x\"\n").is_err());
480 }
481
482 #[test]
483 fn out_of_range_values_are_rejected() {
484 assert!(SearchConfig::from_toml_str("[search]\nbatch_size = 0\n").is_err());
485 assert!(SearchConfig::from_toml_str("[search]\nqueue = \"\"\n").is_err());
486 assert!(SearchConfig::from_toml_str("[search]\nembedding_dimensions = 0\n").is_err());
487 }
488
489 #[test]
490 fn an_ill_typed_value_is_rejected() {
491 assert!(SearchConfig::from_toml_str("[search]\nbatch_size = \"lots\"\n").is_err());
492 }
493
494 fn project(files: &[(&str, &str)]) -> (tempfile::TempDir, MockEnv) {
502 let dir = tempfile::tempdir().expect("tempdir");
503 for (name, contents) in files {
504 std::fs::write(dir.path().join(name), contents).expect("write");
505 }
506 let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", &dir.path().display().to_string());
507 (dir, env)
508 }
509
510 #[test]
511 fn an_absent_config_resolves_to_the_defaults() {
512 let (_dir, env) = project(&[]);
513 assert_eq!(
514 SearchConfig::resolve_with_env(&env).expect("resolve"),
515 SearchConfig::default()
516 );
517 }
518
519 #[test]
520 fn the_base_section_resolves_under_the_default_profile() {
521 let (_dir, env) = project(&[("autumn.toml", "[search]\nqueue = \"indexing\"\n")]);
522 assert_eq!(
523 SearchConfig::resolve_with_env(&env).expect("resolve").queue,
524 "indexing"
525 );
526 }
527
528 #[test]
529 fn an_inline_profile_section_overrides_the_base() {
530 let (_dir, mut env) = project(&[(
534 "autumn.toml",
535 "[search]\nenabled = true\nqueue = \"search\"\n\n\
536 [profile.prod.search]\nenabled = false\n",
537 )]);
538
539 let dev = SearchConfig::resolve_with_env(&env).expect("dev");
540 assert!(dev.enabled, "the base section governs the dev profile");
541
542 env = env.with("AUTUMN_ENV", "prod");
543 let prod = SearchConfig::resolve_with_env(&env).expect("prod");
544 assert!(!prod.enabled, "[profile.prod.search] must win in prod");
545 assert_eq!(
546 prod.queue, "search",
547 "keys the profile does not mention keep the base value"
548 );
549 }
550
551 #[test]
552 fn a_profile_override_file_overrides_the_inline_section() {
553 let (_dir, mut env) = project(&[
554 (
555 "autumn.toml",
556 "[search]\nbatch_size = 100\n\n[profile.prod.search]\nbatch_size = 200\n",
557 ),
558 ("autumn-prod.toml", "[search]\nbatch_size = 300\n"),
559 ]);
560 env = env.with("AUTUMN_ENV", "prod");
561 assert_eq!(
562 SearchConfig::resolve_with_env(&env)
563 .expect("resolve")
564 .batch_size,
565 300
566 );
567 }
568
569 #[test]
570 fn a_section_living_only_in_the_profile_file_is_still_found() {
571 let (_dir, mut env) = project(&[
574 ("autumn.toml", "[server]\nport = 3000\n"),
575 ("autumn-prod.toml", "[search]\nenabled = false\n"),
576 ]);
577 env = env.with("AUTUMN_ENV", "prod");
578 assert!(
579 !SearchConfig::resolve_with_env(&env)
580 .expect("resolve")
581 .enabled
582 );
583 }
584
585 #[test]
586 fn the_legacy_production_spelling_resolves_to_the_same_profile() {
587 let (_dir, mut env) = project(&[(
588 "autumn.toml",
589 "[search]\nenabled = true\n\n[profile.production.search]\nenabled = false\n",
590 )]);
591 env = env.with("AUTUMN_ENV", "production");
592 assert!(
593 !SearchConfig::resolve_with_env(&env)
594 .expect("resolve")
595 .enabled
596 );
597 }
598
599 #[test]
600 fn an_env_override_beats_every_file_layer() {
601 let (_dir, mut env) = project(&[(
602 "autumn.toml",
603 "[search]\nenabled = true\n\n[profile.prod.search]\nenabled = true\n",
604 )]);
605 env = env.with("AUTUMN_ENV", "prod");
606 env = env.with("AUTUMN_SEARCH__ENABLED", "false");
607 let config = SearchConfig::resolve_with_env(&env).expect("resolve");
608 assert!(!config.enabled, "AUTUMN_SEARCH__ENABLED is the last word");
609
610 env = env.with("AUTUMN_SEARCH__QUEUE", "urgent");
611 env = env.with("AUTUMN_SEARCH__BATCH_SIZE", "7");
612 env = env.with("AUTUMN_SEARCH__EMBEDDING_DIMENSIONS", "384");
613 let config = SearchConfig::resolve_with_env(&env).expect("resolve");
614 assert_eq!(config.queue, "urgent");
615 assert_eq!(config.batch_size, 7);
616 assert_eq!(config.embedding_dimensions, Some(384));
617 }
618
619 #[test]
620 fn a_typo_in_a_profile_section_is_an_error_rather_than_a_silent_default() {
621 let (_dir, mut env) = project(&[(
622 "autumn.toml",
623 "[search]\nenabled = true\n\n[profile.prod.search]\nenabld = false\n",
624 )]);
625 env = env.with("AUTUMN_ENV", "prod");
626 assert!(SearchConfig::resolve_with_env(&env).is_err());
627 }
628
629 #[test]
630 fn an_out_of_range_env_override_is_still_rejected() {
631 let (_dir, mut env) = project(&[("autumn.toml", "[search]\nbatch_size = 100\n")]);
632 env = env.with("AUTUMN_SEARCH__BATCH_SIZE", "0");
633 assert!(SearchConfig::resolve_with_env(&env).is_err());
634 }
635
636 #[test]
637 fn an_unparseable_env_override_leaves_the_file_value_alone() {
638 let (_dir, mut env) = project(&[("autumn.toml", "[search]\nbatch_size = 100\n")]);
641 env = env.with("AUTUMN_SEARCH__BATCH_SIZE", "lots");
642 assert_eq!(
643 SearchConfig::resolve_with_env(&env)
644 .expect("resolve")
645 .batch_size,
646 100
647 );
648 }
649
650 #[test]
651 fn a_release_binary_with_no_selector_resolves_prod_not_dev() {
652 let (_dir, env) = project(&[(
659 "autumn.toml",
660 "[search]\nenabled = true\n\n[profile.prod.search]\nenabled = false\n",
661 )]);
662
663 let debug_build = env.clone().with("AUTUMN_IS_DEBUG", "1");
664 assert!(
665 SearchConfig::resolve_with_env(&debug_build)
666 .expect("resolve")
667 .enabled,
668 "a debug build is `dev`"
669 );
670
671 let release_build = env.with("AUTUMN_IS_DEBUG", "0");
672 assert!(
673 !SearchConfig::resolve_with_env(&release_build)
674 .expect("resolve")
675 .enabled,
676 "a release build with no selector must resolve `prod`, as core does"
677 );
678 }
679
680 #[test]
681 fn an_explicit_selector_still_beats_the_build_mode() {
682 let (_dir, env) = project(&[(
683 "autumn.toml",
684 "[search]\nqueue = \"base\"\n\n[profile.prod.search]\nqueue = \"prod\"\n\n\
685 [profile.staging.search]\nqueue = \"staging\"\n",
686 )]);
687 let env = env
688 .with("AUTUMN_IS_DEBUG", "0")
689 .with("AUTUMN_ENV", "staging");
690 assert_eq!(
691 SearchConfig::resolve_with_env(&env).expect("resolve").queue,
692 "staging",
693 "AUTUMN_ENV outranks the build-mode fallback"
694 );
695 }
696
697 #[test]
698 fn the_manifest_directory_need_not_be_a_process_variable() {
699 let (dir, env) = project(&[("autumn.toml", "[search]\nqueue = \"from-manifest\"\n")]);
704 assert_eq!(
705 find_config_file("autumn.toml", &env),
706 dir.path().join("autumn.toml")
707 );
708 assert_eq!(
709 SearchConfig::resolve_with_env(&env).expect("resolve").queue,
710 "from-manifest"
711 );
712 }
713
714 #[test]
715 fn a_custom_profile_reads_its_own_layers_only() {
716 let (_dir, mut env) = project(&[
717 (
718 "autumn.toml",
719 "[search]\nqueue = \"base\"\n\n[profile.staging.search]\nqueue = \"staging\"\n\n\
720 [profile.prod.search]\nqueue = \"prod\"\n",
721 ),
722 ("autumn-prod.toml", "[search]\nqueue = \"prod-file\"\n"),
723 ]);
724 env = env.with("AUTUMN_ENV", "staging");
725 assert_eq!(
726 SearchConfig::resolve_with_env(&env).expect("resolve").queue,
727 "staging",
728 "neither [profile.prod] nor autumn-prod.toml may leak into staging"
729 );
730 }
731
732 #[test]
733 fn file_resolution_prefers_the_manifest_directory_over_the_cwd() {
734 let (dir, env) = project(&[("autumn.toml", "[search]\n")]);
740 assert_eq!(
741 find_config_file("autumn.toml", &env),
742 dir.path().join("autumn.toml")
743 );
744 assert_eq!(
747 find_config_file("autumn-prod.toml", &env),
748 PathBuf::from("autumn-prod.toml")
749 );
750 assert_eq!(
751 find_config_file("autumn.toml", &MockEnv::new()),
752 PathBuf::from("autumn.toml"),
753 "with no manifest dir the CWD is the only candidate"
754 );
755 }
756
757 #[test]
758 fn a_malformed_contributing_file_is_reported_rather_than_ignored() {
759 let (_dir, env) = project(&[("autumn.toml", "[search\nqueue = \"x\"\n")]);
760 assert!(SearchConfig::resolve_with_env(&env).is_err());
761 }
762
763 #[test]
764 fn round_trips_through_toml() {
765 let config = SearchConfig {
766 queue: "indexing".to_owned(),
767 batch_size: 42,
768 enabled: false,
769 embedding_dimensions: Some(768),
770 };
771 let document = format!("[search]\n{}", toml::to_string(&config).expect("serialize"));
772 assert_eq!(
773 SearchConfig::from_toml_str(&document).expect("parse"),
774 config
775 );
776 }
777}