1use 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#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Candidate {
19 pub name: String,
20 pub hint: String,
21 pub config: Value,
22}
23
24pub 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
47pub 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
77fn 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
93pub 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 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
145pub 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
184fn 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
198pub 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#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct PromptOutcome {
214 pub candidate: Candidate,
215 pub enable: bool,
216 pub sync_now: bool,
217}
218
219pub 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
260pub 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
324fn 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 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 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 assert!(
427 body.contains("[sources.opencode]") && body.contains("enabled = false"),
428 "expected declined entry; got: {body}",
429 );
430 }
431}