Skip to main content

pond/adapter/
discovery.rs

1//! Source discovery and the interactive registration picker: probe the
2//! environment for configurable sources, prompt the operator, and persist the
3//! picks to `config.toml`. Config-time and interactive - kept off the
4//! sync-time seam in `mod.rs`.
5
6use std::path::Path;
7
8use anyhow::{Context, bail};
9use serde_json::Value;
10use toml_edit::{DocumentMut, Item, Table};
11
12use super::{Env, by_name, known_names, probe_all};
13
14/// One discovered adapter: its name, a hint to show the operator (typically
15/// the probed path or endpoint), and the JSON config blob that will be
16/// persisted under `[sources.<name>]` if the operator confirms.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Candidate {
19    pub name: String,
20    pub hint: String,
21    pub config: Value,
22}
23
24/// Probe every registered factory whose name is NOT already a key in
25/// `configured`. Used by `pond sync` to spot freshly-detectable adapters
26/// without re-prompting the operator about ones they already opted in or
27/// out of. Returns `Candidate`s in registry order.
28pub fn probe_unconfigured(
29    configured: &std::collections::BTreeMap<String, Value>,
30) -> Vec<Candidate> {
31    let Some(env) = Env::from_env() else {
32        return Vec::new();
33    };
34    super::registry()
35        .iter()
36        .filter(|factory| !configured.contains_key(factory.name()))
37        .filter_map(|factory| {
38            factory.probe_default(&env).map(|config| Candidate {
39                name: factory.name().to_owned(),
40                hint: hint_for(&config),
41                config,
42            })
43        })
44        .collect()
45}
46
47/// Probe every registered factory (or just the one named in `focus`) under
48/// the current environment and shape results into [`Candidate`]s for the
49/// picker. Returns an empty list when no factory's `probe_default` returned
50/// anything - the caller surfaces a "configure manually" error in that case.
51pub fn discover(focus: Option<&str>) -> Vec<Candidate> {
52    let Some(env) = Env::from_env() else {
53        return Vec::new();
54    };
55    let candidates: Vec<Candidate> = match focus {
56        None => probe_all(&env)
57            .into_iter()
58            .map(|(name, config)| Candidate {
59                name: name.to_owned(),
60                hint: hint_for(&config),
61                config,
62            })
63            .collect(),
64        Some(name) => by_name(name)
65            .and_then(|factory| factory.probe_default(&env))
66            .map(|config| Candidate {
67                name: name.to_owned(),
68                hint: hint_for(&config),
69                config,
70            })
71            .into_iter()
72            .collect(),
73    };
74    candidates
75}
76
77/// Best-effort label for the picker. For filesystem configs that's the
78/// `path` (with `$HOME` contracted to `~` for readability); for richer
79/// configs we fall back to a compact JSON dump so the operator at least sees
80/// what they're confirming.
81fn hint_for(config: &Value) -> String {
82    if let Some(path) = config.get("path").and_then(Value::as_str) {
83        return crate::config::contract_home(Path::new(path))
84            .display()
85            .to_string();
86    }
87    if let Some(endpoint) = config.get("endpoint").and_then(Value::as_str) {
88        return endpoint.to_owned();
89    }
90    serde_json::to_string(config).unwrap_or_default()
91}
92
93/// Prompt the operator to pick which `candidates` to register, then persist
94/// the chosen entries to `config.toml` and return them. Pre-checks every
95/// candidate (the operator already opted in by running `pond sync`). When
96/// `stdin_is_tty` is false we never prompt - we bail with a clear "configure
97/// manually" message so CI and post-install scripts get a predictable error
98/// instead of a hang. The caller injects TTY-ness so tests can drive the
99/// non-tty branch deterministically regardless of how `cargo test` is invoked.
100pub fn prompt_and_persist(
101    config_path: &Path,
102    candidates: &[Candidate],
103    stdin_is_tty: bool,
104) -> anyhow::Result<Vec<Candidate>> {
105    if candidates.is_empty() {
106        bail!(
107            "no adapter sources detected in this environment; known adapters: {}",
108            known_names().join(", "),
109        );
110    }
111    if !stdin_is_tty {
112        bail!(
113            "[sources] is empty and stdin is not a terminal; run `pond init --yes` to enable \
114             detected sources, or add a [sources.<adapter>] entry to {} (known adapters: {})",
115            config_path.display(),
116            known_names().join(", "),
117        );
118    }
119    let mut picker = cliclack::multiselect("Select sources to register")
120        .initial_values(candidates.iter().map(|c| c.name.clone()).collect());
121    for candidate in candidates {
122        picker = picker.item(candidate.name.clone(), &candidate.name, &candidate.hint);
123    }
124    let selected: Vec<String> = match picker.interact() {
125        Ok(picks) => picks,
126        // Esc / Ctrl-C in the picker means "register nothing", same outcome
127        // as an empty selection - not a failure.
128        Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {
129            bail!("no sources selected; nothing to sync");
130        }
131        Err(error) => return Err(error).context("source picker prompt failed"),
132    };
133    if selected.is_empty() {
134        bail!("no sources selected; nothing to sync");
135    }
136    let picks: Vec<Candidate> = candidates
137        .iter()
138        .filter(|candidate| selected.contains(&candidate.name))
139        .cloned()
140        .collect();
141    persist_accept(config_path, &picks)?;
142    Ok(picks)
143}
144
145/// Shape accepted and declined sources into a `toml_edit` document:
146/// `[sources.<name>]` with `enabled` as the first key, the accept's config
147/// blob unpacked into TOML pairs (home-prefixed `path` values contracted to
148/// `~/...` so the file stays portable), declines as `enabled = false` stubs.
149/// The one table-shaping routine shared by the sync picker writes and the
150/// `pond init` wizard's single end-of-run write.
151pub fn apply_to_doc(
152    doc: &mut DocumentMut,
153    accepts: &[Candidate],
154    declines: &[&str],
155) -> anyhow::Result<()> {
156    if accepts.is_empty() && declines.is_empty() {
157        return Ok(());
158    }
159    if !doc.contains_key("sources") {
160        let mut table = Table::new();
161        table.set_implicit(true);
162        doc.insert("sources", Item::Table(table));
163    }
164    let sources = sources_table_mut(doc)?;
165    for pick in accepts {
166        let mut entry = json_to_toml_table(&pick.config).with_context(|| {
167            format!(
168                "pick for {:?} did not produce a TOML-shaped table",
169                pick.name
170            )
171        })?;
172        contract_path_key(&mut entry);
173        prepend_enabled(&mut entry, true);
174        sources.insert(&pick.name, Item::Table(entry));
175    }
176    for name in declines {
177        let mut entry = Table::new();
178        prepend_enabled(&mut entry, false);
179        sources.insert(name, Item::Table(entry));
180    }
181    Ok(())
182}
183
184/// Contract a home-prefixed `path` value to `~/...` before it lands in
185/// config.toml. Reads (`expand_home_under` inside each factory's `open()`)
186/// expand the same prefix back, so the round-trip is lossless.
187fn contract_path_key(table: &mut Table) {
188    if let Some(path) = table.get("path").and_then(Item::as_str) {
189        let contracted = crate::config::contract_home(Path::new(path))
190            .display()
191            .to_string();
192        if contracted != path {
193            table["path"] = toml_edit::value(contracted);
194        }
195    }
196}
197
198/// Write the picked sources back to `config.toml` under `[sources.<name>]`,
199/// preserving any existing user comments/formatting via `toml_edit`.
200pub fn persist_accept(config_path: &Path, picks: &[Candidate]) -> anyhow::Result<()> {
201    let mut doc = open_or_init(config_path)?;
202    apply_to_doc(&mut doc, picks, &[])?;
203    std::fs::write(config_path, doc.to_string())
204        .with_context(|| format!("failed to write {}", config_path.display()))?;
205    Ok(())
206}
207
208/// Outcome of a per-adapter `Confirm` prompt during `pond sync`. `enable`
209/// is the answer to "should this adapter be enabled?"; `sync_now` is the
210/// follow-up "should we sync it this run?" (only meaningful when
211/// `enable == true`).
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct PromptOutcome {
214    pub candidate: Candidate,
215    pub enable: bool,
216    pub sync_now: bool,
217}
218
219/// Prompt the operator about each freshly-detected unconfigured adapter:
220/// "Enable X?" and, on accept, "Sync X now?". When `auto_accept` is true
221/// (the operator passed `--yes`) every prompt is skipped and answered
222/// yes/yes. Returns the per-adapter outcomes.
223pub fn prompt_each(
224    candidates: &[Candidate],
225    auto_accept: bool,
226) -> anyhow::Result<Vec<PromptOutcome>> {
227    let mut out = Vec::with_capacity(candidates.len());
228    for candidate in candidates {
229        let label = if candidate.hint.is_empty() {
230            candidate.name.clone()
231        } else {
232            format!("{} ({})", candidate.name, candidate.hint)
233        };
234        let (enable, sync_now) = if auto_accept {
235            (true, true)
236        } else {
237            let enable = cliclack::confirm(format!("Enable {label}?"))
238                .initial_value(true)
239                .interact()
240                .context("enable prompt failed")?;
241            let sync_now = if enable {
242                cliclack::confirm(format!("Sync {} now?", candidate.name))
243                    .initial_value(true)
244                    .interact()
245                    .context("sync-now prompt failed")?
246            } else {
247                false
248            };
249            (enable, sync_now)
250        };
251        out.push(PromptOutcome {
252            candidate: candidate.clone(),
253            enable,
254            sync_now,
255        });
256    }
257    Ok(out)
258}
259
260/// Persist `[sources.<name>] enabled = false` for adapters the operator
261/// declined during a probe prompt. Keeps the decline sticky across runs.
262pub fn persist_decline(config_path: &Path, names: &[&str]) -> anyhow::Result<()> {
263    if names.is_empty() {
264        return Ok(());
265    }
266    let mut doc = open_or_init(config_path)?;
267    apply_to_doc(&mut doc, &[], names)?;
268    std::fs::write(config_path, doc.to_string())
269        .with_context(|| format!("failed to write {}", config_path.display()))?;
270    Ok(())
271}
272
273fn open_or_init(config_path: &Path) -> anyhow::Result<DocumentMut> {
274    if let Some(parent) = config_path.parent() {
275        std::fs::create_dir_all(parent)
276            .with_context(|| format!("failed to create config dir {}", parent.display()))?;
277    }
278    let existing = if config_path.exists() {
279        std::fs::read_to_string(config_path)
280            .with_context(|| format!("failed to read {}", config_path.display()))?
281    } else {
282        String::new()
283    };
284    let mut doc: DocumentMut = existing
285        .parse()
286        .with_context(|| format!("failed to parse {} as TOML", config_path.display()))?;
287    if !doc.contains_key("sources") {
288        let mut table = Table::new();
289        table.set_implicit(true);
290        doc.insert("sources", Item::Table(table));
291    }
292    Ok(doc)
293}
294
295fn sources_table_mut(doc: &mut DocumentMut) -> anyhow::Result<&mut Table> {
296    doc["sources"]
297        .as_table_mut()
298        .ok_or_else(|| anyhow::anyhow!("config.toml has a `sources` value that is not a table"))
299}
300
301fn prepend_enabled(table: &mut Table, value: bool) {
302    use toml_edit::value as tv;
303    let implicit = table.is_implicit();
304    let keys: Vec<String> = table.iter().map(|(k, _)| k.to_owned()).collect();
305    let mut existing: Vec<(String, Item)> = Vec::with_capacity(keys.len());
306    for key in keys {
307        if key == "enabled" {
308            continue;
309        }
310        if let Some(item) = table.remove(&key) {
311            existing.push((key, item));
312        }
313    }
314    table.remove("enabled");
315    let mut fresh = Table::new();
316    fresh.set_implicit(implicit);
317    fresh.insert("enabled", tv(value));
318    for (key, item) in existing {
319        fresh.insert(&key, item);
320    }
321    *table = fresh;
322}
323
324/// Convert a JSON object into a `toml_edit::Table`. Factories produce JSON
325/// blobs (the seam contract); the picker persists them as TOML tables. Non-
326/// object roots are rejected with an error rather than silently dropped.
327fn json_to_toml_table(value: &Value) -> anyhow::Result<Table> {
328    let Value::Object(map) = value else {
329        bail!("config blob must be a JSON object, got {value}");
330    };
331    let mut table = Table::new();
332    for (key, val) in map {
333        table[key] = json_to_toml_item(val)?;
334    }
335    Ok(table)
336}
337
338fn json_to_toml_item(value: &Value) -> anyhow::Result<Item> {
339    use toml_edit::{Array, InlineTable, Value as TomlValue, value as tv};
340    Ok(match value {
341        Value::Null => bail!("null is not representable in TOML"),
342        Value::Bool(b) => tv(*b),
343        Value::Number(n) => {
344            if let Some(i) = n.as_i64() {
345                tv(i)
346            } else if let Some(f) = n.as_f64() {
347                tv(f)
348            } else {
349                bail!("number {n} is not representable in TOML");
350            }
351        }
352        Value::String(s) => tv(s.clone()),
353        Value::Array(values) => {
354            let mut array = Array::new();
355            for v in values {
356                let item = json_to_toml_item(v)?;
357                let toml_value: TomlValue = item.into_value().map_err(|_| {
358                    anyhow::anyhow!("array element {v} is not a scalar; nested tables in arrays")
359                })?;
360                array.push(toml_value);
361            }
362            Item::Value(TomlValue::Array(array))
363        }
364        Value::Object(_) => {
365            let table = json_to_toml_table(value)?;
366            let mut inline = InlineTable::new();
367            for (key, item) in table.iter() {
368                if let Some(v) = item.as_value() {
369                    inline.insert(key, v.clone());
370                }
371            }
372            Item::Value(TomlValue::InlineTable(inline))
373        }
374    })
375}
376
377#[cfg(test)]
378mod tests {
379    #![allow(clippy::expect_used, clippy::unwrap_used)]
380
381    use super::*;
382    use serde_json::json;
383    use tempfile::TempDir;
384
385    #[test]
386    fn prompt_and_persist_errors_on_non_tty_stdin() {
387        // Drive the non-tty branch explicitly. This is the path CI and
388        // package-install scripts hit; the picker must surface a clear
389        // "configure manually" error instead of hanging on a prompt.
390        let temp = TempDir::new().unwrap();
391        let config_path = temp.path().join("config.toml");
392        let candidates = vec![Candidate {
393            name: "claude-code".to_owned(),
394            hint: "/tmp/dummy".to_owned(),
395            config: json!({ "path": "/tmp/dummy" }),
396        }];
397        let err = prompt_and_persist(&config_path, &candidates, false)
398            .expect_err("non-tty stdin must error rather than hang");
399        let msg = err.to_string();
400        assert!(
401            msg.contains("not a terminal"),
402            "error should mention the non-tty branch: {msg}",
403        );
404    }
405
406    #[test]
407    fn persist_accept_and_decline_write_enabled_first() {
408        let temp = TempDir::new().unwrap();
409        let config_path = temp.path().join("config.toml");
410        let accept = Candidate {
411            name: "claude-code".to_owned(),
412            hint: "/tmp/cc".to_owned(),
413            config: json!({ "path": "/tmp/cc" }),
414        };
415        persist_accept(&config_path, &[accept]).unwrap();
416        persist_decline(&config_path, &["opencode"]).unwrap();
417        let body = std::fs::read_to_string(&config_path).unwrap();
418        // Accepted entry: discriminator on top, then the blob.
419        assert!(
420            body.contains("[sources.claude-code]")
421                && body.contains("enabled = true")
422                && body.contains("path = \"/tmp/cc\""),
423            "expected accepted entry; got: {body}",
424        );
425        // Declined entry: discriminator only, no path leak.
426        assert!(
427            body.contains("[sources.opencode]") && body.contains("enabled = false"),
428            "expected declined entry; got: {body}",
429        );
430    }
431}