use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect};
use super::catalogue::{
client_display_name, client_installed, project_path, user_path, HomeDirs, CLIENT_CATALOGUE,
};
use super::{Scope, CARTOG_LOGO};
use crate::cli::ClientKind;
#[derive(Debug, Clone)]
pub struct ScopeOption {
pub scope: Scope,
pub path: PathBuf,
pub installed: bool,
pub file_present: bool,
}
#[derive(Debug, Clone)]
pub struct PickerItem {
pub kind: ClientKind,
pub scopes: Vec<ScopeOption>,
}
impl PickerItem {
pub fn any_installed(&self) -> bool {
self.scopes.iter().any(|s| s.installed)
}
fn default_scope(&self) -> Scope {
self.scopes
.iter()
.find(|s| s.installed)
.map(|s| s.scope)
.unwrap_or(self.scopes[0].scope)
}
}
pub enum PickerOutcome {
Selected(Vec<(ClientKind, Scope)>),
Cancelled,
}
pub fn picker_items(cwd: &Path, homes: &HomeDirs) -> Vec<PickerItem> {
let path_env = std::env::var_os("PATH");
let mut by_kind: Vec<PickerItem> = Vec::new();
for entry in CLIENT_CATALOGUE.iter() {
let path = match entry.scope {
Scope::Project => match project_path(entry.kind, cwd) {
Some(p) => p,
None => continue,
},
Scope::User => match user_path(entry.kind, homes) {
Some(p) => p,
None => continue,
},
};
let installed = client_installed(entry.kind, entry.scope, &path, path_env.as_deref());
let opt = ScopeOption {
scope: entry.scope,
file_present: path.exists(),
installed,
path,
};
match by_kind.iter_mut().find(|i| i.kind == entry.kind) {
Some(existing) => existing.scopes.push(opt),
None => by_kind.push(PickerItem {
kind: entry.kind,
scopes: vec![opt],
}),
}
}
by_kind
}
pub fn format_picker_label(item: &PickerItem) -> String {
let scope_hint = if item.scopes.len() > 1 {
"project + user available".to_string()
} else {
match item.scopes[0].scope {
Scope::Project => "project".to_string(),
Scope::User => "user".to_string(),
}
};
let status = if item.scopes.len() == 1 {
scope_option_status(&item.scopes[0])
} else {
item.scopes
.iter()
.map(scope_option_status)
.find(|s| *s != "not installed")
.unwrap_or("not installed")
};
format!(
"{name:<16} {hint:<28} {status}",
name = client_display_name(item.kind),
hint = scope_hint,
status = status,
)
}
pub(super) fn scope_option_status(opt: &ScopeOption) -> &'static str {
match (opt.installed, opt.file_present) {
(false, _) => "not installed",
(true, true) => "present, will merge",
(true, false) => "will create",
}
}
fn home_relative(path: &Path) -> String {
if let Some(home) = directories::BaseDirs::new().map(|b| b.home_dir().to_path_buf()) {
if let Ok(rest) = path.strip_prefix(&home) {
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
pub(super) fn interactive_picker(items: &[PickerItem]) -> Result<PickerOutcome> {
eprintln!("{CARTOG_LOGO}");
if items.is_empty() {
eprintln!("No MCP-aware clients are known to cartog. Nothing to configure.");
return Ok(PickerOutcome::Cancelled);
}
let theme = ColorfulTheme::default();
let labels: Vec<String> = items.iter().map(format_picker_label).collect();
let defaults: Vec<bool> = items.iter().map(PickerItem::any_installed).collect();
let selection = MultiSelect::with_theme(&theme)
.with_prompt("Step 1 — Which clients to configure? (space to toggle, enter to confirm)")
.items(&labels)
.defaults(&defaults)
.interact_opt()
.context("interactive picker failed")?;
let Some(indices) = selection else {
return Ok(PickerOutcome::Cancelled);
};
if indices.is_empty() {
eprintln!("Nothing selected.");
return Ok(PickerOutcome::Cancelled);
}
let mut chosen: Vec<(ClientKind, Scope)> = Vec::with_capacity(indices.len());
for &i in &indices {
let item = &items[i];
let scope = if item.scopes.len() == 1 {
item.scopes[0].scope
} else {
prompt_scope(&theme, item)?
};
chosen.push((item.kind, scope));
}
eprintln!(
"\nStep 3 — Confirm. Will configure {} client(s):",
chosen.len()
);
for (kind, scope) in &chosen {
let item = items.iter().find(|i| i.kind == *kind).unwrap();
let opt = item.scopes.iter().find(|s| s.scope == *scope).unwrap();
eprintln!(
" · {name:<16} {scope:<8} {path}",
name = client_display_name(*kind),
scope = scope_label(*scope),
path = home_relative(&opt.path),
);
}
let confirmed = Confirm::with_theme(&theme)
.with_prompt("Apply?")
.default(true)
.interact_opt()
.context("confirmation failed")?
.unwrap_or(false);
if !confirmed {
eprintln!("Aborted, no files were modified.");
return Ok(PickerOutcome::Cancelled);
}
Ok(PickerOutcome::Selected(chosen))
}
fn prompt_scope(theme: &ColorfulTheme, item: &PickerItem) -> Result<Scope> {
use dialoguer::Select;
let options: Vec<String> = item
.scopes
.iter()
.map(|opt| {
format!(
"{scope:<8} {path} ({status})",
scope = scope_label(opt.scope),
path = home_relative(&opt.path),
status = scope_option_status(opt),
)
})
.collect();
let default_idx = item
.scopes
.iter()
.position(|s| s.scope == item.default_scope())
.unwrap_or(0);
let idx = Select::with_theme(theme)
.with_prompt(format!(
"Step 2 — Where should {} write its MCP entry?",
client_display_name(item.kind)
))
.items(&options)
.default(default_idx)
.interact()
.context("scope prompt failed")?;
Ok(item.scopes[idx].scope)
}
fn scope_label(scope: Scope) -> &'static str {
match scope {
Scope::Project => "project",
Scope::User => "user",
}
}
pub(super) fn confirm(label: &str) -> Result<bool> {
let mut stderr = std::io::stderr().lock();
write!(stderr, "{label}\n Apply? [Y/n] ")?;
stderr.flush()?;
drop(stderr);
let stdin = std::io::stdin();
let mut line = String::new();
let n = stdin.lock().read_line(&mut line)?;
if n == 0 {
return Ok(false);
}
let answer = line.trim().to_ascii_lowercase();
Ok(matches!(answer.as_str(), "" | "y" | "yes"))
}