1mod detect;
8mod mcp_config;
9mod skill;
10
11use crate::error::SetupError;
12use detect::DetectedAgent;
13use dialoguer::theme::ColorfulTheme;
14use dialoguer::{Confirm, MultiSelect, Select};
15use owo_colors::{OwoColorize, Stream};
16use std::io::IsTerminal;
17use std::process::{Command, Stdio};
18
19enum Mode {
21 Mcp,
23 CliSkills,
25}
26
27struct InstalledFile {
29 path: String,
30 kind: &'static str,
31}
32
33pub fn run_setup() -> Result<(), SetupError> {
40 if !std::io::stderr().is_terminal() {
41 return Err(SetupError::NotInteractive(
42 "`rust-doctor setup` requires an interactive terminal.\n\
43 Hint: run this command directly in your shell, not via a script or pipe."
44 .to_string(),
45 ));
46 }
47
48 print_banner();
49
50 let mode = select_mode()?;
52
53 let agents = detect::detect_agents();
55 if agents.is_empty() {
56 eprintln!(
57 "\n{}",
58 "No supported AI agents detected on this system."
59 .if_supports_color(Stream::Stderr, |t| t.yellow())
60 );
61 eprintln!("Supported agents: Claude Code, Cursor, Windsurf");
62 eprintln!("Install one of these and run `rust-doctor setup` again.");
63 return Ok(());
64 }
65
66 eprintln!(
67 "\n Detected {} agent(s):\n",
68 agents.len().if_supports_color(Stream::Stderr, |t| t.bold())
69 );
70 for agent in &agents {
71 let status = if agent.mcp_already_configured {
72 " (MCP already configured)"
73 } else {
74 ""
75 };
76 eprintln!(
77 " {} {} — {}{}",
78 "✓".if_supports_color(Stream::Stderr, |t| t.green()),
79 agent.name.if_supports_color(Stream::Stderr, |t| t.bold()),
80 agent
81 .description
82 .if_supports_color(Stream::Stderr, |t| t.dimmed()),
83 status.if_supports_color(Stream::Stderr, |t| t.dimmed()),
84 );
85 }
86 eprintln!();
87
88 let selected = select_agents(&agents, &mode)?;
90 if selected.is_empty() {
91 eprintln!("No agents selected. Exiting.");
92 return Ok(());
93 }
94
95 let agent_names: Vec<&str> = selected.iter().map(|a| a.name).collect();
97 let mode_label = match mode {
98 Mode::Mcp => "MCP server",
99 Mode::CliSkills => "CLI + Skills",
100 };
101 eprintln!(
102 "\n Will install {} for: {}",
103 mode_label.if_supports_color(Stream::Stderr, |t| t.bold()),
104 agent_names
105 .join(", ")
106 .if_supports_color(Stream::Stderr, |t| t.cyan())
107 );
108
109 if !Confirm::with_theme(&ColorfulTheme::default())
110 .with_prompt(" Proceed?")
111 .default(true)
112 .interact()?
113 {
114 eprintln!("Cancelled.");
115 return Ok(());
116 }
117
118 eprintln!();
119
120 let installed = match mode {
122 Mode::Mcp => install_mcp(&selected),
123 Mode::CliSkills => install_skills(&selected),
124 };
125
126 print_recap(&installed, &mode);
128
129 Ok(())
130}
131
132fn print_banner() {
133 eprintln!(
134 "\n {}",
135 "rust-doctor setup".if_supports_color(Stream::Stderr, |t| t.bold())
136 );
137 eprintln!(
138 " {}",
139 "Configure rust-doctor for your AI coding agent"
140 .if_supports_color(Stream::Stderr, |t| t.dimmed())
141 );
142}
143
144fn select_mode() -> Result<Mode, dialoguer::Error> {
145 let items = &[
146 "CLI + Skills \u{2014} Installs a skill file that guides your agent to use the CLI (recommended)",
147 "MCP Server \u{2014} Agent calls rust-doctor tools via MCP protocol",
148 ];
149
150 let selection = Select::with_theme(&ColorfulTheme::default())
151 .with_prompt(" How should your agent access rust-doctor?")
152 .items(items)
153 .default(0)
154 .interact()?;
155
156 Ok(if selection == 0 {
157 Mode::CliSkills
158 } else {
159 Mode::Mcp
160 })
161}
162
163fn select_agents<'a>(
164 agents: &'a [DetectedAgent],
165 mode: &Mode,
166) -> Result<Vec<&'a DetectedAgent>, dialoguer::Error> {
167 if agents.len() == 1 {
169 return Ok(agents.iter().collect());
170 }
171
172 let scope_items = &[
174 format!("All detected agents ({})", agents.len()),
175 "Select specific agents...".to_string(),
176 ];
177
178 let scope = Select::with_theme(&ColorfulTheme::default())
179 .with_prompt(" Install for which agents?")
180 .items(scope_items)
181 .default(0)
182 .interact()?;
183
184 if scope == 0 {
185 return Ok(agents.iter().collect());
186 }
187
188 let labels: Vec<String> = agents
190 .iter()
191 .map(|a| {
192 let status = match mode {
193 Mode::Mcp if a.mcp_already_configured => " (will overwrite)",
194 Mode::CliSkills if a.skill_already_installed => " (will overwrite)",
195 _ => "",
196 };
197 format!("{} \u{2014} {}{status}", a.name, a.description)
198 })
199 .collect();
200
201 let selections = MultiSelect::with_theme(&ColorfulTheme::default())
202 .with_prompt(" Select agents (space to toggle, enter to confirm)")
203 .items(&labels)
204 .interact()?;
205
206 Ok(selections
207 .into_iter()
208 .filter_map(|i| agents.get(i))
209 .collect())
210}
211
212fn install_mcp(agents: &[&DetectedAgent]) -> Vec<InstalledFile> {
213 let (cmd, args) = detect_command();
214 let mut installed = Vec::new();
215
216 for agent in agents {
217 if agent.mcp_already_configured {
218 eprintln!(
219 " {} already has rust-doctor MCP configured.",
220 agent.name.if_supports_color(Stream::Stderr, |t| t.cyan())
221 );
222 let replace = Confirm::with_theme(&ColorfulTheme::default())
223 .with_prompt(" Replace with new configuration? (recommended)")
224 .default(true)
225 .interact()
226 .unwrap_or(false);
227 if !replace {
228 eprintln!(" Skipped.");
229 continue;
230 }
231 }
232
233 eprint!(
234 " Configuring {} ... ",
235 agent.name.if_supports_color(Stream::Stderr, |t| t.cyan())
236 );
237
238 match mcp_config::write_mcp_config(&agent.mcp_config_path, &cmd, &args) {
239 Ok(()) => {
240 eprintln!(
241 "{}",
242 "done".if_supports_color(Stream::Stderr, |t| t.green())
243 );
244 installed.push(InstalledFile {
245 path: agent.mcp_config_path.display().to_string(),
246 kind: "MCP config",
247 });
248 }
249 Err(e) => {
250 eprintln!(
251 "{}",
252 format!("failed: {e}").if_supports_color(Stream::Stderr, |t| t.red())
253 );
254 }
255 }
256 }
257
258 installed
259}
260
261fn install_skills(agents: &[&DetectedAgent]) -> Vec<InstalledFile> {
262 let mut installed = Vec::new();
263
264 for agent in agents {
265 let Some(ref skills_dir) = agent.skills_dir else {
266 eprintln!(
267 " {} \u{2014} {}",
268 agent.name.if_supports_color(Stream::Stderr, |t| t.cyan()),
269 "no skills support, skipping".if_supports_color(Stream::Stderr, |t| t.yellow())
270 );
271 continue;
272 };
273
274 if agent.skill_already_installed {
275 eprintln!(
276 " {} already has the rust-doctor skill installed.",
277 agent.name.if_supports_color(Stream::Stderr, |t| t.cyan())
278 );
279 let replace = Confirm::with_theme(&ColorfulTheme::default())
280 .with_prompt(" Replace with latest version? (recommended)")
281 .default(true)
282 .interact()
283 .unwrap_or(false);
284 if !replace {
285 eprintln!(" Skipped.");
286 continue;
287 }
288 }
289
290 eprint!(
291 " Installing skill for {} ... ",
292 agent.name.if_supports_color(Stream::Stderr, |t| t.cyan())
293 );
294
295 match skill::write_skill(skills_dir) {
296 Ok(path) => {
297 eprintln!(
298 "{}",
299 "done".if_supports_color(Stream::Stderr, |t| t.green())
300 );
301 installed.push(InstalledFile {
302 path: path.display().to_string(),
303 kind: "Skill file",
304 });
305 }
306 Err(e) => {
307 eprintln!(
308 "{}",
309 format!("failed: {e}").if_supports_color(Stream::Stderr, |t| t.red())
310 );
311 }
312 }
313 }
314
315 installed
316}
317
318fn detect_command() -> (String, Vec<String>) {
321 let is_available = Command::new("rust-doctor")
322 .arg("--version")
323 .stdout(Stdio::null())
324 .stderr(Stdio::null())
325 .status()
326 .is_ok_and(|s| s.success());
327
328 if is_available {
329 ("rust-doctor".into(), vec!["--mcp".into()])
330 } else {
331 (
332 "npx".into(),
333 vec!["-y".into(), "rust-doctor@latest".into(), "--mcp".into()],
334 )
335 }
336}
337
338fn print_recap(installed: &[InstalledFile], mode: &Mode) {
339 eprintln!();
340
341 if installed.is_empty() {
342 eprintln!(
343 " {}",
344 "No files were installed.".if_supports_color(Stream::Stderr, |t| t.yellow())
345 );
346 return;
347 }
348
349 eprintln!(
350 " {}",
351 "Setup complete!".if_supports_color(Stream::Stderr, |t| t.green())
352 );
353 eprintln!();
354 eprintln!(" Installed files:");
355 for file in installed {
356 eprintln!(
357 " {} {} ({})",
358 "\u{2713}".if_supports_color(Stream::Stderr, |t| t.green()),
359 file.path.if_supports_color(Stream::Stderr, |t| t.dimmed()),
360 file.kind,
361 );
362 }
363
364 eprintln!();
365 match mode {
366 Mode::Mcp => {
367 eprintln!(" Restart your AI agent to activate the MCP server.");
368 eprintln!(
369 " The agent will have access to: {}, {}, {}, {}",
370 "scan".if_supports_color(Stream::Stderr, |t| t.bold()),
371 "score".if_supports_color(Stream::Stderr, |t| t.bold()),
372 "explain_rule".if_supports_color(Stream::Stderr, |t| t.bold()),
373 "list_rules".if_supports_color(Stream::Stderr, |t| t.bold()),
374 );
375 }
376 Mode::CliSkills => {
377 eprintln!(" Your agent can now use rust-doctor via CLI commands.");
378 eprintln!(
379 " Try asking: {}",
380 "\"Run rust-doctor on this project\""
381 .if_supports_color(Stream::Stderr, |t| t.dimmed())
382 );
383 }
384 }
385 eprintln!();
386}