Skip to content

Commit a9c9a0c

Browse files
committed
feat(ide): implement lockfile::discover() for IDE endpoint detection
Real implementation of lockfile discovery — scans ~/.claude/ide/ and ~/.crab/ide/ for *.lock files and returns parsed endpoints. Design notes: - Missing directories are silently skipped (non-error). A user without the plugin just gets an empty vec. - Malformed files (bad filename port / non-JSON / wrong schema) are logged at warn level and skipped per-file, so one stale file from an old plugin version can't prevent discovery of live ones. - ParseOneError is private — it's folded into tracing::warn inside scan_dir and never surfaced to callers. Unblocks R2b (crab-mcp WS upgrade) + R2c (IdeClient::try_connect). 8 new tests covering: well-formed parse, non-.lock skip, bad filename skip, malformed JSON skip, port overflow skip, missing dir tolerance, default workspaceFolders, multi-endpoint dir. 14 total crab-ide tests pass.
1 parent 12f0257 commit a9c9a0c

2 files changed

Lines changed: 222 additions & 13 deletions

File tree

crates/ide/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,8 @@ tracing.workspace = true
1717
thiserror.workspace = true
1818
directories.workspace = true
1919

20+
[dev-dependencies]
21+
tempfile.workspace = true
22+
2023
[lints]
2124
workspace = true

crates/ide/src/lockfile.rs

Lines changed: 219 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@
1313
//! }
1414
//! ```
1515
//!
16-
//! Filename = port (e.g. `12345.lock` ⇒ MCP server listens on `12345`).
16+
//! Filename stem = port (e.g. `12345.lock` ⇒ MCP server listens on `12345`).
1717
//!
1818
//! Search paths (checked in order):
1919
//! 1. `~/.claude/ide/*.lock` — upstream plugin's directory (piggyback path)
2020
//! 2. `~/.crab/ide/*.lock` — our future plugin (self-hosted path)
21-
22-
#![allow(dead_code)] // R1 scaffolding; wired up in R2
21+
//!
22+
//! Unparseable / badly-named files are skipped with a `tracing::warn`
23+
//! rather than failing the whole scan: a stale file from an older plugin
24+
//! version shouldn't prevent us from finding a live one.
2325
2426
use serde::Deserialize;
25-
use std::path::PathBuf;
27+
use std::path::{Path, PathBuf};
2628

2729
/// Parsed contents of a single `.lock` file.
2830
#[derive(Debug, Clone, Deserialize)]
@@ -56,20 +58,224 @@ pub enum DiscoverError {
5658
#[source]
5759
source: std::io::Error,
5860
},
59-
#[error("parse error in {path:?}: {source}")]
60-
Parse {
61-
path: PathBuf,
62-
#[source]
63-
source: serde_json::Error,
64-
},
61+
}
62+
63+
/// Directories to scan for `*.lock` files, in priority order.
64+
///
65+
/// Earlier entries take precedence when multiple lockfiles exist on the
66+
/// same port. Missing directories are silently skipped — a non-error.
67+
fn search_dirs() -> Result<Vec<PathBuf>, DiscoverError> {
68+
let user_dirs = directories::UserDirs::new().ok_or(DiscoverError::NoHome)?;
69+
let home = user_dirs.home_dir().to_path_buf();
70+
Ok(vec![
71+
home.join(".claude").join("ide"),
72+
home.join(".crab").join("ide"),
73+
])
74+
}
75+
76+
/// Scan a single directory for `*.lock` files and append parsed
77+
/// endpoints to `out`.
78+
///
79+
/// Directory-not-found returns `Ok(())` — callers iterate search paths
80+
/// in order and tolerate missing ones. Read errors on the directory
81+
/// itself propagate; per-file parse errors are logged and skipped.
82+
fn scan_dir(dir: &Path, out: &mut Vec<DiscoveredEndpoint>) -> Result<(), DiscoverError> {
83+
let read_dir = match std::fs::read_dir(dir) {
84+
Ok(rd) => rd,
85+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
86+
Err(e) => {
87+
return Err(DiscoverError::Io {
88+
path: dir.to_path_buf(),
89+
source: e,
90+
});
91+
}
92+
};
93+
94+
for entry_result in read_dir {
95+
let entry = match entry_result {
96+
Ok(e) => e,
97+
Err(e) => {
98+
tracing::warn!(dir = %dir.display(), error = %e, "skipping unreadable dir entry");
99+
continue;
100+
}
101+
};
102+
let path = entry.path();
103+
if path.extension().and_then(|s| s.to_str()) != Some("lock") {
104+
continue;
105+
}
106+
match parse_one(&path) {
107+
Ok(ep) => out.push(ep),
108+
Err(e) => {
109+
tracing::warn!(path = %path.display(), error = %e, "skipping malformed lockfile");
110+
}
111+
}
112+
}
113+
Ok(())
114+
}
115+
116+
/// Parse port from filename and JSON body from the file.
117+
fn parse_one(path: &Path) -> Result<DiscoveredEndpoint, ParseOneError> {
118+
let stem = path
119+
.file_stem()
120+
.and_then(|s| s.to_str())
121+
.ok_or(ParseOneError::BadFilename)?;
122+
let port: u16 = stem.parse().map_err(|_| ParseOneError::BadFilename)?;
123+
124+
let bytes = std::fs::read(path).map_err(ParseOneError::Io)?;
125+
let lockfile: Lockfile = serde_json::from_slice(&bytes).map_err(ParseOneError::Parse)?;
126+
127+
Ok(DiscoveredEndpoint {
128+
port,
129+
lockfile,
130+
source: path.to_path_buf(),
131+
})
132+
}
133+
134+
/// Local error for `parse_one` — folded into `tracing::warn` by
135+
/// `scan_dir`, never surfaced to callers.
136+
#[derive(Debug, thiserror::Error)]
137+
enum ParseOneError {
138+
#[error("filename is not a valid u16 port")]
139+
BadFilename,
140+
#[error("io error: {0}")]
141+
Io(#[source] std::io::Error),
142+
#[error("parse error: {0}")]
143+
Parse(#[source] serde_json::Error),
65144
}
66145

67146
/// Return all IDE endpoints discoverable on this host.
68147
///
69148
/// Empty vec means "no IDE currently exposing a plugin endpoint" — a
70149
/// non-error condition; downstream code should degrade gracefully.
150+
///
151+
/// When multiple `.lock` files exist for the same port across the two
152+
/// search directories, the first-discovered wins (i.e. `~/.claude/ide/`
153+
/// shadows `~/.crab/ide/`). We don't actively dedupe beyond that — a
154+
/// single plugin writing both paths is its own bug.
71155
pub fn discover() -> Result<Vec<DiscoveredEndpoint>, DiscoverError> {
72-
// R2: walk ~/.claude/ide/ and ~/.crab/ide/, parse every *.lock,
73-
// extract port from filename, deserialize JSON.
74-
Ok(Vec::new())
156+
let mut endpoints = Vec::new();
157+
for dir in search_dirs()? {
158+
scan_dir(&dir, &mut endpoints)?;
159+
}
160+
Ok(endpoints)
161+
}
162+
163+
#[cfg(test)]
164+
mod tests {
165+
use super::*;
166+
167+
fn write_lock(dir: &Path, port: u16, body: &str) {
168+
std::fs::create_dir_all(dir).unwrap();
169+
let path = dir.join(format!("{port}.lock"));
170+
std::fs::write(path, body).unwrap();
171+
}
172+
173+
fn sample_body(ide: &str, token: &str) -> String {
174+
format!(
175+
r#"{{
176+
"pid": 42,
177+
"workspaceFolders": ["/tmp/ws"],
178+
"ideName": "{ide}",
179+
"transport": "ws",
180+
"authToken": "{token}"
181+
}}"#
182+
)
183+
}
184+
185+
#[test]
186+
fn parses_well_formed_lockfile() {
187+
let tmp = tempfile::tempdir().unwrap();
188+
write_lock(tmp.path(), 12345, &sample_body("IntelliJ IDEA", "tok-abc"));
189+
let mut out = Vec::new();
190+
scan_dir(tmp.path(), &mut out).unwrap();
191+
assert_eq!(out.len(), 1);
192+
assert_eq!(out[0].port, 12345);
193+
assert_eq!(out[0].lockfile.ide_name, "IntelliJ IDEA");
194+
assert_eq!(out[0].lockfile.auth_token, "tok-abc");
195+
assert_eq!(out[0].lockfile.transport, "ws");
196+
assert_eq!(out[0].lockfile.pid, 42);
197+
}
198+
199+
#[test]
200+
fn skips_non_lock_extension() {
201+
let tmp = tempfile::tempdir().unwrap();
202+
std::fs::write(tmp.path().join("notes.txt"), "hello").unwrap();
203+
std::fs::write(tmp.path().join("readme.md"), "hi").unwrap();
204+
write_lock(tmp.path(), 9999, &sample_body("VSCode", "t"));
205+
let mut out = Vec::new();
206+
scan_dir(tmp.path(), &mut out).unwrap();
207+
assert_eq!(out.len(), 1);
208+
assert_eq!(out[0].port, 9999);
209+
}
210+
211+
#[test]
212+
fn skips_bad_filename_gracefully() {
213+
let tmp = tempfile::tempdir().unwrap();
214+
// Non-numeric filename — should be skipped, not fail.
215+
std::fs::write(tmp.path().join("notaport.lock"), sample_body("VSCode", "t")).unwrap();
216+
write_lock(tmp.path(), 8080, &sample_body("VSCode", "t"));
217+
let mut out = Vec::new();
218+
scan_dir(tmp.path(), &mut out).unwrap();
219+
assert_eq!(out.len(), 1);
220+
assert_eq!(out[0].port, 8080);
221+
}
222+
223+
#[test]
224+
fn skips_malformed_json_gracefully() {
225+
let tmp = tempfile::tempdir().unwrap();
226+
std::fs::write(tmp.path().join("7070.lock"), "{ not json }").unwrap();
227+
write_lock(tmp.path(), 8080, &sample_body("VSCode", "t"));
228+
let mut out = Vec::new();
229+
scan_dir(tmp.path(), &mut out).unwrap();
230+
assert_eq!(out.len(), 1);
231+
assert_eq!(out[0].port, 8080);
232+
}
233+
234+
#[test]
235+
fn port_out_of_range_skipped() {
236+
let tmp = tempfile::tempdir().unwrap();
237+
// 99999 > u16::MAX — filename parse fails, file skipped.
238+
std::fs::write(tmp.path().join("99999.lock"), sample_body("VSCode", "t")).unwrap();
239+
let mut out = Vec::new();
240+
scan_dir(tmp.path(), &mut out).unwrap();
241+
assert!(out.is_empty());
242+
}
243+
244+
#[test]
245+
fn missing_directory_is_not_error() {
246+
let tmp = tempfile::tempdir().unwrap();
247+
let missing = tmp.path().join("does-not-exist");
248+
let mut out = Vec::new();
249+
scan_dir(&missing, &mut out).unwrap();
250+
assert!(out.is_empty());
251+
}
252+
253+
#[test]
254+
fn workspace_folders_defaults_empty() {
255+
let tmp = tempfile::tempdir().unwrap();
256+
let body = r#"{
257+
"pid": 1,
258+
"ideName": "VSCode",
259+
"transport": "ws",
260+
"authToken": "t"
261+
}"#;
262+
write_lock(tmp.path(), 6000, body);
263+
let mut out = Vec::new();
264+
scan_dir(tmp.path(), &mut out).unwrap();
265+
assert_eq!(out.len(), 1);
266+
assert!(out[0].lockfile.workspace_folders.is_empty());
267+
}
268+
269+
#[test]
270+
fn multiple_endpoints_in_one_dir() {
271+
let tmp = tempfile::tempdir().unwrap();
272+
write_lock(tmp.path(), 1111, &sample_body("A", "x"));
273+
write_lock(tmp.path(), 2222, &sample_body("B", "y"));
274+
let mut out = Vec::new();
275+
scan_dir(tmp.path(), &mut out).unwrap();
276+
assert_eq!(out.len(), 2);
277+
let mut ports: Vec<u16> = out.iter().map(|e| e.port).collect();
278+
ports.sort_unstable();
279+
assert_eq!(ports, vec![1111, 2222]);
280+
}
75281
}

0 commit comments

Comments
 (0)