1use 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
24fn 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
66fn 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
109pub fn is_local(url: &Url) -> bool {
113 matches!(url.scheme(), "file" | "file+uring")
114}
115
116pub fn local_path(url: &Url) -> Option<PathBuf> {
118 if !is_local(url) {
119 return None;
120 }
121 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
133pub fn child_uri(base: &Url, suffix: &str) -> String {
139 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
148pub 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
159pub 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
179pub 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#[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 #[serde(default)]
323 pub adapters: BTreeMap<String, Value>,
324 #[serde(default)]
327 pub storage: StorageConfig,
328 #[serde(default)]
332 pub creds: BTreeMap<String, CredsSet>,
333}
334
335#[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
350#[serde(deny_unknown_fields)]
351pub struct CredsSet {
352 #[serde(default)]
354 pub scope: Option<String>,
355 #[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
376pub 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
386pub 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#[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#[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
420#[serde(deny_unknown_fields)]
421pub struct MaintenanceConfig {
422 #[serde(default)]
425 pub compaction_fragment_cap: Option<usize>,
426 #[serde(default)]
431 pub cleanup_older_than: Option<String>,
432}
433
434#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
443#[serde(deny_unknown_fields, default)]
444pub struct EmbeddingsConfig {
445 pub model: String,
448 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
462pub 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 url_for_path(PathBuf::from(".pond"))
476}
477
478pub 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
491pub 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 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
514 Ok(Self::load_with_provenance(path)?.0)
515 }
516
517 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 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 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 anyhow!("failed to load config {}: {error}", path.display())
549 })?;
550 config.embeddings.validate()?;
551 config.validate_creds()?;
552 config.embeddings.install_runtime();
553 Ok((config, figment))
557 }
558
559 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 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 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 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
663fn env_mirror() -> Env {
669 Env::prefixed("POND_")
672 .filter(|key| {
673 let key = key.as_str().to_ascii_lowercase();
674 key == "storage_path" || (key.starts_with("creds_") && !key.ends_with("_extra"))
678 })
679 .map(|key| {
680 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
689pub 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
702fn 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 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 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
758fn 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
772fn 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
790pub 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
809pub 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
821pub 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 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 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
854pub 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 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 #![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 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 let bad_model = EmbeddingsConfig {
930 model: " ".to_owned(),
931 dim: 768,
932 };
933 assert!(bad_model.validate().is_err());
934 let odd_dim = EmbeddingsConfig {
937 model: "intfloat/multilingual-e5-base".to_owned(),
938 dim: 100,
939 };
940 assert!(odd_dim.validate().is_ok());
941 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 #[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 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 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 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 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 assert_eq!(
1023 expand_home_under(Path::new("/etc/passwd"), home),
1024 PathBuf::from("/etc/passwd"),
1025 );
1026 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 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 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 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 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 for (_, blob) in &all {
1102 assert!(blob.get("enabled").is_none(), "enabled should be stripped");
1103 }
1104
1105 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 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 assert!(config.resolve_adapters(Some("nope")).is_err());
1124
1125 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 #[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 ("[creds.a]\nacces_key_id = \"x\"\n", "acces_key_id"),
1192 ("[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 (
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 (
1206 "[creds.a]\naccess_key_id = \"x\"\n[creds.b]\naccess_key_id = \"y\"\n",
1207 "scope-less",
1208 ),
1209 (
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 assert!(err.contains("old [storage] passthrough map"), "got: {err}");
1258 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 assert!(!err.contains("AKIA123"), "got: {err}");
1268 assert!(!err.contains("\"shh\""), "got: {err}");
1269 assert!(err.contains("access_key_id = \"...\""), "got: {err}");
1270 assert!(!err.contains("region ="), "got: {err}");
1274 assert!(err.contains("?region="), "got: {err}");
1275 assert!(err.contains("pond storage check"), "got: {err}");
1276 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 jail.set_env("POND_STORAGE_PATH", "/from-env");
1330 jail.set_env("POND_CREDS_WORK_ACCESS_KEY_ID", "from-env");
1331 jail.set_env("POND_CREDS_WORK_SECRET_ACCESS_KEY", "12345");
1333 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}