1use std::collections::{BTreeMap, BTreeSet};
28use std::fs;
29
30use serde::{Deserialize, Serialize};
31
32use crate::Paths;
33use crate::platform::PlatformProbe;
34use crate::registry::Registry;
35
36const TYPOSQUAT_DISTANCE_MAX: usize = 2;
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct UnknownVerdict {
44 pub name: String,
46 pub verdict: UnknownVerdictKind,
48 pub reason: String,
52 pub count: usize,
54 pub paths: Vec<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub enum UnknownVerdictKind {
61 KnownGood,
63 LikelyTyposquat { closest: String, distance: usize },
65 InformationalTyposquat { closest: String, distance: usize },
68 Unknown,
70}
71
72pub fn classify(
81 paths: &Paths,
82 registry: &Registry,
83 probes: &[&dyn PlatformProbe],
84) -> Vec<UnknownVerdict> {
85 let mut named: BTreeSet<(String, String)> = BTreeSet::new();
87
88 for probe in probes {
94 for path in probe.mcp_config_paths(paths) {
95 let Ok(body) = fs::read_to_string(&path) else {
96 continue;
97 };
98 let Ok(entries) = probe.extract_mcp_servers(&body, &path) else {
99 continue;
100 };
101 for entry in entries {
102 named.insert((entry.display_path, entry.name));
103 }
104 }
105 }
106
107 let mut by_name: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
109 for (path, name) in named {
110 by_name.entry(name).or_default().insert(path);
111 }
112
113 by_name
114 .into_iter()
115 .map(|(name, path_set)| {
116 let paths_vec: Vec<String> = path_set.into_iter().collect();
117 let count = paths_vec.len();
118 let rep = paths_vec.first().map_or("", String::as_str);
121 let mut v = classify_one(rep, &name, registry);
122 v.count = count;
123 v.paths = paths_vec;
124 v
125 })
126 .collect()
127}
128
129fn classify_one(path: &str, name: &str, registry: &Registry) -> UnknownVerdict {
130 if let Some(entry) = registry.get(name) {
131 return UnknownVerdict {
132 name: name.to_string(),
133 verdict: UnknownVerdictKind::KnownGood,
134 reason: format!("registered (source: {})", entry.source),
135 count: 1,
136 paths: vec![path.to_string()],
137 };
138 }
139 if let Some((entry, distance)) = registry.closest(name, TYPOSQUAT_DISTANCE_MAX) {
140 let char_count = name.chars().count();
143 if distance == 2 && char_count <= 3 {
144 } else {
146 let verdict = if distance == 1 {
147 UnknownVerdictKind::LikelyTyposquat {
148 closest: entry.name.clone(),
149 distance,
150 }
151 } else {
152 UnknownVerdictKind::InformationalTyposquat {
153 closest: entry.name.clone(),
154 distance,
155 }
156 };
157 return UnknownVerdict {
158 name: name.to_string(),
159 verdict,
160 reason: format!("close to `{}` (distance {})", entry.name, distance),
161 count: 1,
162 paths: vec![path.to_string()],
163 };
164 }
165 }
166 UnknownVerdict {
167 name: name.to_string(),
168 verdict: UnknownVerdictKind::Unknown,
169 reason: "not in registry".to_string(),
170 count: 1,
171 paths: vec![path.to_string()],
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::platform::McpServerEntry;
179 use crate::registry::RegistryEntry;
180 use std::path::{Path, PathBuf};
181
182 struct TestClaudeProbe;
190
191 impl PlatformProbe for TestClaudeProbe {
192 fn id(&self) -> &'static str {
193 "claude-code-test"
194 }
195 fn target_roots(&self, _paths: &Paths) -> Vec<(String, PathBuf)> {
196 Vec::new()
197 }
198 fn mcp_config_paths(&self, paths: &Paths) -> Vec<PathBuf> {
199 vec![
200 paths.user_home.join(".claude.json"),
201 PathBuf::from(".mcp.json"),
202 ]
203 }
204 fn extract_mcp_servers(
205 &self,
206 content: &str,
207 path: &Path,
208 ) -> crate::error::Result<Vec<McpServerEntry>> {
209 let json: serde_json::Value = serde_json::from_str(content)?;
210 let display = path.display().to_string();
211 let mut out = Vec::new();
212 if let Some(top) = json
213 .get("mcpServers")
214 .and_then(serde_json::Value::as_object)
215 {
216 for name in top.keys() {
217 out.push(McpServerEntry {
218 name: name.clone(),
219 display_path: display.clone(),
220 });
221 }
222 }
223 if path
224 .file_name()
225 .and_then(|f| f.to_str())
226 .is_some_and(|n| n == ".claude.json")
227 {
228 if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object)
229 {
230 for (proj_name, proj_val) in projects {
231 if let Some(servers) = proj_val
232 .get("mcpServers")
233 .and_then(serde_json::Value::as_object)
234 {
235 for name in servers.keys() {
236 let scoped = format!("{display}#projects.{proj_name}.mcpServers");
237 out.push(McpServerEntry {
238 name: name.clone(),
239 display_path: scoped,
240 });
241 }
242 }
243 }
244 }
245 }
246 Ok(out)
247 }
248 }
249
250 fn claude_only(probe: &TestClaudeProbe) -> [&dyn PlatformProbe; 1] {
251 [probe as &dyn PlatformProbe]
252 }
253
254 fn write_claude_json(tmp: &tempfile::TempDir, body: &str) {
255 std::fs::write(tmp.path().join(".claude.json"), body).unwrap();
256 }
257
258 fn paths_for(tmp: &tempfile::TempDir) -> Paths {
259 Paths {
260 home: tmp.path().to_path_buf(),
261 user_home: tmp.path().to_path_buf(),
262 }
263 }
264
265 fn small_registry() -> Registry {
266 Registry::from_entries(vec![
267 RegistryEntry {
268 name: "filesystem".into(),
269 source: "test-source".into(),
270 },
271 RegistryEntry {
272 name: "github".into(),
273 source: "test-source".into(),
274 },
275 ])
276 }
277
278 #[test]
279 fn known_good_is_recognised() {
280 let tmp = tempfile::tempdir().unwrap();
281 write_claude_json(&tmp, r#"{"mcpServers":{"filesystem":{"command":"x"}}}"#);
282 let verdicts = classify(
283 &paths_for(&tmp),
284 &small_registry(),
285 &claude_only(&TestClaudeProbe),
286 );
287 assert_eq!(verdicts.len(), 1);
288 assert_eq!(verdicts[0].name, "filesystem");
289 assert_eq!(verdicts[0].verdict, UnknownVerdictKind::KnownGood);
290 }
291
292 #[test]
293 fn typosquat_is_flagged() {
294 let tmp = tempfile::tempdir().unwrap();
295 write_claude_json(&tmp, r#"{"mcpServers":{"filesystme":{"command":"x"}}}"#);
298 let verdicts = classify(
299 &paths_for(&tmp),
300 &small_registry(),
301 &claude_only(&TestClaudeProbe),
302 );
303 assert_eq!(verdicts.len(), 1);
304 match &verdicts[0].verdict {
305 UnknownVerdictKind::InformationalTyposquat { closest, distance } => {
306 assert_eq!(closest, "filesystem");
307 assert_eq!(*distance, 2);
308 }
309 other => panic!("expected InformationalTyposquat, got {other:?}"),
310 }
311 }
312
313 #[test]
314 fn unknown_name_is_unknown() {
315 let tmp = tempfile::tempdir().unwrap();
316 write_claude_json(
317 &tmp,
318 r#"{"mcpServers":{"my-private-tool":{"command":"x"}}}"#,
319 );
320 let verdicts = classify(
321 &paths_for(&tmp),
322 &small_registry(),
323 &claude_only(&TestClaudeProbe),
324 );
325 assert_eq!(verdicts.len(), 1);
326 assert_eq!(verdicts[0].verdict, UnknownVerdictKind::Unknown);
327 }
328
329 #[test]
330 fn per_project_servers_are_classified() {
331 let tmp = tempfile::tempdir().unwrap();
332 write_claude_json(
333 &tmp,
334 r#"{
335 "mcpServers": {"github": {"command":"x"}},
336 "projects": {
337 "/some/proj": {
338 "mcpServers": {"my-tool": {"command":"y"}}
339 }
340 }
341 }"#,
342 );
343 let verdicts = classify(
344 &paths_for(&tmp),
345 &small_registry(),
346 &claude_only(&TestClaudeProbe),
347 );
348 assert_eq!(verdicts.len(), 2);
349 let names: Vec<&str> = verdicts.iter().map(|v| v.name.as_str()).collect();
350 assert!(names.contains(&"github"));
351 assert!(names.contains(&"my-tool"));
352 }
353
354 #[test]
355 fn missing_claude_json_yields_empty_result() {
356 let tmp = tempfile::tempdir().unwrap();
357 let verdicts = classify(
358 &paths_for(&tmp),
359 &small_registry(),
360 &claude_only(&TestClaudeProbe),
361 );
362 assert!(verdicts.is_empty());
363 }
364
365 #[test]
366 fn malformed_json_silently_skipped() {
367 let tmp = tempfile::tempdir().unwrap();
368 write_claude_json(&tmp, "not even close to JSON");
369 let verdicts = classify(
370 &paths_for(&tmp),
371 &small_registry(),
372 &claude_only(&TestClaudeProbe),
373 );
374 assert!(verdicts.is_empty());
375 }
376
377 #[test]
380 fn classify_one_short_name_distance_2_suppressed() {
381 let reg = Registry::from_entries(vec![RegistryEntry {
385 name: "liv".into(),
386 source: "test".into(),
387 }]);
388 let reg2 = Registry::from_entries(vec![RegistryEntry {
392 name: "git".into(),
393 source: "test".into(),
394 }]);
395 let reg3 = Registry::from_entries(vec![RegistryEntry {
398 name: "ab".into(),
399 source: "test".into(),
400 }]);
401 let v = classify_one("path", "cd", ®3);
402 assert_eq!(v.verdict, UnknownVerdictKind::Unknown);
404
405 let reg4 = Registry::from_entries(vec![RegistryEntry {
408 name: "abc".into(),
409 source: "test".into(),
410 }]);
411 let v2 = classify_one("path", "axz", ®4);
413 assert_eq!(v2.verdict, UnknownVerdictKind::Unknown);
414
415 let _ = reg;
417 let _ = reg2;
418 }
419
420 #[test]
421 fn classify_severity_split() {
422 let reg = Registry::from_entries(vec![RegistryEntry {
423 name: "filesystem".into(),
424 source: "test".into(),
425 }]);
426 let v1 = classify_one("p", "filesytem", ®); match &v1.verdict {
429 UnknownVerdictKind::LikelyTyposquat { distance, .. } => assert_eq!(*distance, 1),
430 other => panic!("expected LikelyTyposquat, got {other:?}"),
431 }
432 let v2 = classify_one("p", "filesystXY", ®); match &v2.verdict {
435 UnknownVerdictKind::InformationalTyposquat { distance, .. } => {
436 assert_eq!(*distance, 2);
437 }
438 other => panic!("expected InformationalTyposquat, got {other:?}"),
439 }
440 }
441
442 #[test]
443 fn classify_aggregates_dedup_count() {
444 use std::fs;
445 let tmp = tempfile::tempdir().unwrap();
446 let paths = paths_for(&tmp);
447 write_claude_json(
449 &tmp,
450 r#"{
451 "mcpServers": {"my-tool": {"command":"x"}},
452 "projects": {
453 "/proj1": {"mcpServers": {"my-tool": {"command":"y"}}},
454 "/proj2": {"mcpServers": {"my-tool": {"command":"z"}}}
455 }
456 }"#,
457 );
458 let reg = Registry::from_entries(vec![]);
459 let verdicts = classify(&paths, ®, &claude_only(&TestClaudeProbe));
460 let v = verdicts.iter().find(|v| v.name == "my-tool").unwrap();
462 assert_eq!(v.count, 3);
464 assert_eq!(v.paths.len(), 3);
465 assert!(v.paths.windows(2).all(|w| w[0] <= w[1]));
467
468 let _ = fs::metadata(tmp.path());
470 }
471}