1use crate::rules::ParseEnumError;
7use clap::ValueEnum;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum ClientType {
15 Claude,
17 Cursor,
19 Windsurf,
21 Vscode,
23}
24
25impl std::str::FromStr for ClientType {
26 type Err = ParseEnumError;
27
28 fn from_str(s: &str) -> Result<Self, Self::Err> {
29 match s.to_lowercase().replace(['-', '_'], "").as_str() {
30 "claude" | "claudecode" | "claudedesktop" => Ok(ClientType::Claude),
31 "cursor" => Ok(ClientType::Cursor),
32 "windsurf" => Ok(ClientType::Windsurf),
33 "vscode" | "code" => Ok(ClientType::Vscode),
34 _ => Err(ParseEnumError::invalid("ClientType", s)),
35 }
36 }
37}
38
39impl ClientType {
40 pub fn all() -> &'static [ClientType] {
42 &[
43 ClientType::Claude,
44 ClientType::Cursor,
45 ClientType::Windsurf,
46 ClientType::Vscode,
47 ]
48 }
49
50 pub fn display_name(&self) -> &'static str {
52 match self {
53 ClientType::Claude => "Claude",
54 ClientType::Cursor => "Cursor",
55 ClientType::Windsurf => "Windsurf",
56 ClientType::Vscode => "VS Code",
57 }
58 }
59
60 pub fn home_dir(&self) -> Option<PathBuf> {
63 match self {
64 ClientType::Claude => Self::claude_home_dir(),
65 ClientType::Cursor => Self::cursor_home_dir(),
66 ClientType::Windsurf => Self::windsurf_home_dir(),
67 ClientType::Vscode => Self::vscode_home_dir(),
68 }
69 }
70
71 #[cfg(target_os = "windows")]
72 fn claude_home_dir() -> Option<PathBuf> {
73 dirs::data_dir().map(|d| d.join("Claude"))
74 }
75
76 #[cfg(not(target_os = "windows"))]
77 fn claude_home_dir() -> Option<PathBuf> {
78 dirs::home_dir().map(|d| d.join(".claude"))
79 }
80
81 #[cfg(target_os = "windows")]
82 fn cursor_home_dir() -> Option<PathBuf> {
83 dirs::data_dir().map(|d| d.join("Cursor"))
84 }
85
86 #[cfg(not(target_os = "windows"))]
87 fn cursor_home_dir() -> Option<PathBuf> {
88 dirs::home_dir().map(|d| d.join(".cursor"))
89 }
90
91 #[cfg(target_os = "windows")]
92 fn windsurf_home_dir() -> Option<PathBuf> {
93 dirs::data_dir().map(|d| d.join("Windsurf"))
95 }
96
97 #[cfg(not(target_os = "windows"))]
98 fn windsurf_home_dir() -> Option<PathBuf> {
99 dirs::home_dir().map(|d| d.join(".windsurf"))
100 }
101
102 #[cfg(target_os = "windows")]
103 fn vscode_home_dir() -> Option<PathBuf> {
104 dirs::data_dir().map(|d| d.join("Code"))
105 }
106
107 #[cfg(not(target_os = "windows"))]
108 fn vscode_home_dir() -> Option<PathBuf> {
109 dirs::home_dir().map(|d| d.join(".vscode"))
110 }
111
112 pub fn mcp_config_paths(&self) -> Vec<PathBuf> {
114 let Some(home) = self.home_dir() else {
115 return Vec::new();
116 };
117
118 match self {
119 ClientType::Claude => vec![
120 home.join("mcp.json"),
121 home.join("claude_desktop_config.json"),
122 ],
123 ClientType::Cursor => vec![home.join("mcp.json")],
124 ClientType::Windsurf => vec![home.join("mcp_config.json")],
125 ClientType::Vscode => {
126 let mut paths = Vec::new();
128 if let Some(data_dir) = dirs::data_dir() {
129 paths.push(
131 data_dir
132 .join("Code")
133 .join("User")
134 .join("globalStorage")
135 .join("rooveterinaryinc.roo-cline")
136 .join("settings")
137 .join("cline_mcp_settings.json"),
138 );
139 paths.push(
141 data_dir
142 .join("Code")
143 .join("User")
144 .join("globalStorage")
145 .join("saoudrizwan.claude-dev")
146 .join("settings")
147 .join("cline_mcp_settings.json"),
148 );
149 }
150 paths
151 }
152 }
153 }
154
155 pub fn settings_config_paths(&self) -> Vec<PathBuf> {
157 let Some(home) = self.home_dir() else {
158 return Vec::new();
159 };
160
161 match self {
162 ClientType::Claude => vec![home.join("settings.json")],
163 ClientType::Cursor => vec![home.join("settings.json")],
164 ClientType::Windsurf => vec![home.join("settings.json")],
165 ClientType::Vscode => vec![],
166 }
167 }
168
169 pub fn is_installed(&self) -> bool {
171 self.home_dir().map(|p| p.exists()).unwrap_or(false)
172 }
173
174 pub fn all_config_paths(&self) -> Vec<PathBuf> {
176 let mut paths = self.mcp_config_paths();
177 paths.extend(self.settings_config_paths());
178 paths
179 }
180}
181
182impl std::fmt::Display for ClientType {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 write!(f, "{}", self.display_name())
185 }
186}
187
188#[derive(Debug, Clone)]
190pub struct DetectedClient {
191 pub client_type: ClientType,
193 pub home_dir: PathBuf,
195 pub mcp_configs: Vec<PathBuf>,
197 pub settings_configs: Vec<PathBuf>,
199}
200
201impl DetectedClient {
202 pub fn all_configs(&self) -> Vec<PathBuf> {
204 let mut configs = self.mcp_configs.clone();
205 configs.extend(self.settings_configs.clone());
206 configs
207 }
208
209 pub fn has_configs(&self) -> bool {
211 !self.mcp_configs.is_empty() || !self.settings_configs.is_empty()
212 }
213}
214
215pub fn detect_installed_clients() -> Vec<DetectedClient> {
220 ClientType::all()
221 .iter()
222 .filter_map(|ct| detect_client(*ct))
223 .collect()
224}
225
226pub fn detect_client(client_type: ClientType) -> Option<DetectedClient> {
230 let home = client_type.home_dir()?;
231
232 if !home.exists() {
233 return None;
234 }
235
236 let mcp_configs: Vec<PathBuf> = client_type
237 .mcp_config_paths()
238 .into_iter()
239 .filter(|p| p.exists())
240 .collect();
241
242 let settings_configs: Vec<PathBuf> = client_type
243 .settings_config_paths()
244 .into_iter()
245 .filter(|p| p.exists())
246 .collect();
247
248 if mcp_configs.is_empty() && settings_configs.is_empty() {
250 return None;
251 }
252
253 Some(DetectedClient {
254 client_type,
255 home_dir: home,
256 mcp_configs,
257 settings_configs,
258 })
259}
260
261pub fn list_installed_clients() -> Vec<ClientType> {
263 ClientType::all()
264 .iter()
265 .filter(|ct| ct.is_installed())
266 .copied()
267 .collect()
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn test_client_type_display_name() {
276 assert_eq!(ClientType::Claude.display_name(), "Claude");
277 assert_eq!(ClientType::Cursor.display_name(), "Cursor");
278 assert_eq!(ClientType::Windsurf.display_name(), "Windsurf");
279 assert_eq!(ClientType::Vscode.display_name(), "VS Code");
280 }
281
282 #[test]
283 fn test_client_type_all() {
284 let all = ClientType::all();
285 assert_eq!(all.len(), 4);
286 assert!(all.contains(&ClientType::Claude));
287 assert!(all.contains(&ClientType::Cursor));
288 assert!(all.contains(&ClientType::Windsurf));
289 assert!(all.contains(&ClientType::Vscode));
290 }
291
292 #[test]
293 fn test_client_type_display() {
294 assert_eq!(format!("{}", ClientType::Claude), "Claude");
295 assert_eq!(format!("{}", ClientType::Cursor), "Cursor");
296 }
297
298 #[test]
299 fn test_home_dir_returns_some() {
300 for ct in ClientType::all() {
302 let home = ct.home_dir();
303 assert!(home.is_some(), "home_dir() should return Some for {:?}", ct);
304 }
305 }
306
307 #[cfg(not(target_os = "windows"))]
308 #[test]
309 fn test_claude_home_dir_unix() {
310 let home = ClientType::Claude.home_dir();
311 assert!(home.is_some());
312 let path = home.unwrap();
313 assert!(path.to_string_lossy().contains(".claude"));
314 }
315
316 #[cfg(not(target_os = "windows"))]
317 #[test]
318 fn test_cursor_home_dir_unix() {
319 let home = ClientType::Cursor.home_dir();
320 assert!(home.is_some());
321 let path = home.unwrap();
322 assert!(path.to_string_lossy().contains(".cursor"));
323 }
324
325 #[test]
326 fn test_mcp_config_paths_not_empty() {
327 for ct in ClientType::all() {
329 let paths = ct.mcp_config_paths();
330 assert!(
331 !paths.is_empty() || *ct == ClientType::Vscode,
332 "mcp_config_paths() should not be empty for {:?}",
333 ct
334 );
335 }
336 }
337
338 #[test]
339 fn test_detected_client_has_configs() {
340 let client = DetectedClient {
341 client_type: ClientType::Claude,
342 home_dir: PathBuf::from("/tmp/claude"),
343 mcp_configs: vec![PathBuf::from("/tmp/claude/mcp.json")],
344 settings_configs: vec![],
345 };
346 assert!(client.has_configs());
347
348 let empty_client = DetectedClient {
349 client_type: ClientType::Claude,
350 home_dir: PathBuf::from("/tmp/claude"),
351 mcp_configs: vec![],
352 settings_configs: vec![],
353 };
354 assert!(!empty_client.has_configs());
355 }
356
357 #[test]
358 fn test_detected_client_all_configs() {
359 let client = DetectedClient {
360 client_type: ClientType::Claude,
361 home_dir: PathBuf::from("/tmp/claude"),
362 mcp_configs: vec![PathBuf::from("/tmp/claude/mcp.json")],
363 settings_configs: vec![PathBuf::from("/tmp/claude/settings.json")],
364 };
365 let all = client.all_configs();
366 assert_eq!(all.len(), 2);
367 }
368
369 #[test]
370 fn test_client_type_serialize() {
371 let json = serde_json::to_string(&ClientType::Claude).unwrap();
372 assert_eq!(json, "\"claude\"");
373
374 let json = serde_json::to_string(&ClientType::Vscode).unwrap();
375 assert_eq!(json, "\"vscode\"");
376 }
377
378 #[test]
379 fn test_client_type_deserialize() {
380 let ct: ClientType = serde_json::from_str("\"claude\"").unwrap();
381 assert_eq!(ct, ClientType::Claude);
382
383 let ct: ClientType = serde_json::from_str("\"vscode\"").unwrap();
384 assert_eq!(ct, ClientType::Vscode);
385 }
386
387 #[test]
388 fn test_client_type_from_str() {
389 use std::str::FromStr;
390
391 assert_eq!(
393 <ClientType as FromStr>::from_str("claude").unwrap(),
394 ClientType::Claude
395 );
396 assert_eq!(
397 <ClientType as FromStr>::from_str("cursor").unwrap(),
398 ClientType::Cursor
399 );
400 assert_eq!(
401 <ClientType as FromStr>::from_str("windsurf").unwrap(),
402 ClientType::Windsurf
403 );
404 assert_eq!(
405 <ClientType as FromStr>::from_str("vscode").unwrap(),
406 ClientType::Vscode
407 );
408
409 assert_eq!(
411 <ClientType as FromStr>::from_str("claudecode").unwrap(),
412 ClientType::Claude
413 );
414 assert_eq!(
415 <ClientType as FromStr>::from_str("claude-code").unwrap(),
416 ClientType::Claude
417 );
418 assert_eq!(
419 <ClientType as FromStr>::from_str("claude_desktop").unwrap(),
420 ClientType::Claude
421 );
422 assert_eq!(
423 <ClientType as FromStr>::from_str("code").unwrap(),
424 ClientType::Vscode
425 );
426
427 assert_eq!(
429 <ClientType as FromStr>::from_str("CLAUDE").unwrap(),
430 ClientType::Claude
431 );
432 assert_eq!(
433 <ClientType as FromStr>::from_str("Cursor").unwrap(),
434 ClientType::Cursor
435 );
436
437 assert!(<ClientType as FromStr>::from_str("invalid").is_err());
439 assert!(<ClientType as FromStr>::from_str("").is_err());
440 }
441
442 #[test]
443 fn test_client_type_all_variants() {
444 let all = ClientType::all();
445 assert_eq!(all.len(), 4);
446 assert!(all.contains(&ClientType::Claude));
447 assert!(all.contains(&ClientType::Cursor));
448 assert!(all.contains(&ClientType::Windsurf));
449 assert!(all.contains(&ClientType::Vscode));
450 }
451
452 #[test]
453 fn test_client_type_home_dir() {
454 let claude_home = ClientType::Claude.home_dir();
456 let cursor_home = ClientType::Cursor.home_dir();
457 let windsurf_home = ClientType::Windsurf.home_dir();
458 let vscode_home = ClientType::Vscode.home_dir();
459
460 assert!(claude_home.is_some());
462 assert!(cursor_home.is_some());
463 assert!(windsurf_home.is_some());
464 assert!(vscode_home.is_some());
465 }
466
467 #[test]
468 fn test_client_type_display_name_all() {
469 assert_eq!(ClientType::Claude.display_name(), "Claude");
470 assert_eq!(ClientType::Cursor.display_name(), "Cursor");
471 assert_eq!(ClientType::Windsurf.display_name(), "Windsurf");
472 assert_eq!(ClientType::Vscode.display_name(), "VS Code");
473 }
474
475 #[test]
476 fn test_windsurf_home_dir() {
477 let home = ClientType::Windsurf.home_dir();
479 assert!(home.is_some());
480 #[cfg(not(target_os = "windows"))]
481 {
482 let path = home.unwrap();
483 assert!(path.to_string_lossy().contains(".windsurf"));
484 }
485 }
486
487 #[test]
488 fn test_vscode_home_dir() {
489 let home = ClientType::Vscode.home_dir();
491 assert!(home.is_some());
492 }
493
494 #[test]
495 fn test_is_installed_checks_path_exists() {
496 for ct in ClientType::all() {
499 let _ = ct.is_installed();
500 }
501 }
502
503 #[test]
504 fn test_all_config_paths_combines_mcp_and_settings() {
505 for ct in ClientType::all() {
507 let all = ct.all_config_paths();
508 let mcp = ct.mcp_config_paths();
509 let settings = ct.settings_config_paths();
510 assert_eq!(all.len(), mcp.len() + settings.len());
511 }
512 }
513
514 #[test]
515 fn test_settings_config_paths() {
516 assert!(!ClientType::Claude.settings_config_paths().is_empty());
518 assert!(!ClientType::Cursor.settings_config_paths().is_empty());
519 assert!(!ClientType::Windsurf.settings_config_paths().is_empty());
520 assert!(ClientType::Vscode.settings_config_paths().is_empty());
522 }
523
524 #[test]
525 fn test_list_installed_clients() {
526 let installed = list_installed_clients();
529 for client in &installed {
531 assert!(client.is_installed());
532 }
533 }
534
535 #[test]
536 fn test_vscode_mcp_config_paths() {
537 let paths = ClientType::Vscode.mcp_config_paths();
539 if !paths.is_empty() {
542 for path in &paths {
543 assert!(path.to_string_lossy().contains("cline_mcp_settings.json"));
544 }
545 }
546 }
547}