Skip to main content

autumn_search/
config.rs

1//! The plugin-owned `[search]` section of `autumn.toml`.
2
3use std::path::{Path, PathBuf};
4
5use autumn_web::config::Env;
6use serde::{Deserialize, Serialize};
7
8/// Configuration for the search subsystem.
9///
10/// ```toml
11/// [search]
12/// queue = "search"            # the #[job] queue reindex/backfill run on
13/// batch_size = 500            # rows per backfill batch
14/// enabled = true              # false ⇒ index writes become no-ops
15/// embedding_dimensions = 768  # declared width; enables the pgvector fast path
16/// ```
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19#[non_exhaustive]
20pub struct SearchConfig {
21    /// Named `#[job]` queue the reindex and backfill jobs are routed to.
22    ///
23    /// A dedicated queue by default: indexing is bulk, off-request work that
24    /// should never head-of-line-block a user-visible job.
25    #[serde(default = "default_queue")]
26    pub queue: String,
27
28    /// Rows read (and documents written) per backfill batch.
29    #[serde(default = "default_batch_size")]
30    pub batch_size: usize,
31
32    /// When `false`, index writes become no-ops and queries return empty
33    /// pages. The kill switch for an incident: turning search off must not
34    /// require a deploy, and must not start failing writes to the model.
35    #[serde(default = "default_enabled")]
36    pub enabled: bool,
37
38    /// Declared embedding width.
39    ///
40    /// Optional: the in-memory backend infers it from the vectors it is given.
41    /// The Postgres backend needs it to declare a `pgvector` column, so
42    /// setting it is what enables the accelerated k-NN path; without it,
43    /// vectors fall back to a portable `double precision[]` column.
44    #[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/// An `autumn.toml` document, seen only through its `[search]` table.
72///
73/// Not `deny_unknown_fields` — every other top-level section (`server`, `log`,
74/// `profile`, …) belongs to core or another plugin and is none of this crate's
75/// business. `SearchConfig` itself is strict, so a typo *inside* `[search]`
76/// still fails.
77#[derive(Deserialize)]
78struct Document {
79    #[serde(default)]
80    search: Option<SearchConfig>,
81}
82
83/// Why a `[search]` section was rejected.
84#[derive(Debug, thiserror::Error)]
85#[non_exhaustive]
86pub enum SearchConfigError {
87    /// The TOML did not parse, or `[search]` had an unknown/ill-typed key.
88    #[error("invalid [search] configuration: {0}")]
89    Invalid(String),
90}
91
92impl SearchConfig {
93    /// Read `[search]` from the `autumn.toml` at `path`.
94    ///
95    /// Matches `autumn-media-plugin`'s `MediaConfig::from_autumn_toml`, which
96    /// also takes a **path** — the string-taking form here is
97    /// [`SearchConfig::from_toml_str`], so a caller who passes `"autumn.toml"`
98    /// to the wrong one gets a compile error rather than a TOML parse error
99    /// about the filename.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`SearchConfigError::Invalid`] when the file cannot be read or
104    /// its `[search]` table is malformed.
105    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    /// Parse the `[search]` table out of an `autumn.toml` document.
114    ///
115    /// A missing section yields the defaults. An unknown key is an **error**,
116    /// not a warning: a typo'd `queu = "indexing"` would otherwise silently
117    /// leave indexing on the default queue with no signal at all.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`SearchConfigError::Invalid`] when the document does not
122    /// parse, when `[search]` has an unknown or ill-typed key, or when a value
123    /// is out of range.
124    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    /// Resolve the effective `[search]` section for the **active profile**,
133    /// reading the process environment.
134    ///
135    /// This is what the plugin calls at boot. The pure, testable core is
136    /// [`resolve_with_env`](Self::resolve_with_env).
137    ///
138    /// # Errors
139    ///
140    /// Returns [`SearchConfigError::Invalid`] when a contributing file cannot
141    /// be read or parsed, when the merged `[search]` table has an unknown or
142    /// ill-typed key, or when a value is out of range.
143    pub fn resolve() -> Result<Self, SearchConfigError> {
144        // The OS environment **overlaid with `.env`**, not `OsEnv` alone.
145        //
146        // Core's loader reads `.env`, `.env.local`, `.env.<profile>` into an
147        // overlay `Env` and deliberately does NOT export them into the process
148        // environment. A resolver built on `OsEnv` therefore cannot see them
149        // at all — so `AUTUMN_SEARCH__ENABLED=false` in a project `.env`, the
150        // most ordinary way to set it in development, would leave search on
151        // while every other framework setting in the same file took effect.
152        //
153        // A `.env` that exists but cannot be read or parsed falls back to the
154        // real environment rather than failing the whole resolution: core
155        // surfaces that error on its own path, and search refusing to boot
156        // over it would be a second, more confusing report of one problem.
157        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    /// Pure core of [`resolve`](Self::resolve): build the effective `[search]`
164    /// section from `env` without touching process-global state.
165    ///
166    /// Layering mirrors core's own loader (`AutumnConfig::load_with_env`),
167    /// restricted to the `[search]` subtree:
168    ///
169    /// 1. base `autumn.toml` `[search]`,
170    /// 2. inline `[profile.<name>.search]` (alias then canonical, so the
171    ///    canonical spelling wins),
172    /// 3. `autumn-<profile>.toml` `[search]` (first existing file in the
173    ///    shared [`profile_override_file_lookup_names`] order wins),
174    /// 4. `AUTUMN_SEARCH__*` environment overrides — including values from the
175    ///    project's `.env` files, which core overlays onto the environment
176    ///    without exporting them into the process.
177    ///
178    /// Reading only layer 1 — which is what a naive plugin loader does — means
179    /// `enabled = false` set under `[profile.prod.search]` is silently ignored
180    /// in production. That turns the documented incident kill switch into a
181    /// no-op precisely when it is being reached for, which is why the plugin
182    /// pays for the full merge rather than a single `read_to_string`.
183    ///
184    /// Each filename is resolved independently through `AUTUMN_MANIFEST_DIR`
185    /// then the working directory, exactly as core's `find_config_file_named`
186    /// does — so the plugin reads the same files the host runtime does.
187    ///
188    /// `env` is core's [`Env`](autumn_web::config::Env) abstraction, **not** a
189    /// snapshot of `std::env::vars()`. That distinction is load-bearing:
190    /// [`OsEnv`](autumn_web::config::OsEnv) synthesizes `AUTUMN_MANIFEST_DIR`
191    /// and `AUTUMN_IS_DEBUG` from the `#[autumn_web::main]` macro context, and
192    /// neither is a real process variable. Reading the raw process environment
193    /// instead means a release binary launched with no explicit selector is
194    /// seen as `dev` here while core resolves `prod` — so `[profile.prod]`
195    /// would be skipped — and the app's crate directory is invisible, so a
196    /// binary run from anywhere but the crate root misses its own config.
197    ///
198    /// [`profile_override_file_lookup_names`]: autumn_web::config::profile_override_file_lookup_names
199    ///
200    /// # Errors
201    ///
202    /// Returns [`SearchConfigError::Invalid`] when a contributing file cannot
203    /// be read or parsed, when the merged `[search]` table has an unknown or
204    /// ill-typed key, or when a value is out of range.
205    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        // Layer 1: base `autumn.toml`. Absent is fine — a zero-config app.
210        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            // Layer 2: inline `[profile.<name>]`, alias first.
215            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        // Layer 3: `autumn-<profile>.toml`; the first existing file wins.
223        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        // Layer 4: env overrides, highest priority — the same precedence core
238        // gives `AUTUMN_SERVER__*`.
239        config.apply_env_overrides(env);
240        config.validate()?;
241        Ok(config)
242    }
243
244    /// Apply `AUTUMN_SEARCH__<FIELD>` scalar overrides.
245    ///
246    /// An unparseable value is **ignored** rather than fatal, matching core's
247    /// own `apply_env_overrides`: a malformed env var must not take down a
248    /// process that would otherwise boot on its file config.
249    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    /// Reject values that would misbehave at runtime.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`SearchConfigError::Invalid`] for a zero batch size, an empty
277    /// queue name, or a zero embedding width.
278    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
298/// `toml::from_str` behind a helper so the dependency edge is in one place.
299fn toml_from_str<T: serde::de::DeserializeOwned>(contents: &str) -> Result<T, toml::de::Error> {
300    toml::from_str(contents)
301}
302
303/// Non-blank env value (a blank value reads as unset, as core treats it).
304fn 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
311/// The `(selected spelling, canonical name)` of the active profile.
312///
313/// Mirrors core's `resolve_profile`: `AUTUMN_ENV` → `AUTUMN_PROFILE` →
314/// `--profile` → `AUTUMN_IS_DEBUG=0` ⇒ `prod` → `dev`. Both halves are needed
315/// because the override-file lookup prefers the spelling that was actually
316/// selected (`autumn-production.toml` vs `autumn-prod.toml`).
317fn 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
324/// The raw profile selector, before normalization.
325fn 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    // `--profile <name>` / `--profile=<name>`: the runtime consults process
333    // args too, and `autumn search reindex --profile prod` re-executes the app
334    // binary with exactly that flag.
335    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
355/// Resolve one config filename the way core's `find_config_file_named` does:
356/// `AUTUMN_MANIFEST_DIR` when the file is actually there, else relative to the
357/// working directory. Per-file, not per-directory — a base `autumn.toml` in the
358/// crate root and an `autumn-prod.toml` in the working directory both count.
359fn 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
369/// Read a TOML file as a raw value; `Ok(None)` when it does not exist.
370///
371/// A file that exists but cannot be read is a real problem — a permissions
372/// mistake must not read as "zero-config".
373fn 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
389/// Inline `[profile.<name>]` names for a canonical profile, legacy alias first
390/// so the canonical spelling merges last and wins. Mirrors core's
391/// `profile_lookup_names`.
392fn 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
400/// Lift `[profile.<name>]` out of a parsed document as a standalone table.
401fn 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
409/// Deep-merge `overlay` into `base`: tables merge recursively, everything else
410/// replaces. A faithful copy of core's private `deep_merge`, bounded by the
411/// same depth, so the plugin's layering matches the runtime's exactly.
412fn 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    /// Matches core's `MAX_MERGE_DEPTH`.
418    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    // ── Profile-aware resolution ────────────────────────────────────────────
495
496    /// A project directory plus the `Env` that points the resolver at it.
497    ///
498    /// `MockEnv` is core's own test double for the same abstraction the
499    /// runtime uses, so these exercise the real resolution path rather than a
500    /// parallel one built on `std::env::vars()`.
501    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        // The kill switch: `enabled = false` under `[profile.prod.search]` has
531        // to actually turn search off in prod. Reading only the base section
532        // would leave it enabled — with no signal at all.
533        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        // No base `[search]` at all — an app that configures search per
572        // environment and nowhere else.
573        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        // Core's own overrides ignore a malformed value rather than refusing
639        // to boot; a process that would run on its file config should.
640        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        // `AUTUMN_IS_DEBUG` is NOT a real process variable — core's `OsEnv`
653        // synthesizes it from the `#[autumn_web::main]` macro context. A
654        // resolver reading `std::env::vars()` never sees it, so a release
655        // binary launched with no explicit selector reads as `dev` while core
656        // reads `prod`, and `[profile.prod.search] enabled = false` is skipped
657        // in exactly the environment it was written for.
658        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        // Same point for the other synthesized value: `OsEnv` falls back to
700        // the macro-supplied crate directory when `AUTUMN_MANIFEST_DIR` is not
701        // in the process environment. Going through `Env` is what lets the
702        // plugin find the app's config from any working directory.
703        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        // `autumn search reindex --package app` runs the binary from the
735        // workspace root, where the CWD holds a different `autumn.toml` (or
736        // none). Core resolves through `AUTUMN_MANIFEST_DIR` first; the
737        // plugin-owned section must resolve the same way or it reads a
738        // different file than the rest of the config.
739        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        // A file the manifest dir does NOT hold falls through to the CWD,
745        // per-file — core resolves each filename independently.
746        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}