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