Skip to main content

pond/
config.rs

1//! Configuration loading: the `[embeddings]`, `[adapters]`, `[storage]`, and
2//! `[creds.*]` blocks.
3//!
4//! pond ships built-in defaults, so an instance with no `config.toml` still
5//! works. `pond config schema` emits [`DEFAULT_CONFIG_TOML`], the
6//! fully-annotated example. Loading layers `config.toml` under the `POND_*`
7//! env mirror via figment, so every command also works with no config file
8//! at all (spec.md#storage-configless) - URLs + env vars are sufficient.
9
10use std::{
11    collections::BTreeMap,
12    path::{Path, PathBuf},
13};
14
15use anyhow::{Context, Result, anyhow, bail};
16use figment::{
17    Figment,
18    providers::{Env, Format, Toml},
19};
20use serde::{Deserialize, Deserializer, Serialize, de};
21use serde_json::Value;
22use url::Url;
23
24/// Parse `"128 MiB"`, `"1 GiB"`, `"500 KiB"`, or a bare byte count. Accepts
25/// SI (KB/MB/GB) and binary (KiB/MiB/GiB/TiB) suffixes; treats the bare unit
26/// `"B"` and unsuffixed numbers as raw bytes. Tolerant of whitespace and
27/// case. The result MUST fit in `usize` (Lance's cache APIs take `usize`).
28fn parse_byte_size(raw: &str) -> Result<usize, String> {
29    let trimmed = raw.trim();
30    if trimmed.is_empty() {
31        return Err("byte-size value is empty".to_owned());
32    }
33    let split = trimmed
34        .find(|c: char| c.is_ascii_alphabetic())
35        .unwrap_or(trimmed.len());
36    let (number, unit) = trimmed.split_at(split);
37    let number: f64 = number
38        .trim()
39        .parse()
40        .map_err(|_| format!("byte-size value {raw:?} is not a number"))?;
41    if !number.is_finite() || number < 0.0 {
42        return Err(format!("byte-size value {raw:?} must be non-negative"));
43    }
44    let multiplier: f64 = match unit.trim().to_ascii_lowercase().as_str() {
45        "" | "b" => 1.0,
46        "k" | "kb" => 1_000.0,
47        "kib" => 1_024.0,
48        "m" | "mb" => 1_000_000.0,
49        "mib" => 1_048_576.0,
50        "g" | "gb" => 1_000_000_000.0,
51        "gib" => 1_073_741_824.0,
52        "tib" => 1_099_511_627_776.0,
53        other => {
54            return Err(format!(
55                "byte-size unit {other:?} not recognized (try MiB / GiB)"
56            ));
57        }
58    };
59    let bytes = number * multiplier;
60    if !bytes.is_finite() || bytes > usize::MAX as f64 {
61        return Err(format!("byte-size value {raw:?} overflows usize"));
62    }
63    Ok(bytes as usize)
64}
65
66/// Accept string / integer / float / bool and stringify. The env mirror
67/// parses values TOML-ishly, so `POND_CREDS_X_SECRET_ACCESS_KEY=12345`
68/// arrives as a number; these fields are strings no matter how they scan.
69fn lenient_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
70where
71    D: Deserializer<'de>,
72{
73    #[derive(Deserialize)]
74    #[serde(untagged)]
75    enum Repr {
76        Text(String),
77        Int(i64),
78        Float(f64),
79        Bool(bool),
80    }
81    Ok(
82        Option::<Repr>::deserialize(deserializer)?.map(|repr| match repr {
83            Repr::Text(value) => value,
84            Repr::Int(value) => value.to_string(),
85            Repr::Float(value) => value.to_string(),
86            Repr::Bool(value) => value.to_string(),
87        }),
88    )
89}
90
91fn deserialize_byte_size_opt<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
92where
93    D: Deserializer<'de>,
94{
95    #[derive(Deserialize)]
96    #[serde(untagged)]
97    enum Repr {
98        Bytes(u64),
99        Text(String),
100    }
101    let repr: Option<Repr> = Option::deserialize(deserializer)?;
102    match repr {
103        None => Ok(None),
104        Some(Repr::Bytes(value)) => usize::try_from(value).map(Some).map_err(de::Error::custom),
105        Some(Repr::Text(value)) => parse_byte_size(&value).map(Some).map_err(de::Error::custom),
106    }
107}
108
109/// True when the URL is on the local filesystem. Mirrors Lance's
110/// `ObjectStore::is_local` (lance-io/src/object_store.rs:541): the `file` and
111/// `file+uring` schemes are local; everything else (incl. `memory://`) is not.
112pub fn is_local(url: &Url) -> bool {
113    matches!(url.scheme(), "file" | "file+uring")
114}
115
116/// Extract the filesystem `PathBuf` for local URLs. `None` for remote.
117pub fn local_path(url: &Url) -> Option<PathBuf> {
118    if !is_local(url) {
119        return None;
120    }
121    // `Url::to_file_path` only accepts the `file` scheme, and `set_scheme`
122    // can't cross the special/non-special boundary - rebuild `file+uring`
123    // URLs as `file` textually.
124    match url.as_str().strip_prefix("file+uring:") {
125        Some(rest) => Url::parse(&format!("file:{rest}"))
126            .ok()?
127            .to_file_path()
128            .ok(),
129        None => url.to_file_path().ok(),
130    }
131}
132
133/// URI string for a child of this location (typically one Lance dataset under
134/// the data dir). Trims a single trailing slash on the base, then concatenates
135/// with a `/` separator. This keeps `Dataset::open` / `Dataset::write` happy
136/// on both filesystem and object-store backends - they want the URI form, not
137/// a `url::Url`.
138pub fn child_uri(base: &Url, suffix: &str) -> String {
139    // For local URLs we strip the `file://` prefix so log lines and error
140    // messages render as plain paths (`/srv/pond/sessions.lance`), matching
141    // what pond used to emit before the URL migration.
142    if let Some(path) = local_path(base) {
143        return path.join(suffix).display().to_string();
144    }
145    format!("{}/{suffix}", base.as_str().trim_end_matches('/'))
146}
147
148/// Render a `Url` for human-readable log/diagnostic output: local URLs come
149/// back as plain paths (no `file://` prefix, `$HOME` contracted to `~`);
150/// remote URLs stay verbatim.
151pub fn display(url: &Url) -> String {
152    if let Some(path) = local_path(url) {
153        contract_home(&path).display().to_string()
154    } else {
155        url.to_string()
156    }
157}
158
159/// Build a `Url` from a filesystem path. Convenience for tests and for
160/// callers that hold a `PathBuf` already. The path must be
161/// absolute (`url::Url::from_file_path` is a hard requirement on Unix); a
162/// relative path gets canonicalized via `std::path::absolute` first.
163pub fn url_for_path(path: impl AsRef<Path>) -> Result<Url> {
164    let path = path.as_ref();
165    let absolute = if path.is_absolute() {
166        path.to_path_buf()
167    } else {
168        std::path::absolute(path)
169            .with_context(|| format!("failed to absolutize {}", path.display()))?
170    };
171    Url::from_file_path(&absolute).map_err(|()| {
172        anyhow!(
173            "failed to convert path {} into a file:// URL",
174            absolute.display()
175        )
176    })
177}
178
179/// Default `config.toml` body emitted by `pond config schema`. Every
180/// line is commented: pond ships built-in defaults, so the file is purely a
181/// discoverable template and pond still works with no `config.toml` on disk.
182pub const DEFAULT_CONFIG_TOML: &str = "\
183# pond configuration.
184#
185# pond ships built-in defaults, so every setting here is optional - delete this
186# file and pond still works. Uncomment and edit to override.
187
188# Where pond looks for adapter data to import. One entry per adapter type
189# (`claude-code`, `codex-cli`, ...). `pond sync` with no arguments syncs every
190# entry; `pond sync <adapter>` syncs just one. With an empty `[adapters]`,
191# `pond sync` runs an interactive discovery against the known default paths
192# and writes the picks back here.
193#
194# Future wrap: pond is single-namespace in v1 (spec.md#wire-namespace-resolution); `[adapters]` is
195# flat here. When multi-namespace pond lands, adapter registration becomes
196# per-tenant under `[namespaces.<ns>.adapters.<adapter>]`. Pre-v1 the schema
197# is breakable; the rename is operationally free until a real second tenant
198# exists.
199#
200# [adapters.claude-code]
201# enabled = true
202# path = \"~/.claude/projects\"
203#
204# [adapters.codex-cli]
205# enabled = true
206# path = \"~/.codex/sessions\"
207#
208# Set `enabled = false` to keep the section but skip it on `pond sync`;
209# re-enable via `pond adapters enable <adapter>`.
210
211# Embeddings. Search defaults to the vector arm (matching on meaning) when the
212# store has any vectors, falling back to FTS otherwise - the model loads lazily
213# on the first vector query, so there's no cost on FTS-only corpora. `model`
214# selects the HuggingFace XLM-RoBERTa model; `dim` declares its output width and
215# is baked into the messages.vector schema on table creation - it must equal the
216# model's hidden_size.
217#
218# Common pairings:
219#   model = \"intfloat/multilingual-e5-small\"   dim = 384   (default)
220#   model = \"intfloat/multilingual-e5-base\"    dim = 768
221#   model = \"intfloat/multilingual-e5-large\"   dim = 1024
222#
223# A different-dim model needs a fresh data dir; pond enforces this at the
224# schema boundary.
225#
226# [embeddings]
227# model = \"intfloat/multilingual-e5-small\"
228# dim = 384
229
230# Search tuning. Leave unset for Lance defaults; set when tuning vector recall
231# against a corpus.
232#
233# [search]
234# nprobes = 16
235
236# Storage maintenance. Tunes the compaction + cleanup pass that runs inside
237# `pond sync` and `pond optimize`.
238#
239# - `compaction_fragment_cap` is the per-task fragment-count backstop: a
240#   planned compaction task touching at least this many fragments bypasses the
241#   width and write-amplification checks once the merge can shrink the
242#   fragment count. Default 64; 0 disables task filtering and runs every task
243#   Lance plans.
244# - `cleanup_older_than` is the manifest-retention window for the safe cleanup
245#   pass. Accepts `Ns` / `Nm` / `Nh` / `Nd` (default `1d`, floor `1h` - it is
246#   what protects in-flight readers). Versions older than this are reclaimed
247#   by Lance's OCC-coordinated GC.
248#
249# [maintenance]
250# compaction_fragment_cap = 64
251# cleanup_older_than = \"1d\"
252
253# Long-running process caps. Both accept either a plain byte count or a
254# humansize-style suffix (\"128 MiB\", \"1 GiB\"). Both are optional - leave
255# unset to let pond pick the backend-aware default:
256#   local FS  : index_cache = 256 MiB, metadata_cache = 128 MiB
257#   remote    : index_cache = 2 GiB,   metadata_cache = 512 MiB
258# Lance's library defaults (6 GiB / 1 GiB) are too generous for a per-session
259# `pond mcp` process; tightening them is what keeps RSS under the 500 MiB target
260# without measurable latency regressions on typical agent-history corpora.
261#
262# [runtime]
263# index_cache_bytes    = \"256 MiB\"
264# metadata_cache_bytes = \"128 MiB\"
265
266# Storage address and credentials (spec.md#storage-url-grammar).
267#
268# `path` is the default destination used when `--storage-path` (env
269# `POND_STORAGE_PATH`) is not passed. Absent = the platform-local data dir.
270# Addresses are URLs; the `s3+https` form carries the endpoint, bucket, and
271# prefix in one token:
272#
273#   /abs/path or ~/path                  local filesystem
274#   s3://bucket/prefix                   AWS S3 (ambient credential chain)
275#   s3+https://host/bucket/prefix        S3-compatible endpoint (Hetzner, R2, B2, MinIO)
276#   gs://bucket/prefix                   Google Cloud Storage
277#   az://account/container/prefix        Azure Blob
278#
279# Credentials live in `[creds.<name>]` sets and bind to URLs by `scope`
280# prefix - longest match wins (spec.md#creds-scope-match); a set without
281# `scope` matches any URL. With no matching set, the standard cloud SDK
282# chain applies (AWS_* env, shared credentials file, instance metadata).
283# Secrets never go in URLs or CLI flags; besides inline values,
284# `access_key_id_file` / `secret_access_key_file` read a file and
285# `secret_access_key_command` runs a command (e.g. `op read ...`). `extra`
286# holds verbatim `object_store` options pond has not typed.
287#
288# Every field mirrors to env: `POND_STORAGE_PATH`, `POND_CREDS_<NAME>_<FIELD>`
289# (set names are lowercase alphanumeric, so the env grammar is unambiguous).
290# Precedence: CLI flag > POND_* env > this file > ambient cloud chain.
291# Probe a destination end-to-end with `pond storage check`.
292#
293# Future wrap: pond is single-namespace in v1 (spec.md#wire-namespace-resolution);
294# `[storage]` is flat here on the assumption of one bucket per pond. When
295# multi-namespace pond lands this becomes `[namespaces.<ns>.storage]`.
296#
297# [storage]
298# path = \"s3+https://nbg1.your-objectstorage.com/my-pond\"
299#
300# [creds.default]
301# access_key_id     = \"...\"
302# secret_access_key = \"...\"
303";
304
305/// Top-level `config.toml` shape.
306#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct Config {
309    #[serde(default)]
310    pub embeddings: EmbeddingsConfig,
311    #[serde(default)]
312    pub search: SearchConfig,
313    #[serde(default)]
314    pub maintenance: MaintenanceConfig,
315    #[serde(default)]
316    pub runtime: RuntimeConfig,
317    /// `[adapters.<adapter>]` map: per-adapter config blobs the matching
318    /// factory deserializes inside its `open()`. The shape is adapter-defined
319    /// (filesystem adapters expect `{ path = "..." }`; API-backed adapters
320    /// expect endpoint + auth keys), so this layer stays opaque. Empty by
321    /// default; `pond sync` runs discovery into this map on first use.
322    #[serde(default)]
323    pub adapters: BTreeMap<String, Value>,
324    /// `[storage]`: the default destination URL (spec.md#storage-url-grammar).
325    /// `None` = the platform-local data dir.
326    #[serde(default)]
327    pub storage: StorageConfig,
328    /// `[creds.<name>]`: URL-scoped credential sets. Every storage URL
329    /// resolves its own set by longest-prefix `scope` match
330    /// (spec.md#creds-scope-match); the resolver lives in `pond::substrate`.
331    #[serde(default)]
332    pub creds: BTreeMap<String, CredsSet>,
333}
334
335/// `[storage]`: the single default destination. Typed so the legacy
336/// passthrough map (ENV-style `object_store` keys) fails loudly with the
337/// rewrite recipe instead of silently changing meaning.
338#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
339#[serde(deny_unknown_fields)]
340pub struct StorageConfig {
341    #[serde(default)]
342    pub path: Option<String>,
343}
344
345/// One `[creds.<name>]` set. All fields optional; validation enforces at most
346/// one variant per logical secret. `extra` carries verbatim `object_store`
347/// options pond has not typed (redaction in `pond config show` still applies
348/// to its keys by name).
349#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
350#[serde(deny_unknown_fields)]
351pub struct CredsSet {
352    /// URL prefix this set binds to. `None` = the catch-all set (at most one).
353    #[serde(default)]
354    pub scope: Option<String>,
355    // Key / region fields are `lenient_string`: the env mirror parses values
356    // TOML-ishly, so an all-digit key or region arrives as a number and must
357    // still land in these String fields.
358    #[serde(default, deserialize_with = "lenient_string")]
359    pub access_key_id: Option<String>,
360    #[serde(default)]
361    pub access_key_id_file: Option<PathBuf>,
362    #[serde(default, deserialize_with = "lenient_string")]
363    pub secret_access_key: Option<String>,
364    #[serde(default)]
365    pub secret_access_key_file: Option<PathBuf>,
366    #[serde(default)]
367    pub secret_access_key_command: Option<String>,
368    #[serde(default, deserialize_with = "lenient_string")]
369    pub region: Option<String>,
370    #[serde(default)]
371    pub virtual_hosted_style_request: Option<bool>,
372    #[serde(default)]
373    pub extra: BTreeMap<String, String>,
374}
375
376/// `[creds.<name>]` name charset `[a-z][a-z0-9]{0,15}` (spec.md#storage-env-mirror):
377/// lowercase-alphanumeric keeps `POND_CREDS_<NAME>_<FIELD>` splittable at the
378/// first `_` after the name. Shared by config validation and `pond creds`.
379pub fn valid_creds_set_name(name: &str) -> bool {
380    let mut chars = name.chars();
381    chars.next().is_some_and(|c| c.is_ascii_lowercase())
382        && name.len() <= 16
383        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
384}
385
386/// The rejection message for a name that fails [`valid_creds_set_name`], shared
387/// by config validation and `pond creds` so the rule and its wording never drift.
388pub fn creds_set_name_error(name: &str) -> String {
389    format!(
390        "creds set name {name:?} must match [a-z][a-z0-9]{{0,15}} (lowercase alphanumeric, no separators)"
391    )
392}
393
394/// `[runtime]`: long-running process caps. Both knobs accept either a plain
395/// byte count or a `humansize`-style suffix (`"128 MiB"`, `"1 GiB"`). Both are
396/// optional - `None` lets `pond::substrate` pick the backend-aware default
397/// (local FS gets a tight cap; object stores stay near Lance's defaults).
398#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
399#[serde(deny_unknown_fields, default)]
400pub struct RuntimeConfig {
401    #[serde(default, deserialize_with = "deserialize_byte_size_opt")]
402    pub index_cache_bytes: Option<usize>,
403    #[serde(default, deserialize_with = "deserialize_byte_size_opt")]
404    pub metadata_cache_bytes: Option<usize>,
405}
406
407/// `[search]`: optional Lance vector-query tuning knobs.
408#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
409#[serde(deny_unknown_fields)]
410pub struct SearchConfig {
411    #[serde(default)]
412    pub nprobes: Option<usize>,
413}
414
415/// `[maintenance]`: storage-maintenance knobs shared by `pond sync` and
416/// `pond optimize`. All optional - omit and pond falls back to the
417/// in-process defaults in `pond::substrate` (`DEFAULT_COMPACTION_FRAGMENT_CAP`,
418/// `default_cleanup_older_than`).
419#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
420#[serde(deny_unknown_fields)]
421pub struct MaintenanceConfig {
422    /// Per-task fragment-count backstop for the write-amplification veto.
423    /// Default 64; 0 disables all task filtering.
424    #[serde(default)]
425    pub compaction_fragment_cap: Option<usize>,
426    /// Manifest-retention window for the safe cleanup pass. Accepts
427    /// `Ns`/`Nm`/`Nh`/`Nd` (default `1d`). Versions older than this are
428    /// reclaimed by Lance's OCC-coordinated GC (`delete_unverified=false`),
429    /// which never races a concurrent writer on any backend.
430    #[serde(default)]
431    pub cleanup_older_than: Option<String>,
432}
433
434/// `[embeddings]`: model selector and vector dimension. There is no master
435/// switch - a `vector` search degrades to FTS when no vectors exist in the
436/// store (`has_embeddings()` is the only gate); the candle/Metal model is
437/// `LazyEmbedder`-loaded on the first query that
438/// actually needs it. `model` and `dim` are installed into the process at
439/// startup via `embed::init_model_id` / `sessions::init_embedding_dim`, so
440/// swapping models for a one-off experiment is a temporary config file - no
441/// CLI flag and no per-call-site plumbing.
442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
443#[serde(deny_unknown_fields, default)]
444pub struct EmbeddingsConfig {
445    /// The embedding model id (spec.md#search): any XLM-RoBERTa model loadable
446    /// by `candle-transformers`. Defaults to `intfloat/multilingual-e5-small`.
447    pub model: String,
448    /// Output dimension of `model`. Must equal the model's `hidden_size`.
449    /// Defaults to 384 (e5-small). Set to 768 for e5-base, 1024 for e5-large.
450    pub dim: usize,
451}
452
453impl Default for EmbeddingsConfig {
454    fn default() -> Self {
455        Self {
456            model: crate::embed::DEFAULT_MODEL_ID.to_owned(),
457            dim: crate::sessions::DEFAULT_EMBEDDING_DIM,
458        }
459    }
460}
461
462/// The platform-local default storage path, used when neither
463/// `--storage-path` / `POND_STORAGE_PATH` nor `[storage].path` is set:
464/// `$XDG_DATA_HOME/pond`, then `$HOME/.local/share/pond`, then `.pond`.
465/// `xdg_data_home` is honored only if absolute, per the XDG base-directory
466/// spec.
467pub fn default_storage_path(xdg_data_home: Option<PathBuf>, home: Option<PathBuf>) -> Result<Url> {
468    if let Some(xdg) = xdg_data_home.filter(|path| path.is_absolute()) {
469        return url_for_path(xdg.join("pond"));
470    }
471    if let Some(home) = home {
472        return url_for_path(home.join(".local").join("share").join("pond"));
473    }
474    // No HOME and no usable XDG var - stay usable rather than panic.
475    url_for_path(PathBuf::from(".pond"))
476}
477
478/// Cache dir for rebuildable artifacts (the search row meta map): the XDG-cache
479/// analog of [`default_storage_path`]. Separate root because the contents are
480/// regenerated from the store, not durable data.
481pub fn default_cache_path(xdg_cache_home: Option<PathBuf>, home: Option<PathBuf>) -> PathBuf {
482    if let Some(xdg) = xdg_cache_home.filter(|path| path.is_absolute()) {
483        return xdg.join("pond");
484    }
485    if let Some(home) = home {
486        return home.join(".cache").join("pond");
487    }
488    PathBuf::from(".pond-cache")
489}
490
491/// Local default path for `config.toml`. URI-backed data dirs always land
492/// here because the config file has to be local (it names the bucket and
493/// any creds). XDG hierarchy: `$XDG_CONFIG_HOME/pond/config.toml`, then
494/// `$HOME/.config/pond/config.toml`, then `.pond.toml` in cwd.
495pub fn default_config_path(xdg_config_home: Option<PathBuf>, home: Option<PathBuf>) -> PathBuf {
496    if let Some(xdg) = xdg_config_home.filter(|path| path.is_absolute()) {
497        return xdg.join("pond").join("config.toml");
498    }
499    if let Some(home) = home {
500        return home.join(".config").join("pond").join("config.toml");
501    }
502    PathBuf::from(".pond.toml")
503}
504
505impl Config {
506    /// Load `config.toml` from `path` (if it exists) layered under the
507    /// `POND_*` env mirror, and validate. A missing file yields the built-in
508    /// defaults - env vars alone are a complete config
509    /// (spec.md#storage-configless). On success the resolved embedding model
510    /// id + dim are installed into the process (`OnceLock`-backed; only the
511    /// first call per process sticks), so all downstream code paths see a
512    /// consistent pair without per-handler plumbing.
513    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
514        Ok(Self::load_with_provenance(path)?.0)
515    }
516
517    /// [`Config::load`] over an in-memory TOML body (still layered under the
518    /// `POND_*` env mirror). `pond init` uses this to validate and resolve
519    /// the config it is composing BEFORE anything touches disk - the wizard
520    /// writes exactly once, at the end.
521    pub fn load_str(body: &str) -> Result<Self> {
522        let figment = Figment::new().merge(Toml::string(body)).merge(env_mirror());
523        let config: Self = figment
524            .extract_lossy()
525            .map_err(|error| anyhow!("failed to load config: {error}"))?;
526        config.embeddings.validate()?;
527        config.validate_creds()?;
528        Ok(config)
529    }
530
531    /// [`Config::load`] that also returns the figment, so `pond config show`
532    /// can attribute each value to its source layer (file / env / default).
533    pub fn load_with_provenance(path: impl AsRef<Path>) -> Result<(Self, Figment)> {
534        let path = path.as_ref();
535        let figment = Figment::new().merge(Toml::file(path)).merge(env_mirror());
536        // `extract_lossy`, not `extract`: env values parse TOML-ishly, so an
537        // all-digit secret would arrive as a number and fail the String field;
538        // lossy stringifies scalars instead.
539        let config: Self = figment.extract_lossy().map_err(|error| {
540            if let Some(recipe) = detect_legacy_storage(path) {
541                return anyhow!("{recipe}");
542            }
543            if let Some(recipe) = detect_legacy_sources(path) {
544                return anyhow!("{recipe}");
545            }
546            // Inline figment's message (it already names the failing key and
547            // source layer) so single-line error surfaces keep the detail.
548            anyhow!("failed to load config {}: {error}", path.display())
549        })?;
550        config.embeddings.validate()?;
551        config.validate_creds()?;
552        config.embeddings.install_runtime();
553        // Tilde expansion is per-adapter (inside each factory's `open()`):
554        // an API-backed adapter has no path to expand, and only the
555        // filesystem-shaped adapters need the helper. See `expand_home_under`.
556        Ok((config, figment))
557    }
558
559    /// `[creds.*]` structural rules (spec.md#creds-scope-match): set-name
560    /// charset, at most one variant per logical secret, at most one
561    /// scope-less set, no duplicate scopes. All parse-time so a misbinding
562    /// dies before any URL resolves against it.
563    fn validate_creds(&self) -> Result<()> {
564        let mut scopeless: Option<&str> = None;
565        let mut scopes: BTreeMap<String, &str> = BTreeMap::new();
566        for (name, set) in &self.creds {
567            if !valid_creds_set_name(name) {
568                bail!(creds_set_name_error(name));
569            }
570            if set.access_key_id.is_some() && set.access_key_id_file.is_some() {
571                bail!("[creds.{name}] sets both access_key_id and access_key_id_file; pick one");
572            }
573            let secret_variants = [
574                set.secret_access_key.is_some(),
575                set.secret_access_key_file.is_some(),
576                set.secret_access_key_command.is_some(),
577            ]
578            .iter()
579            .filter(|present| **present)
580            .count();
581            if secret_variants > 1 {
582                bail!(
583                    "[creds.{name}] sets more than one of secret_access_key / secret_access_key_file / secret_access_key_command; pick one"
584                );
585            }
586            match set.scope.as_deref() {
587                None => {
588                    if let Some(other) = scopeless {
589                        bail!(
590                            "[creds.{other}] and [creds.{name}] are both scope-less; at most one catch-all set is allowed - add a `scope` to one"
591                        );
592                    }
593                    scopeless = Some(name);
594                }
595                Some(scope) => {
596                    // Duplicates are checked on the canonical form (incl.
597                    // trailing-slash trim, matching scope-match semantics),
598                    // so two spellings of one prefix can never tie at
599                    // resolve time.
600                    let canonical = crate::substrate::parse_scope(scope)
601                        .map(|url| url.as_str().trim_end_matches('/').to_owned())
602                        .with_context(|| {
603                            format!("[creds.{name}] scope {scope:?} is not a valid URL prefix")
604                        })?;
605                    if let Some(other) = scopes.insert(canonical, name) {
606                        bail!(
607                            "[creds.{other}] and [creds.{name}] declare the same scope {scope:?}; merge them or narrow one"
608                        );
609                    }
610                }
611            }
612        }
613        Ok(())
614    }
615
616    /// Resolve the `[adapters.<adapter>]` entries to drive `pond sync`. Only
617    /// sections with `enabled = true` flow through; sections with
618    /// `enabled = false` (or absent) are treated as opt-out and the
619    /// per-adapter blob (minus `enabled`) is handed to the factory's
620    /// `open()`. With `adapter = None` returns every enabled entry; with
621    /// `Some(name)` returns just that one - and errors if it's not in
622    /// config OR if it's currently disabled (the caller should then
623    /// re-prompt or report).
624    pub fn resolve_adapters(&self, adapter: Option<&str>) -> Result<Vec<(String, Value)>> {
625        match adapter {
626            None => Ok(self
627                .adapters
628                .iter()
629                .filter_map(|(name, blob)| take_enabled(name, blob))
630                .collect()),
631            Some(name) => {
632                let blob = self
633                    .adapters
634                    .get(name)
635                    .ok_or_else(|| anyhow!("no [adapters.{name}] entry in config"))?;
636                take_enabled(name, blob).map(|entry| vec![entry]).ok_or_else(|| {
637                    anyhow!(
638                        "adapter [{name}] is disabled (enabled = false); run `pond adapters enable {name}` to re-enable, then `pond sync {name}`"
639                    )
640                })
641            }
642        }
643    }
644
645    /// Names that are configured but currently `enabled = false`. Used by
646    /// `pond sync` post-import to know not to re-probe an adapter the user
647    /// already declined (the decline persists; re-prompt only via the
648    /// positional override `pond sync <name>`).
649    pub fn disabled_adapter_names(&self) -> Vec<&str> {
650        self.adapters
651            .iter()
652            .filter_map(|(name, blob)| {
653                let enabled = blob
654                    .get("enabled")
655                    .and_then(Value::as_bool)
656                    .unwrap_or(false);
657                if enabled { None } else { Some(name.as_str()) }
658            })
659            .collect()
660    }
661}
662
663/// The `POND_*` env mirror (spec.md#storage-env-mirror): `POND_STORAGE_PATH`
664/// -> `storage.path`, `POND_CREDS_<NAME>_<FIELD>` -> `creds.<name>.<field>`.
665/// Filtered to exactly those two shapes - clap owns its own `POND_*` vars
666/// (`POND_CONFIG_FILE`, `POND_HOST`, ...) and an unfiltered prefix would turn each
667/// of them into an unknown-field error here.
668fn env_mirror() -> Env {
669    // Keys reach these closures pre-lowercasing (`CREDS_...`), so compare on
670    // an ascii-lowered copy; `str::starts_with` is case-sensitive.
671    Env::prefixed("POND_")
672        .filter(|key| {
673            let key = key.as_str().to_ascii_lowercase();
674            // `extra` has no env form (spec.md#storage-env-mirror): the env
675            // grammar stays flat strings; structured options belong in the
676            // file (or URL query params).
677            key == "storage_path" || (key.starts_with("creds_") && !key.ends_with("_extra"))
678        })
679        .map(|key| {
680            // Set names are lowercase alphanumeric (validate_creds), so the
681            // first `_` after `creds` and the one after the name are the only
682            // separators; field names keep their underscores.
683            let key = key.as_str().to_ascii_lowercase();
684            let dots = if key.starts_with("creds_") { 2 } else { 1 };
685            key.replacen('_', ".", dots).into()
686        })
687}
688
689/// The pre-redesign `[storage]` passthrough keys, by role (ENV-style
690/// `object_store` aliases). Both the load-time error recipe
691/// (`detect_legacy_storage`) and the `pond init` rewrite read these, so the
692/// legacy vocabulary lives in one place - a new alias must not require
693/// editing two detectors in lockstep.
694pub const LEGACY_ENDPOINT_KEYS: &[&str] = &["aws_endpoint", "endpoint"];
695pub const LEGACY_ACCESS_KEY_KEYS: &[&str] = &["aws_access_key_id", "access_key_id"];
696pub const LEGACY_SECRET_KEY_KEYS: &[&str] = &["aws_secret_access_key", "secret_access_key"];
697pub const LEGACY_VIRTUAL_HOSTED_KEYS: &[&str] = &[
698    "aws_virtual_hosted_style_request",
699    "virtual_hosted_style_request",
700];
701
702/// Recognize the pre-redesign `[storage]` passthrough map (ENV-style
703/// `object_store` keys) and return the exact rewrite onto `[storage].path` +
704/// `[creds.default]`. An error with a recipe, not a shim: old configs do not
705/// keep working.
706fn detect_legacy_storage(path: &Path) -> Option<String> {
707    let text = std::fs::read_to_string(path).ok()?;
708    let value: toml::Value = toml::from_str(&text).ok()?;
709    let storage = value.get("storage")?.as_table()?;
710    if storage.is_empty() || storage.keys().all(|key| key == "path") {
711        return None;
712    }
713    let get = |names: &[&str]| {
714        storage.iter().find_map(|(key, value)| {
715            names
716                .iter()
717                .any(|name| key.eq_ignore_ascii_case(name))
718                .then(|| value.as_str().unwrap_or_default().to_owned())
719        })
720    };
721    let endpoint = get(LEGACY_ENDPOINT_KEYS);
722    let host = endpoint
723        .as_deref()
724        .and_then(|e| e.split("://").nth(1))
725        .unwrap_or("<endpoint-host>");
726    // Under the declared virtual-hosted style the endpoint host leads with
727    // the bucket; de-fold it, or following the recipe verbatim folds the
728    // bucket in twice (the new grammar re-applies virtual hosting).
729    let virtual_hosted = storage.iter().any(|(key, value)| {
730        LEGACY_VIRTUAL_HOSTED_KEYS
731            .iter()
732            .any(|name| key.eq_ignore_ascii_case(name))
733            && (value.as_bool().unwrap_or(false)
734                || value
735                    .as_str()
736                    .is_some_and(|text| text.eq_ignore_ascii_case("true") || text == "1"))
737    });
738    let path_recipe = match host.split_once('.') {
739        Some((bucket, rest)) if virtual_hosted && rest.contains('.') => {
740            format!("s3+https://{rest}/{bucket}/<prefix>")
741        }
742        _ => format!("s3+https://{host}/<bucket>/<prefix>"),
743    };
744    // spec.md#storage-redaction: never echo credential values, even back to
745    // their owner - stderr lands in logs, scrollback, and pasted bug reports.
746    let mut recipe = format!(
747        "config {} uses the old [storage] passthrough map; rewrite it as:\n\n[storage]\npath = \"{path_recipe}\"\n\n[creds.default]\n",
748        path.display(),
749    );
750    recipe.push_str("access_key_id     = \"...\"  # copy from the old [storage] section\n");
751    recipe.push_str("secret_access_key = \"...\"  # copy from the old [storage] section\n");
752    recipe.push_str(
753        "\n(the endpoint and bucket fold into the URL; allow_http is scheme-derived; virtual-hosted addressing defaults on; the region is autodetected - append ?region=<x> to the URL only if your store insists. `pond storage check` verifies the result end-to-end, and `pond init` can apply this rewrite for you)",
754    );
755    Some(recipe)
756}
757
758/// Recognize a pre-rename `[sources.<name>]` config block (the adapter map was
759/// renamed `sources` -> `adapters`) and return a one-line recipe pointing at
760/// `pond init`. An error with a recipe, not a shim: old configs do not silently
761/// keep working. Transitional - delete once live configs have migrated.
762fn detect_legacy_sources(path: &Path) -> Option<String> {
763    let text = std::fs::read_to_string(path).ok()?;
764    let value: toml::Value = toml::from_str(&text).ok()?;
765    value.get("sources")?.as_table()?;
766    Some(format!(
767        "config {} uses a [sources.*] block; the adapter map was renamed to [adapters.*]. Run `pond init` to migrate it, or rename each `[sources.<name>]` header to `[adapters.<name>]` by hand.",
768        path.display(),
769    ))
770}
771
772/// Inner helper: return `Some((name, blob))` when the adapter section is
773/// enabled, stripping the discriminator from the blob before handing it on;
774/// `None` when the section is missing `enabled` or has `enabled = false`.
775fn take_enabled(name: &str, blob: &Value) -> Option<(String, Value)> {
776    let enabled = blob
777        .get("enabled")
778        .and_then(Value::as_bool)
779        .unwrap_or(false);
780    if !enabled {
781        return None;
782    }
783    let mut clean = blob.clone();
784    if let Some(obj) = clean.as_object_mut() {
785        obj.remove("enabled");
786    }
787    Some((name.to_owned(), clean))
788}
789
790/// Expand `~` and `$VAR`/`${VAR}` in `path` against an explicit `home`.
791/// Filesystem-shaped adapters call this from inside their factory's `open()`.
792/// Tests use it directly to exercise the rule without mutating the
793/// process-wide `HOME` env var (`std::env::set_var` is `unsafe` under
794/// edition 2024 and pond forbids unsafe code). Unset vars and `~user` forms
795/// pass through unchanged - never guess.
796pub fn expand_home_under(path: &Path, home: &Path) -> PathBuf {
797    let Some(text) = path.to_str() else {
798        return path.to_path_buf();
799    };
800    let home_text = home.to_string_lossy();
801    let expanded = shellexpand::full_with_context_no_errors(
802        text,
803        || Some(home_text.clone()),
804        |var| std::env::var(var).ok(),
805    );
806    PathBuf::from(expanded.as_ref())
807}
808
809/// The inverse of [`expand_home_under`] for display and config writes:
810/// contract a `home` prefix back to `~` so user-facing surfaces (and the
811/// paths `pond init` persists) stay portable and readable. Non-home paths
812/// pass through unchanged.
813pub fn contract_home_under(path: &Path, home: &Path) -> PathBuf {
814    match path.strip_prefix(home) {
815        Ok(rest) if rest.as_os_str().is_empty() => PathBuf::from("~"),
816        Ok(rest) => Path::new("~").join(rest),
817        Err(_) => path.to_path_buf(),
818    }
819}
820
821/// [`contract_home_under`] against the process `HOME`. Returns the input
822/// rendered for humans; machine surfaces (JSON output, the wire) keep
823/// absolute paths.
824pub fn contract_home(path: &Path) -> PathBuf {
825    match std::env::var_os("HOME") {
826        Some(home) => contract_home_under(path, Path::new(&home)),
827        None => path.to_path_buf(),
828    }
829}
830
831impl EmbeddingsConfig {
832    /// Surface-level validation: model id non-empty and dim positive. The
833    /// dim/model mismatch is the load-time check inside `CandleEmbedder::load`,
834    /// which knows the model's `hidden_size`.
835    pub fn validate(&self) -> Result<()> {
836        if self.model.trim().is_empty() {
837            bail!("embeddings.model must be a non-empty HuggingFace model id");
838        }
839        if self.dim == 0 {
840            bail!("embeddings.dim must be positive; got {}", self.dim);
841        }
842        Ok(())
843    }
844
845    /// Install model id + dim into the process. Idempotent: only the first
846    /// call sticks (matches `OnceLock` semantics in `embed::init_model_id` and
847    /// `sessions::init_embedding_dim`).
848    pub fn install_runtime(&self) {
849        crate::embed::init_model_id(self.model.clone());
850        crate::sessions::init_embedding_dim(self.dim);
851    }
852}
853
854/// Write `config.toml` with owner-only perms (0600). The file can carry a
855/// plaintext `secret_access_key` (inline `[creds.*]`), so it must never be
856/// group/world-readable - matching the AWS CLI's 0600 on its credentials file.
857/// Unix only; Windows is out of v1 scope. Order is truncate -> chmod -> write,
858/// so the secret is only ever written once perms are already 0600, even when
859/// repairing a pre-existing 0644 file.
860pub fn write_config_file(path: &Path, contents: &str) -> Result<()> {
861    #[cfg(unix)]
862    {
863        use std::io::Write as _;
864        use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
865        let mut file = std::fs::OpenOptions::new()
866            .write(true)
867            .create(true)
868            .truncate(true)
869            .mode(0o600)
870            .open(path)
871            .with_context(|| format!("failed to write {}", path.display()))?;
872        // `.mode()` applies only on creation; chmod also repairs a pre-existing file.
873        file.set_permissions(std::fs::Permissions::from_mode(0o600))
874            .with_context(|| format!("failed to chmod 0600 {}", path.display()))?;
875        file.write_all(contents.as_bytes())
876            .with_context(|| format!("failed to write {}", path.display()))?;
877    }
878    #[cfg(not(unix))]
879    {
880        std::fs::write(path, contents)
881            .with_context(|| format!("failed to write {}", path.display()))?;
882    }
883    Ok(())
884}
885
886#[cfg(test)]
887mod tests {
888    // `result_large_err`: `figment::Jail` closures return `figment::Error`
889    // by contract; the size is figment's, not ours.
890    #![allow(clippy::expect_used, clippy::unwrap_used, clippy::result_large_err)]
891
892    use super::*;
893    use serde_json::Value;
894    use tempfile::TempDir;
895
896    #[test]
897    fn local_path_resolves_both_local_schemes() {
898        let plain = Url::parse("file:///tmp/pond-store").unwrap();
899        assert_eq!(local_path(&plain), Some(PathBuf::from("/tmp/pond-store")));
900        let uring = Url::parse("file+uring:///tmp/pond-store").unwrap();
901        assert_eq!(local_path(&uring), Some(PathBuf::from("/tmp/pond-store")));
902        assert_eq!(local_path(&Url::parse("s3://bucket/prefix").unwrap()), None);
903    }
904
905    #[cfg(unix)]
906    #[test]
907    fn write_config_file_is_owner_only_0600() {
908        use std::os::unix::fs::PermissionsExt;
909        let dir = TempDir::new().unwrap();
910        let path = dir.path().join("config.toml");
911        // A pre-existing world-readable file must be repaired, not left at 0644.
912        std::fs::write(&path, "old").unwrap();
913        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
914        write_config_file(&path, "[creds.default]\nsecret_access_key = \"x\"\n").unwrap();
915        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
916        assert_eq!(mode, 0o600, "config with secrets must be owner-only");
917        assert!(
918            std::fs::read_to_string(&path)
919                .unwrap()
920                .contains("secret_access_key")
921        );
922    }
923
924    #[test]
925    fn validate_catches_empty_model_and_bad_dim() {
926        assert!(EmbeddingsConfig::default().validate().is_ok());
927        // Empty / whitespace-only model id is rejected: HuggingFace fetch
928        // would fail far away from the config error.
929        let bad_model = EmbeddingsConfig {
930            model: "   ".to_owned(),
931            dim: 768,
932        };
933        assert!(bad_model.validate().is_err());
934        // Non-multiple-of-8 dims are accepted now: IVF_SQ has no subspace
935        // stride, so the old `dim % 8` requirement is gone.
936        let odd_dim = EmbeddingsConfig {
937            model: "intfloat/multilingual-e5-base".to_owned(),
938            dim: 100,
939        };
940        assert!(odd_dim.validate().is_ok());
941        // Zero is still rejected.
942        let zero_dim = EmbeddingsConfig {
943            model: "intfloat/multilingual-e5-base".to_owned(),
944            dim: 0,
945        };
946        assert!(zero_dim.validate().is_err());
947    }
948
949    // Every `Config::load` reads the process-global POND_* env mirror, so any
950    // test that calls it must hold the Jail lock - otherwise `env_mirror_layers_
951    // over_file`'s POND_CREDS_* vars leak in mid-load from a parallel thread and
952    // the load fails validation (two scope-less creds sets). Jail is the lock.
953    #[test]
954    fn config_load_missing_file_falls_back_to_builtin() {
955        figment::Jail::expect_with(|_jail| {
956            let config = Config::load("/nonexistent/pond-config-xyz.toml").unwrap();
957            assert_eq!(config.embeddings, EmbeddingsConfig::default());
958            Ok(())
959        });
960    }
961
962    #[test]
963    fn default_config_toml_loads_to_the_builtin_defaults() {
964        figment::Jail::expect_with(|jail| {
965            jail.create_file("config.toml", DEFAULT_CONFIG_TOML)?;
966            // The shipped template is all comments, so it must load and validate as
967            // the built-in defaults - a malformed template fails right here.
968            let config = Config::load("config.toml").unwrap();
969            assert_eq!(config.embeddings, EmbeddingsConfig::default());
970            assert_eq!(config.embeddings.model, crate::embed::DEFAULT_MODEL_ID);
971            assert_eq!(
972                config.embeddings.dim,
973                crate::sessions::DEFAULT_EMBEDDING_DIM
974            );
975            Ok(())
976        });
977    }
978
979    #[test]
980    fn default_storage_path_follows_xdg_then_home() {
981        // An absolute XDG_DATA_HOME wins.
982        let resolved =
983            default_storage_path(Some(PathBuf::from("/xdg")), Some(PathBuf::from("/home")))
984                .unwrap();
985        assert!(is_local(&resolved));
986        assert_eq!(local_path(&resolved).unwrap(), PathBuf::from("/xdg/pond"));
987
988        // A relative XDG_DATA_HOME is ignored per the XDG spec; HOME is the fallback.
989        let resolved = default_storage_path(
990            Some(PathBuf::from("relative")),
991            Some(PathBuf::from("/home")),
992        )
993        .unwrap();
994        assert_eq!(
995            local_path(&resolved).unwrap(),
996            PathBuf::from("/home/.local/share/pond"),
997        );
998
999        // No XDG and no HOME - stays usable: returns the cwd-anchored `.pond`.
1000        // The result is absolute (Lance's URL conversion requires it), so we
1001        // just check that the URL ends with the relative path's components.
1002        let resolved = default_storage_path(None, None).unwrap();
1003        assert!(is_local(&resolved));
1004        assert!(
1005            local_path(&resolved).unwrap().ends_with(".pond"),
1006            "fallback path should end with .pond: {resolved}",
1007        );
1008    }
1009
1010    #[test]
1011    fn expand_home_under_handles_tilde_forms() {
1012        let home = Path::new("/srv/me");
1013        assert_eq!(
1014            expand_home_under(Path::new("~"), home),
1015            PathBuf::from("/srv/me")
1016        );
1017        assert_eq!(
1018            expand_home_under(Path::new("~/.codex/sessions"), home),
1019            PathBuf::from("/srv/me/.codex/sessions"),
1020        );
1021        // Absolute paths pass through unchanged.
1022        assert_eq!(
1023            expand_home_under(Path::new("/etc/passwd"), home),
1024            PathBuf::from("/etc/passwd"),
1025        );
1026        // A leading `~something` (no slash) is not the home form - leave it.
1027        assert_eq!(
1028            expand_home_under(Path::new("~user/elsewhere"), home),
1029            PathBuf::from("~user/elsewhere"),
1030        );
1031    }
1032
1033    #[test]
1034    fn expand_home_under_handles_env_vars() {
1035        // Jail serializes env mutation against the other env-touching tests.
1036        figment::Jail::expect_with(|jail| {
1037            jail.set_env("POND_TEST_EXPAND_DIR", "/srv/data");
1038            let home = Path::new("/srv/me");
1039            assert_eq!(
1040                expand_home_under(Path::new("$POND_TEST_EXPAND_DIR/pond"), home),
1041                PathBuf::from("/srv/data/pond"),
1042            );
1043            assert_eq!(
1044                expand_home_under(Path::new("${POND_TEST_EXPAND_DIR}/pond"), home),
1045                PathBuf::from("/srv/data/pond"),
1046            );
1047            // Unset vars pass through unchanged - never guess.
1048            assert_eq!(
1049                expand_home_under(Path::new("$POND_TEST_UNSET_VAR/x"), home),
1050                PathBuf::from("$POND_TEST_UNSET_VAR/x"),
1051            );
1052            Ok(())
1053        });
1054    }
1055
1056    #[test]
1057    fn contract_home_under_inverts_expansion() {
1058        let home = Path::new("/srv/me");
1059        assert_eq!(
1060            contract_home_under(Path::new("/srv/me/.local/share/pond"), home),
1061            PathBuf::from("~/.local/share/pond"),
1062        );
1063        assert_eq!(
1064            contract_home_under(Path::new("/srv/me"), home),
1065            PathBuf::from("~")
1066        );
1067        // Non-home paths pass through unchanged.
1068        assert_eq!(
1069            contract_home_under(Path::new("/etc/passwd"), home),
1070            PathBuf::from("/etc/passwd"),
1071        );
1072    }
1073
1074    #[test]
1075    fn resolve_adapters_returns_one_or_all_or_errors() {
1076        figment::Jail::expect_with(|jail| {
1077            jail.create_file(
1078                "config.toml",
1079                "\
1080[adapters.claude-code]
1081enabled = true
1082path = \"/srv/claude\"
1083
1084[adapters.codex-cli]
1085enabled = true
1086path = \"/srv/codex\"
1087
1088[adapters.opencode]
1089enabled = false
1090",
1091            )?;
1092            let config = Config::load("config.toml").unwrap();
1093
1094            // None -> only enabled entries
1095            let all = config.resolve_adapters(None).unwrap();
1096            assert_eq!(all.len(), 2);
1097            let names: Vec<_> = all.iter().map(|(n, _)| n.as_str()).collect();
1098            assert!(names.contains(&"claude-code"));
1099            assert!(names.contains(&"codex-cli"));
1100            // The `enabled` discriminator never reaches the adapter blob.
1101            for (_, blob) in &all {
1102                assert!(blob.get("enabled").is_none(), "enabled should be stripped");
1103            }
1104
1105            // Some(name) -> one entry, opaque JSON blob
1106            let one = config.resolve_adapters(Some("codex-cli")).unwrap();
1107            assert_eq!(one.len(), 1);
1108            assert_eq!(one[0].0, "codex-cli");
1109            assert_eq!(
1110                one[0].1.get("path").and_then(Value::as_str),
1111                Some("/srv/codex"),
1112            );
1113
1114            // Disabled positional -> errors with the recovery hint baked in.
1115            let disabled = config.resolve_adapters(Some("opencode"));
1116            let err = disabled
1117                .expect_err("disabled adapter must error")
1118                .to_string();
1119            assert!(err.contains("enabled = false"), "got: {err}");
1120            assert!(err.contains("pond sync opencode"), "got: {err}");
1121
1122            // Unknown -> error
1123            assert!(config.resolve_adapters(Some("nope")).is_err());
1124
1125            // disabled_adapter_names lists exactly the off ones.
1126            assert_eq!(config.disabled_adapter_names(), vec!["opencode"]);
1127            Ok(())
1128        });
1129    }
1130
1131    #[test]
1132    fn memory_uri_is_classified_as_remote() {
1133        let url = Url::parse("memory:///pond-remote-test").expect("memory uri parses");
1134        assert!(
1135            !is_local(&url),
1136            "memory:// is not a local-filesystem URL: {url}",
1137        );
1138        assert!(
1139            local_path(&url).is_none(),
1140            "local_path must return None for non-file schemes",
1141        );
1142    }
1143
1144    // The storage/creds tests run inside `figment::Jail` even when they set
1145    // no env vars: the Jail-based env-mirror test mutates process-global env
1146    // mid-flight, and the Jail lock is what serializes them against it.
1147
1148    #[test]
1149    fn storage_and_creds_round_trip() {
1150        figment::Jail::expect_with(|jail| {
1151            jail.create_file(
1152                "config.toml",
1153                r#"
1154[storage]
1155path = "s3+https://nbg1.example.com/my-pond"
1156
1157[creds.default]
1158access_key_id     = "AKIA123"
1159secret_access_key = "shh"
1160
1161[creds.work]
1162scope             = "s3+https://fsn1.example.com/work-pond/"
1163access_key_id     = "AKIA456"
1164secret_access_key_command = "op read op://vault/pond/secret"
1165region            = "fsn1"
1166virtual_hosted_style_request = false
1167extra = { request_timeout = "60 seconds" }
1168"#,
1169            )?;
1170            let config = Config::load("config.toml").expect("config loads");
1171            assert_eq!(
1172                config.storage.path.as_deref(),
1173                Some("s3+https://nbg1.example.com/my-pond"),
1174            );
1175            assert_eq!(config.creds.len(), 2);
1176            let work = &config.creds["work"];
1177            assert_eq!(
1178                work.secret_access_key_command.as_deref(),
1179                Some("op read op://vault/pond/secret"),
1180            );
1181            assert_eq!(work.virtual_hosted_style_request, Some(false));
1182            assert_eq!(work.extra["request_timeout"], "60 seconds");
1183            Ok(())
1184        });
1185    }
1186
1187    #[test]
1188    fn creds_validators_reject_bad_shapes() {
1189        let cases: &[(&str, &str)] = &[
1190            // Unknown key dies loudly (typos must not silently no-op).
1191            ("[creds.a]\nacces_key_id = \"x\"\n", "acces_key_id"),
1192            // Name charset: separators break the env-mirror grammar.
1193            ("[creds.my_set]\naccess_key_id = \"x\"\n", "[a-z][a-z0-9]"),
1194            ("[creds.A1]\naccess_key_id = \"x\"\n", "[a-z][a-z0-9]"),
1195            // One variant per logical secret.
1196            (
1197                "[creds.a]\nsecret_access_key = \"x\"\nsecret_access_key_command = \"cat\"\n",
1198                "more than one",
1199            ),
1200            (
1201                "[creds.a]\naccess_key_id = \"x\"\naccess_key_id_file = \"/k\"\n",
1202                "pick one",
1203            ),
1204            // At most one scope-less set.
1205            (
1206                "[creds.a]\naccess_key_id = \"x\"\n[creds.b]\naccess_key_id = \"y\"\n",
1207                "scope-less",
1208            ),
1209            // Duplicate scopes can never tie-break - checked canonicalized,
1210            // so two spellings of one prefix still collide.
1211            (
1212                "[creds.a]\nscope = \"s3+https://h:443/b/\"\naccess_key_id = \"x\"\n[creds.b]\nscope = \"s3+https://h/b\"\naccess_key_id = \"y\"\n",
1213                "same scope",
1214            ),
1215        ];
1216        figment::Jail::expect_with(|jail| {
1217            for (body, needle) in cases {
1218                jail.create_file("config.toml", body)?;
1219                let err = Config::load("config.toml").expect_err(body).to_string();
1220                assert!(
1221                    err.contains(needle),
1222                    "want {needle:?} in error for {body:?}, got: {err}",
1223                );
1224            }
1225            Ok(())
1226        });
1227    }
1228
1229    #[test]
1230    fn valid_creds_set_name_matches_env_mirror_charset() {
1231        for ok in ["default", "work", "work2", "a", "abcdefghij123456"] {
1232            assert!(valid_creds_set_name(ok), "{ok:?} should be valid");
1233        }
1234        for bad in ["", "Work", "my_set", "2fast", "abcdefghij1234567", "set-1"] {
1235            assert!(!valid_creds_set_name(bad), "{bad:?} should be invalid");
1236        }
1237    }
1238
1239    #[test]
1240    fn legacy_storage_map_errors_with_the_rewrite_recipe() {
1241        figment::Jail::expect_with(|jail| {
1242            jail.create_file(
1243                "config.toml",
1244                r#"
1245[storage]
1246AWS_ACCESS_KEY_ID = "AKIA123"
1247AWS_SECRET_ACCESS_KEY = "shh"
1248AWS_REGION = "nbg1"
1249AWS_ENDPOINT = "https://ttq.nbg1.your-objectstorage.com"
1250aws_virtual_hosted_style_request = "true"
1251"#,
1252            )?;
1253            let err = Config::load("config.toml")
1254                .expect_err("legacy map must error")
1255                .to_string();
1256            // The error IS the migration: old keys mapped onto the new shape.
1257            assert!(err.contains("old [storage] passthrough map"), "got: {err}");
1258            // The declared virtual-hosted style pins the bucket as the leading
1259            // host label; the recipe must de-fold it, not repeat the folded
1260            // host (which the new grammar would fold again).
1261            assert!(
1262                err.contains("s3+https://nbg1.your-objectstorage.com/ttq/<prefix>"),
1263                "recipe must de-fold the virtual-hosted endpoint, got: {err}",
1264            );
1265            // spec.md#storage-redaction: the recipe must NOT echo the real
1266            // key values - placeholders plus a "copy from" pointer only.
1267            assert!(!err.contains("AKIA123"), "got: {err}");
1268            assert!(!err.contains("\"shh\""), "got: {err}");
1269            assert!(err.contains("access_key_id     = \"...\""), "got: {err}");
1270            // Region is autodetected (AWS) or defaulted (S3-compatible
1271            // endpoints ignore it): the recipe must not carry AWS_REGION
1272            // forward, only name the ?region= override.
1273            assert!(!err.contains("region            ="), "got: {err}");
1274            assert!(err.contains("?region="), "got: {err}");
1275            assert!(err.contains("pond storage check"), "got: {err}");
1276            // Without the addressing-style key the split is unknowable; the
1277            // recipe keeps the host verbatim with a <bucket> placeholder.
1278            jail.create_file(
1279                "config.toml",
1280                r#"
1281[storage]
1282AWS_ACCESS_KEY_ID = "AKIA123"
1283AWS_ENDPOINT = "https://ttq.nbg1.your-objectstorage.com"
1284"#,
1285            )?;
1286            let err = Config::load("config.toml")
1287                .expect_err("legacy map must error")
1288                .to_string();
1289            assert!(
1290                err.contains("s3+https://ttq.nbg1.your-objectstorage.com/<bucket>/<prefix>"),
1291                "got: {err}",
1292            );
1293            Ok(())
1294        });
1295    }
1296
1297    #[test]
1298    fn legacy_sources_block_errors_with_the_adapters_recipe() {
1299        figment::Jail::expect_with(|jail| {
1300            jail.create_file(
1301                "config.toml",
1302                "[sources.claude-code]\nenabled = true\npath = \"/srv/claude\"\n",
1303            )?;
1304            let err = Config::load("config.toml")
1305                .expect_err("legacy [sources.*] must error")
1306                .to_string();
1307            assert!(err.contains("[adapters.*]"), "names the new key: {err}");
1308            assert!(err.contains("pond init"), "points at the fix: {err}");
1309            Ok(())
1310        });
1311    }
1312
1313    #[test]
1314    fn env_mirror_layers_over_file() {
1315        figment::Jail::expect_with(|jail| {
1316            jail.create_file(
1317                "config.toml",
1318                r#"
1319[storage]
1320path = "/from-file"
1321
1322[creds.work]
1323scope         = "s3://file-bucket/"
1324access_key_id = "from-file"
1325region        = "file-region"
1326"#,
1327            )?;
1328            // Env beats file per field; untouched fields survive the merge.
1329            jail.set_env("POND_STORAGE_PATH", "/from-env");
1330            jail.set_env("POND_CREDS_WORK_ACCESS_KEY_ID", "from-env");
1331            // A purely-numeric env secret must stay a string (extract_lossy).
1332            jail.set_env("POND_CREDS_WORK_SECRET_ACCESS_KEY", "12345");
1333            // A set defined only in env is discovered by the prefix scan.
1334            jail.set_env("POND_CREDS_CI_ACCESS_KEY_ID", "ci-key");
1335            let config = Config::load("config.toml").expect("env+file config loads");
1336            assert_eq!(config.storage.path.as_deref(), Some("/from-env"));
1337            let work = &config.creds["work"];
1338            assert_eq!(work.access_key_id.as_deref(), Some("from-env"));
1339            assert_eq!(work.secret_access_key.as_deref(), Some("12345"));
1340            assert_eq!(work.region.as_deref(), Some("file-region"));
1341            assert_eq!(work.scope.as_deref(), Some("s3://file-bucket/"));
1342            assert_eq!(config.creds["ci"].access_key_id.as_deref(), Some("ci-key"));
1343            Ok(())
1344        });
1345    }
1346}