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::{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 (or just the one named in `focus`) under
26/// the current environment and shape results into [`Candidate`]s for the
27/// picker. Returns an empty list when no factory's `probe_default` returned
28/// anything - the caller surfaces a "configure manually" error in that case.
29pub fn discover(focus: Option<&str>) -> Vec<Candidate> {
30    let Some(env) = Env::from_env() else {
31        return Vec::new();
32    };
33    let candidates: Vec<Candidate> = match focus {
34        None => probe_all(&env)
35            .into_iter()
36            .map(|(name, config)| Candidate {
37                name: name.to_owned(),
38                hint: hint_for(&config),
39                config,
40            })
41            .collect(),
42        Some(name) => by_name(name)
43            .and_then(|factory| factory.probe_default(&env))
44            .map(|config| Candidate {
45                name: name.to_owned(),
46                hint: hint_for(&config),
47                config,
48            })
49            .into_iter()
50            .collect(),
51    };
52    candidates
53}
54
55/// Best-effort label for the picker. For filesystem configs that's the
56/// `path`; for richer configs we fall back to a compact JSON dump so the
57/// operator at least sees what they're confirming.
58fn hint_for(config: &Value) -> String {
59    if let Some(path) = config.get("path").and_then(Value::as_str) {
60        return path.to_owned();
61    }
62    if let Some(endpoint) = config.get("endpoint").and_then(Value::as_str) {
63        return endpoint.to_owned();
64    }
65    serde_json::to_string(config).unwrap_or_default()
66}
67
68/// Prompt the operator to pick which `candidates` to register, then persist
69/// the chosen entries to `config.toml` and return them. Pre-checks every
70/// candidate (the operator already opted in by running `pond sync`). When
71/// `stdin_is_tty` is false we never prompt - we bail with a clear "configure
72/// manually" message so CI and post-install scripts get a predictable error
73/// instead of a hang. The caller injects TTY-ness so tests can drive the
74/// non-tty branch deterministically regardless of how `cargo test` is invoked.
75pub fn prompt_and_persist(
76    config_path: &Path,
77    candidates: &[Candidate],
78    stdin_is_tty: bool,
79) -> anyhow::Result<Vec<Candidate>> {
80    if candidates.is_empty() {
81        bail!(
82            "no adapter sources detected in this environment; known adapters: {}",
83            known_names().join(", "),
84        );
85    }
86    if !stdin_is_tty {
87        bail!(
88            "[sources] is empty and stdin is not a terminal; add a [sources.<adapter>] \
89             entry to {} (known adapters: {})",
90            config_path.display(),
91            known_names().join(", "),
92        );
93    }
94    let labels = candidates
95        .iter()
96        .map(|c| format!("{} ({})", c.name, c.hint))
97        .collect::<Vec<_>>();
98    let defaults = vec![true; candidates.len()];
99    let selections = MultiSelect::with_theme(&ColorfulTheme::default())
100        .with_prompt("Select sources to register (space toggles, enter confirms)")
101        .items(&labels)
102        .defaults(&defaults)
103        .interact()
104        .context("source picker prompt failed")?;
105    if selections.is_empty() {
106        bail!("no sources selected; nothing to sync");
107    }
108    let picks: Vec<Candidate> = selections
109        .into_iter()
110        .filter_map(|index| candidates.get(index).cloned())
111        .collect();
112    persist(config_path, &picks)?;
113    Ok(picks)
114}
115
116/// Write the picked sources back to `config.toml` under `[sources.<name>]`,
117/// preserving any existing user comments/formatting via `toml_edit`. Each
118/// pick's `config` JSON object is unpacked into TOML key/value pairs.
119fn persist(config_path: &Path, picks: &[Candidate]) -> anyhow::Result<()> {
120    if let Some(parent) = config_path.parent() {
121        std::fs::create_dir_all(parent)
122            .with_context(|| format!("failed to create config dir {}", parent.display()))?;
123    }
124    let existing = if config_path.exists() {
125        std::fs::read_to_string(config_path)
126            .with_context(|| format!("failed to read {}", config_path.display()))?
127    } else {
128        String::new()
129    };
130    let mut doc: DocumentMut = existing
131        .parse()
132        .with_context(|| format!("failed to parse {} as TOML", config_path.display()))?;
133
134    if !doc.contains_key("sources") {
135        let mut table = Table::new();
136        table.set_implicit(true);
137        doc.insert("sources", Item::Table(table));
138    }
139    let Some(sources) = doc["sources"].as_table_mut() else {
140        bail!("config.toml has a `sources` value that is not a table");
141    };
142    for pick in picks {
143        let entry = json_to_toml_table(&pick.config).with_context(|| {
144            format!(
145                "pick for {:?} did not produce a TOML-shaped table",
146                pick.name
147            )
148        })?;
149        sources.insert(&pick.name, Item::Table(entry));
150    }
151
152    std::fs::write(config_path, doc.to_string())
153        .with_context(|| format!("failed to write {}", config_path.display()))?;
154    Ok(())
155}
156
157/// Convert a JSON object into a `toml_edit::Table`. Factories produce JSON
158/// blobs (the seam contract); the picker persists them as TOML tables. Non-
159/// object roots are rejected with an error rather than silently dropped.
160fn json_to_toml_table(value: &Value) -> anyhow::Result<Table> {
161    let Value::Object(map) = value else {
162        bail!("config blob must be a JSON object, got {value}");
163    };
164    let mut table = Table::new();
165    for (key, val) in map {
166        table[key] = json_to_toml_item(val)?;
167    }
168    Ok(table)
169}
170
171fn json_to_toml_item(value: &Value) -> anyhow::Result<Item> {
172    use toml_edit::{Array, InlineTable, Value as TomlValue, value as tv};
173    Ok(match value {
174        Value::Null => bail!("null is not representable in TOML"),
175        Value::Bool(b) => tv(*b),
176        Value::Number(n) => {
177            if let Some(i) = n.as_i64() {
178                tv(i)
179            } else if let Some(f) = n.as_f64() {
180                tv(f)
181            } else {
182                bail!("number {n} is not representable in TOML");
183            }
184        }
185        Value::String(s) => tv(s.clone()),
186        Value::Array(values) => {
187            let mut array = Array::new();
188            for v in values {
189                let item = json_to_toml_item(v)?;
190                let toml_value: TomlValue = item.into_value().map_err(|_| {
191                    anyhow::anyhow!("array element {v} is not a scalar; nested tables in arrays")
192                })?;
193                array.push(toml_value);
194            }
195            Item::Value(TomlValue::Array(array))
196        }
197        Value::Object(_) => {
198            let table = json_to_toml_table(value)?;
199            let mut inline = InlineTable::new();
200            for (key, item) in table.iter() {
201                if let Some(v) = item.as_value() {
202                    inline.insert(key, v.clone());
203                }
204            }
205            Item::Value(TomlValue::InlineTable(inline))
206        }
207    })
208}
209
210#[cfg(test)]
211mod tests {
212    #![allow(clippy::expect_used, clippy::unwrap_used)]
213
214    use super::*;
215    use serde_json::json;
216    use tempfile::TempDir;
217
218    #[test]
219    fn prompt_and_persist_errors_on_non_tty_stdin() {
220        // Drive the non-tty branch explicitly. This is the path CI and
221        // package-install scripts hit; the picker must surface a clear
222        // "configure manually" error instead of hanging on a prompt.
223        let temp = TempDir::new().unwrap();
224        let config_path = temp.path().join("config.toml");
225        let candidates = vec![Candidate {
226            name: "claude-code".to_owned(),
227            hint: "/tmp/dummy".to_owned(),
228            config: json!({ "path": "/tmp/dummy" }),
229        }];
230        let err = prompt_and_persist(&config_path, &candidates, false)
231            .expect_err("non-tty stdin must error rather than hang");
232        let msg = err.to_string();
233        assert!(
234            msg.contains("not a terminal"),
235            "error should mention the non-tty branch: {msg}",
236        );
237    }
238}