use std::path::{Path, PathBuf};
use autumn_web::config::Env;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct SearchConfig {
#[serde(default = "default_queue")]
pub queue: String,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub embedding_dimensions: Option<usize>,
}
fn default_queue() -> String {
"search".to_owned()
}
const fn default_batch_size() -> usize {
500
}
const fn default_enabled() -> bool {
true
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
queue: default_queue(),
batch_size: default_batch_size(),
enabled: default_enabled(),
embedding_dimensions: None,
}
}
}
#[derive(Deserialize)]
struct Document {
#[serde(default)]
search: Option<SearchConfig>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SearchConfigError {
#[error("invalid [search] configuration: {0}")]
Invalid(String),
}
impl SearchConfig {
pub fn from_autumn_toml(path: impl AsRef<std::path::Path>) -> Result<Self, SearchConfigError> {
let path = path.as_ref();
let contents = std::fs::read_to_string(path).map_err(|e| {
SearchConfigError::Invalid(format!("cannot read {}: {e}", path.display()))
})?;
Self::from_toml_str(&contents)
}
pub fn from_toml_str(contents: &str) -> Result<Self, SearchConfigError> {
let document: Document =
toml_from_str(contents).map_err(|e| SearchConfigError::Invalid(e.to_string()))?;
let config = document.search.unwrap_or_default();
config.validate()?;
Ok(config)
}
pub fn resolve() -> Result<Self, SearchConfigError> {
autumn_web::dotenv::os_env_with_dotenv().map_or_else(
|_| Self::resolve_with_env(&autumn_web::config::OsEnv),
|env| Self::resolve_with_env(&env),
)
}
pub fn resolve_with_env(env: &dyn Env) -> Result<Self, SearchConfigError> {
let (selected_input, canonical) = resolve_active_profile(env);
let mut merged = toml::Value::Table(toml::map::Map::new());
let base = read_optional_toml(&find_config_file("autumn.toml", env))?;
if let Some(base) = &base {
deep_merge(&mut merged, base.clone());
for name in profile_inline_lookup_names(&canonical) {
if let Some(section) = profile_section(base, name) {
deep_merge(&mut merged, section);
}
}
}
for name in
autumn_web::config::profile_override_file_lookup_names(&canonical, &selected_input)
{
let path = find_config_file(&format!("autumn-{name}.toml"), env);
if let Some(overlay) = read_optional_toml(&path)? {
deep_merge(&mut merged, overlay);
break;
}
}
let document: Document = merged
.try_into()
.map_err(|e: toml::de::Error| SearchConfigError::Invalid(e.to_string()))?;
let mut config = document.search.unwrap_or_default();
config.apply_env_overrides(env);
config.validate()?;
Ok(config)
}
fn apply_env_overrides(&mut self, env: &dyn Env) {
if let Some(queue) = env_trimmed(env, "AUTUMN_SEARCH__QUEUE") {
self.queue = queue;
}
if let Some(value) = env_trimmed(env, "AUTUMN_SEARCH__BATCH_SIZE")
&& let Ok(parsed) = value.parse::<usize>()
{
self.batch_size = parsed;
}
if let Some(value) = env_trimmed(env, "AUTUMN_SEARCH__ENABLED") {
match value.to_ascii_lowercase().as_str() {
"true" | "1" => self.enabled = true,
"false" | "0" => self.enabled = false,
_ => {}
}
}
if let Some(value) = env_trimmed(env, "AUTUMN_SEARCH__EMBEDDING_DIMENSIONS")
&& let Ok(parsed) = value.parse::<usize>()
{
self.embedding_dimensions = Some(parsed);
}
}
pub fn validate(&self) -> Result<(), SearchConfigError> {
if self.batch_size == 0 {
return Err(SearchConfigError::Invalid(
"search.batch_size must be at least 1".to_owned(),
));
}
if self.queue.trim().is_empty() {
return Err(SearchConfigError::Invalid(
"search.queue must not be empty".to_owned(),
));
}
if self.embedding_dimensions == Some(0) {
return Err(SearchConfigError::Invalid(
"search.embedding_dimensions must be at least 1".to_owned(),
));
}
Ok(())
}
}
fn toml_from_str<T: serde::de::DeserializeOwned>(contents: &str) -> Result<T, toml::de::Error> {
toml::from_str(contents)
}
fn env_trimmed(env: &dyn Env, key: &str) -> Option<String> {
env.var(key)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn resolve_active_profile(env: &dyn Env) -> (String, String) {
let selected = resolve_profile_input(env);
let canonical =
autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
(selected, canonical)
}
fn resolve_profile_input(env: &dyn Env) -> String {
if let Some(value) = env_trimmed(env, "AUTUMN_ENV") {
return value;
}
if let Some(value) = env_trimmed(env, "AUTUMN_PROFILE") {
return value;
}
let args: Vec<String> = std::env::args().collect();
for (index, arg) in args.iter().enumerate() {
if arg == "--profile"
&& let Some(profile) = args.get(index.saturating_add(1))
&& !profile.trim().is_empty()
{
return profile.trim().to_owned();
}
if let Some(profile) = arg.strip_prefix("--profile=")
&& !profile.trim().is_empty()
{
return profile.trim().to_owned();
}
}
if env_trimmed(env, "AUTUMN_IS_DEBUG").as_deref() == Some("0") {
return "prod".to_owned();
}
"dev".to_owned()
}
fn find_config_file(filename: &str, env: &dyn Env) -> PathBuf {
if let Some(dir) = env_trimmed(env, "AUTUMN_MANIFEST_DIR") {
let candidate = PathBuf::from(dir).join(filename);
if candidate.exists() {
return candidate;
}
}
PathBuf::from(filename)
}
fn read_optional_toml(path: &Path) -> Result<Option<toml::Value>, SearchConfigError> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let table = toml::from_str::<toml::Table>(&contents).map_err(|e| {
SearchConfigError::Invalid(format!("cannot parse {}: {e}", path.display()))
})?;
Ok(Some(toml::Value::Table(table)))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(SearchConfigError::Invalid(format!(
"cannot read {}: {error}",
path.display()
))),
}
}
fn profile_inline_lookup_names(canonical: &str) -> Vec<&str> {
match canonical {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
fn profile_section(base: &toml::Value, profile: &str) -> Option<toml::Value> {
base.get("profile")
.and_then(toml::Value::as_table)
.and_then(|profiles| profiles.get(profile))
.and_then(toml::Value::as_table)
.map(|table| toml::Value::Table(table.clone()))
}
fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
deep_merge_at(base, overlay, 0);
}
fn deep_merge_at(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
const MAX_MERGE_DEPTH: usize = 16;
if depth > MAX_MERGE_DEPTH {
return;
}
let toml::Value::Table(overlay_table) = overlay else {
return;
};
let Some(base_table) = base.as_table_mut() else {
return;
};
for (key, overlay_value) in overlay_table {
let recurse =
overlay_value.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
if recurse {
if let Some(base_value) = base_table.get_mut(&key) {
deep_merge_at(base_value, overlay_value, depth.saturating_add(1));
}
} else {
base_table.insert(key, overlay_value);
}
}
}
#[cfg(test)]
mod tests {
use autumn_web::config::MockEnv;
use super::*;
#[test]
fn defaults_route_indexing_to_its_own_queue() {
let config = SearchConfig::default();
assert_eq!(config.queue, "search");
assert_eq!(config.batch_size, 500);
assert!(config.enabled);
assert_eq!(config.embedding_dimensions, None);
}
#[test]
fn a_partial_section_keeps_the_other_defaults() {
let config =
SearchConfig::from_toml_str("[search]\nqueue = \"indexing\"\n").expect("parse");
assert_eq!(config.queue, "indexing");
assert_eq!(config.batch_size, 500);
assert!(config.enabled);
}
#[test]
fn an_unrelated_document_yields_the_defaults() {
assert_eq!(
SearchConfig::from_toml_str("[server]\nport = 3000\n").expect("parse"),
SearchConfig::default()
);
assert_eq!(
SearchConfig::from_toml_str("").expect("parse"),
SearchConfig::default()
);
}
#[test]
fn a_typo_is_an_error_rather_than_a_silent_default() {
assert!(SearchConfig::from_toml_str("[search]\nqueu = \"x\"\n").is_err());
}
#[test]
fn out_of_range_values_are_rejected() {
assert!(SearchConfig::from_toml_str("[search]\nbatch_size = 0\n").is_err());
assert!(SearchConfig::from_toml_str("[search]\nqueue = \"\"\n").is_err());
assert!(SearchConfig::from_toml_str("[search]\nembedding_dimensions = 0\n").is_err());
}
#[test]
fn an_ill_typed_value_is_rejected() {
assert!(SearchConfig::from_toml_str("[search]\nbatch_size = \"lots\"\n").is_err());
}
fn project(files: &[(&str, &str)]) -> (tempfile::TempDir, MockEnv) {
let dir = tempfile::tempdir().expect("tempdir");
for (name, contents) in files {
std::fs::write(dir.path().join(name), contents).expect("write");
}
let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", &dir.path().display().to_string());
(dir, env)
}
#[test]
fn an_absent_config_resolves_to_the_defaults() {
let (_dir, env) = project(&[]);
assert_eq!(
SearchConfig::resolve_with_env(&env).expect("resolve"),
SearchConfig::default()
);
}
#[test]
fn the_base_section_resolves_under_the_default_profile() {
let (_dir, env) = project(&[("autumn.toml", "[search]\nqueue = \"indexing\"\n")]);
assert_eq!(
SearchConfig::resolve_with_env(&env).expect("resolve").queue,
"indexing"
);
}
#[test]
fn an_inline_profile_section_overrides_the_base() {
let (_dir, mut env) = project(&[(
"autumn.toml",
"[search]\nenabled = true\nqueue = \"search\"\n\n\
[profile.prod.search]\nenabled = false\n",
)]);
let dev = SearchConfig::resolve_with_env(&env).expect("dev");
assert!(dev.enabled, "the base section governs the dev profile");
env = env.with("AUTUMN_ENV", "prod");
let prod = SearchConfig::resolve_with_env(&env).expect("prod");
assert!(!prod.enabled, "[profile.prod.search] must win in prod");
assert_eq!(
prod.queue, "search",
"keys the profile does not mention keep the base value"
);
}
#[test]
fn a_profile_override_file_overrides_the_inline_section() {
let (_dir, mut env) = project(&[
(
"autumn.toml",
"[search]\nbatch_size = 100\n\n[profile.prod.search]\nbatch_size = 200\n",
),
("autumn-prod.toml", "[search]\nbatch_size = 300\n"),
]);
env = env.with("AUTUMN_ENV", "prod");
assert_eq!(
SearchConfig::resolve_with_env(&env)
.expect("resolve")
.batch_size,
300
);
}
#[test]
fn a_section_living_only_in_the_profile_file_is_still_found() {
let (_dir, mut env) = project(&[
("autumn.toml", "[server]\nport = 3000\n"),
("autumn-prod.toml", "[search]\nenabled = false\n"),
]);
env = env.with("AUTUMN_ENV", "prod");
assert!(
!SearchConfig::resolve_with_env(&env)
.expect("resolve")
.enabled
);
}
#[test]
fn the_legacy_production_spelling_resolves_to_the_same_profile() {
let (_dir, mut env) = project(&[(
"autumn.toml",
"[search]\nenabled = true\n\n[profile.production.search]\nenabled = false\n",
)]);
env = env.with("AUTUMN_ENV", "production");
assert!(
!SearchConfig::resolve_with_env(&env)
.expect("resolve")
.enabled
);
}
#[test]
fn an_env_override_beats_every_file_layer() {
let (_dir, mut env) = project(&[(
"autumn.toml",
"[search]\nenabled = true\n\n[profile.prod.search]\nenabled = true\n",
)]);
env = env.with("AUTUMN_ENV", "prod");
env = env.with("AUTUMN_SEARCH__ENABLED", "false");
let config = SearchConfig::resolve_with_env(&env).expect("resolve");
assert!(!config.enabled, "AUTUMN_SEARCH__ENABLED is the last word");
env = env.with("AUTUMN_SEARCH__QUEUE", "urgent");
env = env.with("AUTUMN_SEARCH__BATCH_SIZE", "7");
env = env.with("AUTUMN_SEARCH__EMBEDDING_DIMENSIONS", "384");
let config = SearchConfig::resolve_with_env(&env).expect("resolve");
assert_eq!(config.queue, "urgent");
assert_eq!(config.batch_size, 7);
assert_eq!(config.embedding_dimensions, Some(384));
}
#[test]
fn a_typo_in_a_profile_section_is_an_error_rather_than_a_silent_default() {
let (_dir, mut env) = project(&[(
"autumn.toml",
"[search]\nenabled = true\n\n[profile.prod.search]\nenabld = false\n",
)]);
env = env.with("AUTUMN_ENV", "prod");
assert!(SearchConfig::resolve_with_env(&env).is_err());
}
#[test]
fn an_out_of_range_env_override_is_still_rejected() {
let (_dir, mut env) = project(&[("autumn.toml", "[search]\nbatch_size = 100\n")]);
env = env.with("AUTUMN_SEARCH__BATCH_SIZE", "0");
assert!(SearchConfig::resolve_with_env(&env).is_err());
}
#[test]
fn an_unparseable_env_override_leaves_the_file_value_alone() {
let (_dir, mut env) = project(&[("autumn.toml", "[search]\nbatch_size = 100\n")]);
env = env.with("AUTUMN_SEARCH__BATCH_SIZE", "lots");
assert_eq!(
SearchConfig::resolve_with_env(&env)
.expect("resolve")
.batch_size,
100
);
}
#[test]
fn a_release_binary_with_no_selector_resolves_prod_not_dev() {
let (_dir, env) = project(&[(
"autumn.toml",
"[search]\nenabled = true\n\n[profile.prod.search]\nenabled = false\n",
)]);
let debug_build = env.clone().with("AUTUMN_IS_DEBUG", "1");
assert!(
SearchConfig::resolve_with_env(&debug_build)
.expect("resolve")
.enabled,
"a debug build is `dev`"
);
let release_build = env.with("AUTUMN_IS_DEBUG", "0");
assert!(
!SearchConfig::resolve_with_env(&release_build)
.expect("resolve")
.enabled,
"a release build with no selector must resolve `prod`, as core does"
);
}
#[test]
fn an_explicit_selector_still_beats_the_build_mode() {
let (_dir, env) = project(&[(
"autumn.toml",
"[search]\nqueue = \"base\"\n\n[profile.prod.search]\nqueue = \"prod\"\n\n\
[profile.staging.search]\nqueue = \"staging\"\n",
)]);
let env = env
.with("AUTUMN_IS_DEBUG", "0")
.with("AUTUMN_ENV", "staging");
assert_eq!(
SearchConfig::resolve_with_env(&env).expect("resolve").queue,
"staging",
"AUTUMN_ENV outranks the build-mode fallback"
);
}
#[test]
fn the_manifest_directory_need_not_be_a_process_variable() {
let (dir, env) = project(&[("autumn.toml", "[search]\nqueue = \"from-manifest\"\n")]);
assert_eq!(
find_config_file("autumn.toml", &env),
dir.path().join("autumn.toml")
);
assert_eq!(
SearchConfig::resolve_with_env(&env).expect("resolve").queue,
"from-manifest"
);
}
#[test]
fn a_custom_profile_reads_its_own_layers_only() {
let (_dir, mut env) = project(&[
(
"autumn.toml",
"[search]\nqueue = \"base\"\n\n[profile.staging.search]\nqueue = \"staging\"\n\n\
[profile.prod.search]\nqueue = \"prod\"\n",
),
("autumn-prod.toml", "[search]\nqueue = \"prod-file\"\n"),
]);
env = env.with("AUTUMN_ENV", "staging");
assert_eq!(
SearchConfig::resolve_with_env(&env).expect("resolve").queue,
"staging",
"neither [profile.prod] nor autumn-prod.toml may leak into staging"
);
}
#[test]
fn file_resolution_prefers_the_manifest_directory_over_the_cwd() {
let (dir, env) = project(&[("autumn.toml", "[search]\n")]);
assert_eq!(
find_config_file("autumn.toml", &env),
dir.path().join("autumn.toml")
);
assert_eq!(
find_config_file("autumn-prod.toml", &env),
PathBuf::from("autumn-prod.toml")
);
assert_eq!(
find_config_file("autumn.toml", &MockEnv::new()),
PathBuf::from("autumn.toml"),
"with no manifest dir the CWD is the only candidate"
);
}
#[test]
fn a_malformed_contributing_file_is_reported_rather_than_ignored() {
let (_dir, env) = project(&[("autumn.toml", "[search\nqueue = \"x\"\n")]);
assert!(SearchConfig::resolve_with_env(&env).is_err());
}
#[test]
fn round_trips_through_toml() {
let config = SearchConfig {
queue: "indexing".to_owned(),
batch_size: 42,
enabled: false,
embedding_dimensions: Some(768),
};
let document = format!("[search]\n{}", toml::to_string(&config).expect("serialize"));
assert_eq!(
SearchConfig::from_toml_str(&document).expect("parse"),
config
);
}
}