1use 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#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Candidate {
20 pub name: String,
21 pub hint: String,
22 pub config: Value,
23}
24
25pub 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
48pub 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
78fn 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
91pub 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
139pub 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#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct PromptOutcome {
168 pub candidate: Candidate,
169 pub enable: bool,
170 pub sync_now: bool,
171}
172
173pub 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
216pub 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
285fn 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 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 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 assert!(
388 body.contains("[sources.opencode]") && body.contains("enabled = false"),
389 "expected declined entry; got: {body}",
390 );
391 }
392}