pond/adapter/
discovery.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Candidate {
20 pub name: String,
21 pub hint: String,
22 pub config: Value,
23}
24
25pub 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
55fn 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
68pub 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
116fn 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
157fn 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 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}