use std::fs;
use std::io::IsTerminal;
use std::path::Path;
use anyhow::{Context, Result};
use super::catalogue::{
build_specs, client_installed, client_name, project_path, serve_args, user_path, HomeDirs,
CLIENT_CATALOGUE,
};
use super::merge::{codex_section_name, merge_codex_toml, merge_entry};
use super::picker::{confirm, interactive_picker, picker_items, PickerOutcome};
use super::{
Action, ClientSpec, DiffPair, IdeReport, IdeStatus, IdeStep, IdeSummary, MergeStrategy, Scope,
};
use crate::cli::{ClientKind, IdeScope};
pub fn cmd_ide(
client: Option<ClientKind>,
scope: IdeScope,
yes: bool,
dry_run: bool,
no_watch: bool,
json: bool,
) -> Result<()> {
let interactive =
!yes && !dry_run && !json && client.is_none() && std::io::stdin().is_terminal();
let cwd = std::env::current_dir()?;
let homes = HomeDirs::detect();
let report = if interactive {
let items = picker_items(&cwd, &homes);
match interactive_picker(&items)? {
PickerOutcome::Cancelled => return Ok(()),
PickerOutcome::Selected(chosen) => {
run_ide_for_clients(&chosen, dry_run, no_watch, &cwd, &homes)?
}
}
} else {
run_ide(client, scope, interactive, dry_run, no_watch, &cwd, &homes)?
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", report.render_human());
print_next_steps(&report, dry_run);
}
if report.has_errors() {
std::process::exit(1);
}
Ok(())
}
pub(super) fn dedupe_preserving_order(
clients: Vec<ClientKind>,
) -> (Vec<ClientKind>, Vec<ClientKind>) {
let mut unique = Vec::with_capacity(clients.len());
let mut dropped = Vec::new();
for c in clients {
if unique.contains(&c) {
dropped.push(c);
} else {
unique.push(c);
}
}
(unique, dropped)
}
pub(super) fn filter_catalogue_by_clients(
clients: &[ClientKind],
scope: IdeScope,
) -> Vec<(ClientKind, Scope)> {
CLIENT_CATALOGUE
.iter()
.filter(|e| match scope {
IdeScope::Project => e.scope == Scope::Project,
IdeScope::User => e.scope == Scope::User,
IdeScope::All => true,
})
.filter(|e| clients.contains(&e.kind))
.map(|e| (e.kind, e.scope))
.collect()
}
pub fn cmd_install(
clients: Vec<ClientKind>,
scope: IdeScope,
dry_run: bool,
no_watch: bool,
json: bool,
) -> Result<()> {
let cwd = std::env::current_dir()?;
let homes = HomeDirs::detect();
let report = if clients.is_empty() {
run_ide(None, scope, false, dry_run, no_watch, &cwd, &homes)?
} else {
let (clients, duplicates) = dedupe_preserving_order(clients);
if !duplicates.is_empty() {
eprintln!(
"note: duplicate clients ignored: {}",
duplicates
.iter()
.map(|c| format!("{c:?}"))
.collect::<Vec<_>>()
.join(", ")
);
}
let chosen = filter_catalogue_by_clients(&clients, scope);
if chosen.is_empty() {
let requested: Vec<String> = clients.iter().map(|c| format!("{c:?}")).collect();
anyhow::bail!(
"no catalogue entries match clients={:?} at scope={:?}. \
Try --scope all, or pick clients available at this scope.",
requested,
scope,
);
}
run_ide_for_clients(&chosen, dry_run, no_watch, &cwd, &homes)?
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", report.render_human());
print_next_steps(&report, dry_run);
}
if report.has_errors() {
std::process::exit(1);
}
Ok(())
}
fn run_ide_for_clients(
chosen: &[(ClientKind, Scope)],
dry_run: bool,
no_watch: bool,
cwd: &Path,
homes: &HomeDirs,
) -> Result<IdeReport> {
let specs: Vec<ClientSpec> = chosen
.iter()
.filter_map(|&(kind, scope)| spec_for(kind, scope, no_watch, cwd, homes))
.collect();
let mut steps = Vec::with_capacity(specs.len());
let mut summary = IdeSummary::default();
for spec in specs {
let step = process_spec(&spec, cwd, false, dry_run, false);
summary.total += 1;
match step.status {
IdeStatus::Created => summary.created += 1,
IdeStatus::Updated => summary.updated += 1,
IdeStatus::Unchanged => summary.unchanged += 1,
IdeStatus::Skipped => summary.skipped += 1,
IdeStatus::Error => summary.error += 1,
}
steps.push(step);
}
Ok(IdeReport { steps, summary })
}
pub(super) fn spec_for(
kind: ClientKind,
scope: Scope,
no_watch: bool,
cwd: &Path,
homes: &HomeDirs,
) -> Option<ClientSpec> {
let entry = CLIENT_CATALOGUE
.iter()
.find(|e| e.kind == kind && e.scope == scope)?;
let path = match entry.scope {
Scope::Project => project_path(entry.kind, cwd)?,
Scope::User => user_path(entry.kind, homes)?,
};
Some(ClientSpec {
kind: entry.kind,
scope: entry.scope,
path,
strategy: entry.strategy,
args: serve_args(no_watch),
})
}
fn print_next_steps(report: &IdeReport, dry_run: bool) {
if dry_run {
println!("Dry run only. Re-run without --dry-run to apply.");
return;
}
let s = &report.summary;
if s.created + s.updated > 0 {
println!(
"Next: open your editor and try a cartog tool (e.g. `search`, `refs`). \
Re-run `cartog ide` after installing more editors."
);
} else if s.skipped > 0 && s.created + s.updated + s.unchanged == 0 {
println!(
"No MCP clients were configured. Install an MCP-aware editor \
(Claude Code, Cursor, Windsurf, Zed) and re-run `cartog ide`."
);
}
}
pub fn run_ide(
client: Option<ClientKind>,
scope: IdeScope,
interactive: bool,
dry_run: bool,
no_watch: bool,
cwd: &Path,
homes: &HomeDirs,
) -> Result<IdeReport> {
let specs = build_specs(client, scope, no_watch, cwd, homes);
if specs.is_empty() {
anyhow::bail!(
"no clients match the requested filter ({:?} + scope={:?}). \
Check `--client` and `--scope` are compatible \
(e.g. claude-code and cursor are project-scoped only).",
client,
scope,
);
}
let mut steps = Vec::with_capacity(specs.len());
let mut summary = IdeSummary::default();
let auto_only = client.is_none();
for spec in specs {
let step = process_spec(&spec, cwd, interactive, dry_run, auto_only);
match step.status {
IdeStatus::Created => summary.created += 1,
IdeStatus::Updated => summary.updated += 1,
IdeStatus::Unchanged => summary.unchanged += 1,
IdeStatus::Skipped => summary.skipped += 1,
IdeStatus::Error => summary.error += 1,
}
summary.total += 1;
steps.push(step);
}
Ok(IdeReport { steps, summary })
}
pub(super) fn process_spec(
spec: &ClientSpec,
cwd: &Path,
interactive: bool,
dry_run: bool,
auto_only: bool,
) -> IdeStep {
let client = client_name(spec.kind).to_string();
let scope = match spec.scope {
Scope::Project => "project",
Scope::User => "user",
}
.to_string();
let path = spec.path.display().to_string();
let make = |status: IdeStatus, message: String, diff: Option<DiffPair>| IdeStep {
client: client.clone(),
scope: scope.clone(),
path: path.clone(),
status,
message,
diff,
};
if auto_only
&& spec.scope == Scope::User
&& !client_installed(
spec.kind,
spec.scope,
&spec.path,
std::env::var_os("PATH").as_deref(),
)
{
return make(
IdeStatus::Skipped,
"client not detected (no CLI on PATH, no config directory)".into(),
None,
);
}
let existing = match fs::read_to_string(&spec.path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return make(
IdeStatus::Error,
format!("could not read existing config: {e}"),
None,
);
}
};
let merge_result = if spec.strategy == MergeStrategy::CodexToml {
let section = codex_section_name(cwd);
merge_codex_toml(existing.as_deref(), &spec.args, §ion)
} else {
merge_entry(existing.as_deref(), spec.strategy, &spec.args)
};
let outcome = match merge_result {
Ok(o) => o,
Err(e) => {
return make(
IdeStatus::Skipped,
format!("not modified ({e}); fix the file or remove it and re-run"),
None,
);
}
};
let write_action = match outcome.action {
Action::Unchanged => {
return make(IdeStatus::Unchanged, "already up to date".into(), None);
}
Action::Created => WriteAction::Create,
Action::Updated => WriteAction::Update,
};
let status = IdeStatus::from_action(outcome.action);
if interactive {
let prompt = format!("→ {client} ({path}): {}", write_action.planned());
match confirm(&prompt) {
Ok(false) => return make(IdeStatus::Skipped, "declined by user".into(), None),
Err(e) => {
return make(IdeStatus::Error, format!("prompt failed: {e}"), None);
}
Ok(true) => {}
}
}
if dry_run {
return make(
status,
write_action.planned().into(),
Some(DiffPair {
before: existing,
after: outcome.new_json,
}),
);
}
if let Err(e) = atomic_write(&spec.path, &outcome.new_json) {
return make(IdeStatus::Error, format!("write failed: {e}"), None);
}
make(status, write_action.done().into(), None)
}
#[derive(Clone, Copy)]
enum WriteAction {
Create,
Update,
}
impl WriteAction {
fn planned(self) -> &'static str {
match self {
WriteAction::Create => "would create",
WriteAction::Update => "would update",
}
}
fn done(self) -> &'static str {
match self {
WriteAction::Create => "created",
WriteAction::Update => "updated",
}
}
}
fn atomic_write(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)
.with_context(|| format!("could not create {}", parent.display()))?;
}
}
let pid = std::process::id();
let file_name = path
.file_name()
.map(|s| s.to_os_string())
.unwrap_or_default();
let mut tmp_name = file_name;
tmp_name.push(format!(".cartog-{pid}.tmp"));
let tmp = path.with_file_name(tmp_name);
fs::write(&tmp, contents).with_context(|| format!("could not write {}", tmp.display()))?;
fs::rename(&tmp, path)
.with_context(|| format!("could not move tmp file into {}", path.display()))?;
Ok(())
}