use std::path::Path;
use anyhow::{Context, bail};
use serde_json::Value;
use toml_edit::{DocumentMut, Item, Table};
use super::{Env, by_name, known_names, probe_all};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
pub name: String,
pub hint: String,
pub config: Value,
}
pub fn probe_unconfigured(
configured: &std::collections::BTreeMap<String, Value>,
) -> Vec<Candidate> {
let Some(env) = Env::from_env() else {
return Vec::new();
};
super::registry()
.iter()
.filter(|factory| !configured.contains_key(factory.name()))
.filter_map(|factory| {
factory.probe_default(&env).map(|config| Candidate {
name: factory.name().to_owned(),
hint: hint_for(&config),
config,
})
})
.collect()
}
pub fn discover(focus: Option<&str>) -> Vec<Candidate> {
let Some(env) = Env::from_env() else {
return Vec::new();
};
let candidates: Vec<Candidate> = match focus {
None => probe_all(&env)
.into_iter()
.map(|(name, config)| Candidate {
name: name.to_owned(),
hint: hint_for(&config),
config,
})
.collect(),
Some(name) => by_name(name)
.and_then(|factory| factory.probe_default(&env))
.map(|config| Candidate {
name: name.to_owned(),
hint: hint_for(&config),
config,
})
.into_iter()
.collect(),
};
candidates
}
fn hint_for(config: &Value) -> String {
if let Some(path) = config.get("path").and_then(Value::as_str) {
return crate::config::contract_home(Path::new(path))
.display()
.to_string();
}
if let Some(endpoint) = config.get("endpoint").and_then(Value::as_str) {
return endpoint.to_owned();
}
serde_json::to_string(config).unwrap_or_default()
}
pub fn prompt_and_persist(
config_path: &Path,
candidates: &[Candidate],
stdin_is_tty: bool,
) -> anyhow::Result<Vec<Candidate>> {
if candidates.is_empty() {
bail!(
"no adapter sources detected in this environment; known adapters: {}",
known_names().join(", "),
);
}
if !stdin_is_tty {
bail!(
"[sources] is empty and stdin is not a terminal; run `pond init --yes` to enable \
detected sources, or add a [sources.<adapter>] entry to {} (known adapters: {})",
config_path.display(),
known_names().join(", "),
);
}
let mut picker = cliclack::multiselect("Select sources to register")
.initial_values(candidates.iter().map(|c| c.name.clone()).collect());
for candidate in candidates {
picker = picker.item(candidate.name.clone(), &candidate.name, &candidate.hint);
}
let selected: Vec<String> = match picker.interact() {
Ok(picks) => picks,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {
bail!("no sources selected; nothing to sync");
}
Err(error) => return Err(error).context("source picker prompt failed"),
};
if selected.is_empty() {
bail!("no sources selected; nothing to sync");
}
let picks: Vec<Candidate> = candidates
.iter()
.filter(|candidate| selected.contains(&candidate.name))
.cloned()
.collect();
persist_accept(config_path, &picks)?;
Ok(picks)
}
pub fn apply_to_doc(
doc: &mut DocumentMut,
accepts: &[Candidate],
declines: &[&str],
) -> anyhow::Result<()> {
if accepts.is_empty() && declines.is_empty() {
return Ok(());
}
if !doc.contains_key("sources") {
let mut table = Table::new();
table.set_implicit(true);
doc.insert("sources", Item::Table(table));
}
let sources = sources_table_mut(doc)?;
for pick in accepts {
let mut entry = json_to_toml_table(&pick.config).with_context(|| {
format!(
"pick for {:?} did not produce a TOML-shaped table",
pick.name
)
})?;
contract_path_key(&mut entry);
prepend_enabled(&mut entry, true);
sources.insert(&pick.name, Item::Table(entry));
}
for name in declines {
let mut entry = Table::new();
prepend_enabled(&mut entry, false);
sources.insert(name, Item::Table(entry));
}
Ok(())
}
fn contract_path_key(table: &mut Table) {
if let Some(path) = table.get("path").and_then(Item::as_str) {
let contracted = crate::config::contract_home(Path::new(path))
.display()
.to_string();
if contracted != path {
table["path"] = toml_edit::value(contracted);
}
}
}
pub fn persist_accept(config_path: &Path, picks: &[Candidate]) -> anyhow::Result<()> {
let mut doc = open_or_init(config_path)?;
apply_to_doc(&mut doc, picks, &[])?;
std::fs::write(config_path, doc.to_string())
.with_context(|| format!("failed to write {}", config_path.display()))?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptOutcome {
pub candidate: Candidate,
pub enable: bool,
pub sync_now: bool,
}
pub fn prompt_each(
candidates: &[Candidate],
auto_accept: bool,
) -> anyhow::Result<Vec<PromptOutcome>> {
let mut out = Vec::with_capacity(candidates.len());
for candidate in candidates {
let label = if candidate.hint.is_empty() {
candidate.name.clone()
} else {
format!("{} ({})", candidate.name, candidate.hint)
};
let (enable, sync_now) = if auto_accept {
(true, true)
} else {
let enable = cliclack::confirm(format!("Enable {label}?"))
.initial_value(true)
.interact()
.context("enable prompt failed")?;
let sync_now = if enable {
cliclack::confirm(format!("Sync {} now?", candidate.name))
.initial_value(true)
.interact()
.context("sync-now prompt failed")?
} else {
false
};
(enable, sync_now)
};
out.push(PromptOutcome {
candidate: candidate.clone(),
enable,
sync_now,
});
}
Ok(out)
}
pub fn persist_decline(config_path: &Path, names: &[&str]) -> anyhow::Result<()> {
if names.is_empty() {
return Ok(());
}
let mut doc = open_or_init(config_path)?;
apply_to_doc(&mut doc, &[], names)?;
std::fs::write(config_path, doc.to_string())
.with_context(|| format!("failed to write {}", config_path.display()))?;
Ok(())
}
fn open_or_init(config_path: &Path) -> anyhow::Result<DocumentMut> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
}
let existing = if config_path.exists() {
std::fs::read_to_string(config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?
} else {
String::new()
};
let mut doc: DocumentMut = existing
.parse()
.with_context(|| format!("failed to parse {} as TOML", config_path.display()))?;
if !doc.contains_key("sources") {
let mut table = Table::new();
table.set_implicit(true);
doc.insert("sources", Item::Table(table));
}
Ok(doc)
}
fn sources_table_mut(doc: &mut DocumentMut) -> anyhow::Result<&mut Table> {
doc["sources"]
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("config.toml has a `sources` value that is not a table"))
}
fn prepend_enabled(table: &mut Table, value: bool) {
use toml_edit::value as tv;
let implicit = table.is_implicit();
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_owned()).collect();
let mut existing: Vec<(String, Item)> = Vec::with_capacity(keys.len());
for key in keys {
if key == "enabled" {
continue;
}
if let Some(item) = table.remove(&key) {
existing.push((key, item));
}
}
table.remove("enabled");
let mut fresh = Table::new();
fresh.set_implicit(implicit);
fresh.insert("enabled", tv(value));
for (key, item) in existing {
fresh.insert(&key, item);
}
*table = fresh;
}
fn json_to_toml_table(value: &Value) -> anyhow::Result<Table> {
let Value::Object(map) = value else {
bail!("config blob must be a JSON object, got {value}");
};
let mut table = Table::new();
for (key, val) in map {
table[key] = json_to_toml_item(val)?;
}
Ok(table)
}
fn json_to_toml_item(value: &Value) -> anyhow::Result<Item> {
use toml_edit::{Array, InlineTable, Value as TomlValue, value as tv};
Ok(match value {
Value::Null => bail!("null is not representable in TOML"),
Value::Bool(b) => tv(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
tv(i)
} else if let Some(f) = n.as_f64() {
tv(f)
} else {
bail!("number {n} is not representable in TOML");
}
}
Value::String(s) => tv(s.clone()),
Value::Array(values) => {
let mut array = Array::new();
for v in values {
let item = json_to_toml_item(v)?;
let toml_value: TomlValue = item.into_value().map_err(|_| {
anyhow::anyhow!("array element {v} is not a scalar; nested tables in arrays")
})?;
array.push(toml_value);
}
Item::Value(TomlValue::Array(array))
}
Value::Object(_) => {
let table = json_to_toml_table(value)?;
let mut inline = InlineTable::new();
for (key, item) in table.iter() {
if let Some(v) = item.as_value() {
inline.insert(key, v.clone());
}
}
Item::Value(TomlValue::InlineTable(inline))
}
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn prompt_and_persist_errors_on_non_tty_stdin() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("config.toml");
let candidates = vec![Candidate {
name: "claude-code".to_owned(),
hint: "/tmp/dummy".to_owned(),
config: json!({ "path": "/tmp/dummy" }),
}];
let err = prompt_and_persist(&config_path, &candidates, false)
.expect_err("non-tty stdin must error rather than hang");
let msg = err.to_string();
assert!(
msg.contains("not a terminal"),
"error should mention the non-tty branch: {msg}",
);
}
#[test]
fn persist_accept_and_decline_write_enabled_first() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("config.toml");
let accept = Candidate {
name: "claude-code".to_owned(),
hint: "/tmp/cc".to_owned(),
config: json!({ "path": "/tmp/cc" }),
};
persist_accept(&config_path, &[accept]).unwrap();
persist_decline(&config_path, &["opencode"]).unwrap();
let body = std::fs::read_to_string(&config_path).unwrap();
assert!(
body.contains("[sources.claude-code]")
&& body.contains("enabled = true")
&& body.contains("path = \"/tmp/cc\""),
"expected accepted entry; got: {body}",
);
assert!(
body.contains("[sources.opencode]") && body.contains("enabled = false"),
"expected declined entry; got: {body}",
);
}
}