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