Skip to main content

cc_audit/
client.rs

1//! AI coding client detection and configuration paths.
2//!
3//! This module provides functionality to detect installed AI coding clients
4//! (Claude, Cursor, Windsurf, VS Code) and locate their configuration files.
5
6use crate::rules::ParseEnumError;
7use clap::ValueEnum;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11/// Supported AI coding clients.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum ClientType {
15    /// Claude Desktop / Claude Code
16    Claude,
17    /// Cursor IDE
18    Cursor,
19    /// Windsurf IDE
20    Windsurf,
21    /// VS Code with MCP extensions
22    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    /// Returns all supported client types.
41    pub fn all() -> &'static [ClientType] {
42        &[
43            ClientType::Claude,
44            ClientType::Cursor,
45            ClientType::Windsurf,
46            ClientType::Vscode,
47        ]
48    }
49
50    /// Returns the display name of the client.
51    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    /// Returns the home directory path for this client.
61    /// Returns `None` if the home directory cannot be determined.
62    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        // Windsurf may not be available on Windows yet
94        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    /// Returns the MCP configuration file paths for this client.
113    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                // VS Code MCP extensions store config in globalStorage
127                let mut paths = Vec::new();
128                if let Some(data_dir) = dirs::data_dir() {
129                    // Roo-Cline extension
130                    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                    // Claude Dev extension
140                    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    /// Returns the settings/hooks configuration file paths for this client.
156    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    /// Checks if this client is installed on the system.
170    pub fn is_installed(&self) -> bool {
171        self.home_dir().map(|p| p.exists()).unwrap_or(false)
172    }
173
174    /// Returns all scannable paths for this client (MCP + settings).
175    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/// Information about a detected AI coding client.
189#[derive(Debug, Clone)]
190pub struct DetectedClient {
191    /// The type of client.
192    pub client_type: ClientType,
193    /// The home directory of the client.
194    pub home_dir: PathBuf,
195    /// Existing MCP configuration files.
196    pub mcp_configs: Vec<PathBuf>,
197    /// Existing settings configuration files.
198    pub settings_configs: Vec<PathBuf>,
199}
200
201impl DetectedClient {
202    /// Returns all existing configuration files for this client.
203    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    /// Returns true if any configuration files exist.
210    pub fn has_configs(&self) -> bool {
211        !self.mcp_configs.is_empty() || !self.settings_configs.is_empty()
212    }
213}
214
215/// Detects all installed AI coding clients on the system.
216///
217/// Returns a list of detected clients with their configuration file paths.
218/// Only clients with at least one existing configuration file are returned.
219pub fn detect_installed_clients() -> Vec<DetectedClient> {
220    ClientType::all()
221        .iter()
222        .filter_map(|ct| detect_client(*ct))
223        .collect()
224}
225
226/// Detects a specific client type.
227///
228/// Returns `None` if the client is not installed or has no configuration files.
229pub 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    // Only return if at least one config file exists
249    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
261/// Lists all installed clients (even without configuration files).
262pub 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        // Home dir should always be resolvable on a real system
301        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        // All clients should have at least one potential MCP config path
328        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        // Standard names
392        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        // Alternate names
410        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        // Case insensitive
428        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        // Invalid
438        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        // Home dir should return Some on most systems
455        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        // These should all succeed on a normal system
461        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        // Specific test for windsurf
478        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        // Specific test for vscode
490        let home = ClientType::Vscode.home_dir();
491        assert!(home.is_some());
492    }
493}