Skip to main content

lean_ctx/lsp/
port_discovery.rs

1//! Discovery of the in-IDE JetBrains backend via a per-project port file.
2//!
3//! The plugin writes `<data_dir>/jetbrains-<projecthash>.port` (JSON, 0600), where
4//! `<data_dir>` = core::data_dir::lean_ctx_data_dir() (LEAN_CTX_DATA_DIR → ~/.lean-ctx → XDG).
5//! `projecthash = sha256(canonical(project_root))[..16]` — Rust and Kotlin MUST
6//! canonicalize identically (symlink / trailing-slash trap, spec §5.5).
7
8use std::time::Duration;
9
10use serde::Deserialize;
11
12/// Contents of the per-project port file (subset Rust needs).
13#[derive(Debug, Clone, Deserialize)]
14pub struct PortFile {
15    pub port: u16,
16    pub token: String,
17    pub pid: u32,
18    #[serde(default)]
19    pub project_root: String,
20    #[serde(default)]
21    pub ide_version: String,
22}
23
24/// `sha256(canonical(project_root))[..16]` as lowercase hex (first 8 bytes → 16 chars).
25pub fn project_hash(project_root: &str) -> String {
26    use std::fmt::Write as _;
27
28    use sha2::{Digest, Sha256};
29    let canonical = std::fs::canonicalize(project_root).map_or_else(
30        |_| project_root.to_string(),
31        |p| p.to_string_lossy().to_string(),
32    );
33    let digest = Sha256::digest(canonical.as_bytes());
34    let mut hex = String::with_capacity(16);
35    for b in digest.iter().take(8) {
36        let _ = write!(hex, "{b:02x}");
37    }
38    hex
39}
40
41/// `<data_dir>/jetbrains-<projecthash>.port` — `<data_dir>` resolved via
42/// `core::data_dir::lean_ctx_data_dir()` (LEAN_CTX_DATA_DIR → ~/.lean-ctx → XDG),
43/// NOT a hardcoded `~/.lean-ctx` (spec §5.5 / §15.5). The Kotlin side mirrors this resolution.
44pub fn port_file_path(project_root: &str) -> Option<std::path::PathBuf> {
45    let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
46    Some(dir.join(format!("jetbrains-{}.port", project_hash(project_root))))
47}
48
49/// Reads + parses the port file, or `None` if absent/unreadable/malformed.
50pub fn read_port_file(project_root: &str) -> Option<PortFile> {
51    use std::io::Read;
52    // A port file is a few hundred bytes; anything far larger is not one.
53    const MAX_BYTES: u64 = 64 * 1024;
54    let path = port_file_path(project_root)?;
55    let mut text = String::new();
56    std::fs::File::open(path)
57        .ok()?
58        .take(MAX_BYTES + 1)
59        .read_to_string(&mut text)
60        .ok()?;
61    if text.len() as u64 > MAX_BYTES {
62        return None;
63    }
64    serde_json::from_str(&text).ok()
65}
66
67/// Liveness check for the IDE process. Linux: `/proc/<pid>`. Other OSes:
68/// optimistic `true` (the `/health` ping is the authoritative reachability gate).
69pub fn pid_alive(pid: u32) -> bool {
70    #[cfg(target_os = "linux")]
71    {
72        std::path::Path::new(&format!("/proc/{pid}")).exists()
73    }
74    #[cfg(not(target_os = "linux"))]
75    {
76        let _ = pid;
77        true
78    }
79}
80
81/// `GET /health` with token header and a tight timeout (~300ms, spec §4.3).
82/// ureq 3.x: per-request timeout via `.config().timeout_global(..).build()`.
83pub fn health_ok(pf: &PortFile) -> bool {
84    let url = format!("http://127.0.0.1:{}/health", pf.port);
85    ureq::get(&url)
86        .config()
87        .timeout_global(Some(Duration::from_millis(300)))
88        .build()
89        .header("X-LeanCtx-Token", &pf.token)
90        .call()
91        .is_ok()
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn project_hash_is_stable_and_16_hex() {
100        let h1 = project_hash("/some/project");
101        let h2 = project_hash("/some/project");
102        assert_eq!(h1, h2, "hash must be deterministic");
103        assert_eq!(h1.len(), 16, "expected 16 hex chars (8 bytes)");
104        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
105    }
106
107    #[test]
108    fn port_file_absent_for_unlikely_root() {
109        // A path that has no port file → None (never panics).
110        assert!(read_port_file("/nonexistent/lean-ctx/project/xyz").is_none());
111    }
112
113    #[test]
114    fn project_hash_matches_known_vector() {
115        // sha256("/some/project")[..8] — canonicalize fails (path absent) → raw fallback.
116        // Shared parity anchor with the Kotlin LeanCtxPaths test.
117        assert_eq!(project_hash("/some/project"), "a0317725f24b01df");
118    }
119
120    #[test]
121    fn port_file_path_honors_data_dir_env() {
122        let _lock = crate::core::data_dir::test_env_lock();
123        let dir = std::env::temp_dir().join("lc_jb_portfile_env");
124        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
125        let p = port_file_path("/some/project").unwrap();
126        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
127        assert_eq!(p, dir.join("jetbrains-a0317725f24b01df.port"));
128    }
129}