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# 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#[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 #[serde(default)]
333 pub adapters: BTreeMap<String, Value>,
334 #[serde(default)]
337 pub storage: StorageConfig,
338 #[serde(default)]
342 pub creds: BTreeMap<String, CredsSet>,
343}
344
345#[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
360#[serde(deny_unknown_fields)]
361pub struct CredsSet {
362 #[serde(default)]
364 pub scope: Option<String>,
365 #[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
386pub 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
396pub 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#[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#[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
430#[serde(deny_unknown_fields)]
431pub struct MaintenanceConfig {
432 #[serde(default)]
435 pub compaction_fragment_cap: Option<usize>,
436 #[serde(default)]
441 pub cleanup_older_than: Option<String>,
442}
443
444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453#[serde(deny_unknown_fields, default)]
454pub struct EmbeddingsConfig {
455 pub model: String,
458 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
472pub 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 url_for_path(PathBuf::from(".pond"))
486}
487
488pub 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
501pub 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 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
524 Ok(Self::load_with_provenance(path)?.0)
525 }
526
527 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 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 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 anyhow!("failed to load config {}: {error}", path.display())
559 })?;
560 config.embeddings.validate()?;
561 config.validate_creds()?;
562 config.embeddings.install_runtime();
563 Ok((config, figment))
567 }
568
569 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 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 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 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
673fn env_mirror() -> Env {
679 Env::prefixed("POND_")
682 .filter(|key| {
683 let key = key.as_str().to_ascii_lowercase();
684 key == "storage_path" || (key.starts_with("creds_") && !key.ends_with("_extra"))
688 })
689 .map(|key| {
690 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
699pub 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
712fn 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 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 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
768fn 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
782fn 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
800pub 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
819pub 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
831pub 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 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 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
864pub 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 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 #![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 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 let bad_model = EmbeddingsConfig {
940 model: " ".to_owned(),
941 dim: 768,
942 };
943 assert!(bad_model.validate().is_err());
944 let odd_dim = EmbeddingsConfig {
947 model: "intfloat/multilingual-e5-base".to_owned(),
948 dim: 100,
949 };
950 assert!(odd_dim.validate().is_ok());
951 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 #[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 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 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 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 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 assert_eq!(
1033 expand_home_under(Path::new("/etc/passwd"), home),
1034 PathBuf::from("/etc/passwd"),
1035 );
1036 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 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 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 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 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 for (_, blob) in &all {
1112 assert!(blob.get("enabled").is_none(), "enabled should be stripped");
1113 }
1114
1115 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 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 assert!(config.resolve_adapters(Some("nope")).is_err());
1134
1135 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 #[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 ("[creds.a]\nacces_key_id = \"x\"\n", "acces_key_id"),
1202 ("[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 (
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 (
1216 "[creds.a]\naccess_key_id = \"x\"\n[creds.b]\naccess_key_id = \"y\"\n",
1217 "scope-less",
1218 ),
1219 (
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 assert!(err.contains("old [storage] passthrough map"), "got: {err}");
1268 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 assert!(!err.contains("AKIA123"), "got: {err}");
1278 assert!(!err.contains("\"shh\""), "got: {err}");
1279 assert!(err.contains("access_key_id = \"...\""), "got: {err}");
1280 assert!(!err.contains("region ="), "got: {err}");
1284 assert!(err.contains("?region="), "got: {err}");
1285 assert!(err.contains("pond storage check"), "got: {err}");
1286 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 jail.set_env("POND_STORAGE_PATH", "/from-env");
1340 jail.set_env("POND_CREDS_WORK_ACCESS_KEY_ID", "from-env");
1341 jail.set_env("POND_CREDS_WORK_SECRET_ACCESS_KEY", "12345");
1343 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}