cargo-brief 0.9.0

Visibility-aware Rust API extractor — pseudo-Rust output for AI agent consumption
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! Client-side logic: ensure daemon is running, connect, send commands.

use std::fs::{File, OpenOptions};
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

use anyhow::{Context, Result, bail};

use super::protocol::{DaemonRequest, DaemonResponse, read_message, write_message};

/// Create a named pipe (FIFO) at `path`. Ignores `EEXIST` (idempotent).
pub(super) fn create_fifo(path: &Path, mode: libc::mode_t) -> Result<()> {
    use std::ffi::CString;
    use std::os::unix::ffi::OsStrExt;

    let c_path =
        CString::new(path.as_os_str().as_bytes()).context("FIFO path contains null byte")?;
    // SAFETY: mkfifo is a standard POSIX call; c_path is valid and null-terminated.
    let ret = unsafe { libc::mkfifo(c_path.as_ptr(), mode) };
    if ret != 0 {
        let err = std::io::Error::last_os_error();
        if err.raw_os_error() != Some(libc::EEXIST) {
            return Err(err).with_context(|| format!("mkfifo failed: {}", path.display()));
        }
    }
    Ok(())
}

/// Acquire an exclusive advisory lock on `file` (blocking).
pub(super) fn flock_exclusive(file: &File) -> Result<()> {
    // SAFETY: flock is a standard POSIX call; fd is valid while File is alive.
    let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
    if ret != 0 {
        return Err(std::io::Error::last_os_error()).context("flock(LOCK_EX) failed");
    }
    Ok(())
}

/// Call `libc::poll()` with EINTR retry. Returns the poll result (>0 = ready, 0 = timeout).
pub(super) fn poll_retry(pfd: &mut libc::pollfd, timeout_ms: libc::c_int) -> Result<libc::c_int> {
    loop {
        // SAFETY: poll on a valid fd with a stack-allocated pollfd.
        let n = unsafe { libc::poll(pfd, 1, timeout_ms) };
        if n >= 0 {
            return Ok(n);
        }
        let err = std::io::Error::last_os_error();
        if err.raw_os_error() != Some(libc::EINTR) {
            return Err(err).context("poll() failed");
        }
        // EINTR — retry
    }
}

/// Toggle `O_NONBLOCK` on a file descriptor.
pub(super) fn set_nonblocking(file: &File, nonblock: bool) -> Result<()> {
    let fd = file.as_raw_fd();
    // SAFETY: fcntl F_GETFL/F_SETFL are standard POSIX calls on a valid fd.
    unsafe {
        let flags = libc::fcntl(fd, libc::F_GETFL);
        if flags == -1 {
            return Err(std::io::Error::last_os_error()).context("fcntl(F_GETFL) failed");
        }
        let new_flags = if nonblock {
            flags | libc::O_NONBLOCK
        } else {
            flags & !libc::O_NONBLOCK
        };
        if libc::fcntl(fd, libc::F_SETFL, new_flags) == -1 {
            return Err(std::io::Error::last_os_error()).context("fcntl(F_SETFL) failed");
        }
    }
    Ok(())
}

/// Daemon directory for a workspace. Uses `<target_dir>/cargo-brief-lsp/<hash>`
/// so the FIFOs live inside the project's target directory (sandbox-friendly).
/// Canonicalizes the workspace root to avoid duplicate daemons from symlinks.
pub fn daemon_dir(target_dir: &Path, workspace_root: &Path) -> PathBuf {
    let canonical = workspace_root
        .canonicalize()
        .unwrap_or_else(|_| workspace_root.to_path_buf());
    let hash = short_hash(&canonical);
    target_dir.join("cargo-brief-lsp").join(hash)
}

/// Ensure daemon is running. Returns the daemon directory path.
/// Liveness check: PID file alive + `lsp.req` FIFO exists (readiness invariant).
pub fn ensure_daemon(target_dir: &Path, workspace_root: &Path, verbose: bool) -> Result<PathBuf> {
    let dir = daemon_dir(target_dir, workspace_root);
    let pid_file = dir.join("lsp.pid");
    let req_fifo = dir.join("lsp.req");

    // Check if existing daemon is alive and ready (FIFOs exist)
    if req_fifo.exists()
        && pid_file.exists()
        && let Ok(pid_str) = std::fs::read_to_string(&pid_file)
        && let Ok(pid) = pid_str.trim().parse::<u32>()
        && process_alive(pid)
    {
        if verbose {
            eprintln!("[lsp] daemon already running (PID {pid})");
        }
        return Ok(dir);
    }

    // Check for stale PID file
    if pid_file.exists()
        && let Ok(pid_str) = std::fs::read_to_string(&pid_file)
        && let Ok(pid) = pid_str.trim().parse::<u32>()
        && !process_alive(pid)
    {
        if verbose {
            eprintln!("[lsp] cleaning up stale daemon (PID {pid})");
        }
        cleanup_daemon_files(&dir);
    }

    // Spawn daemon process
    std::fs::create_dir_all(&dir)
        .with_context(|| format!("Failed to create daemon dir: {}", dir.display()))?;

    let log_path = dir.join("lsp.log");

    if verbose {
        eprintln!("[lsp] spawning daemon for {}", workspace_root.display());
    }
    let mut child = spawn_daemon(workspace_root, &dir, &log_path)?;

    // Wait for FIFO to appear (daemon creates FIFOs after ra init)
    wait_for_daemon(&dir, Duration::from_secs(120), &mut child, &log_path)?;
    Ok(dir)
}

/// Send a request to the daemon via FIFO and return the response.
/// Uses `flock` on `lsp.lock` to serialize concurrent clients.
pub fn send_command(
    daemon_dir: &Path,
    request: DaemonRequest,
    timeout: Duration,
) -> Result<DaemonResponse> {
    let lock_path = daemon_dir.join("lsp.lock");
    let req_path = daemon_dir.join("lsp.req");
    let resp_path = daemon_dir.join("lsp.resp");

    // 1. Acquire exclusive lock
    let lock_file = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(&lock_path)
        .context("Failed to open lock file")?;
    flock_exclusive(&lock_file)?;

    // 2. Open req FIFO for writing (blocks until daemon has read-end — instant)
    let mut req_fd = OpenOptions::new()
        .write(true)
        .open(&req_path)
        .context("Failed to open request FIFO")?;

    // 3. Write request, then drop to signal we're done writing
    write_message(&mut req_fd, &request)?;
    drop(req_fd);

    // 4. Open resp FIFO for reading (non-blocking initially for drain + poll)
    let resp_fd = OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NONBLOCK)
        .open(&resp_path)
        .context("Failed to open response FIFO")?;

    // 4a. Drain stale data from resp FIFO (from a previously crashed client)
    let mut drain_buf = [0u8; 4096];
    loop {
        // SAFETY: read on a valid fd with a stack-allocated buffer.
        let n = unsafe {
            libc::read(
                resp_fd.as_raw_fd(),
                drain_buf.as_mut_ptr() as *mut libc::c_void,
                drain_buf.len(),
            )
        };
        if n <= 0 {
            break;
        }
    }

    // 5. Poll for response with timeout (EINTR-safe)
    let timeout_ms: libc::c_int = timeout.as_millis().try_into().unwrap_or(libc::c_int::MAX);
    let mut pfd = libc::pollfd {
        fd: resp_fd.as_raw_fd(),
        events: libc::POLLIN,
        revents: 0,
    };
    let n = poll_retry(&mut pfd, timeout_ms)?;
    if n == 0 {
        bail!(
            "Timed out waiting for daemon response ({}s)",
            timeout.as_secs()
        );
    }

    // 6. Switch to blocking and read the response
    set_nonblocking(&resp_fd, false)?;
    let mut resp_fd = resp_fd;
    let response: DaemonResponse = read_message(&mut resp_fd)?;

    // 7. flock auto-released on lock_fd drop
    Ok(response)
}

/// Remove daemon files (FIFOs, PID, lock, log) from a daemon directory.
pub(super) fn cleanup_daemon_files(dir: &Path) {
    for name in ["lsp.pid", "lsp.req", "lsp.resp", "lsp.lock", "lsp.log"] {
        std::fs::remove_file(dir.join(name)).ok();
    }
}

/// Spawn the daemon via re-exec. Returns the Child handle.
fn spawn_daemon(workspace_root: &Path, daemon_dir: &Path, log_path: &Path) -> Result<Child> {
    use std::os::unix::process::CommandExt;

    let exe = std::env::current_exe().context("Failed to get current executable path")?;

    let ws_str = workspace_root
        .to_str()
        .context("Non-UTF8 workspace root path")?;
    let dir_str = daemon_dir.to_str().context("Non-UTF8 daemon dir path")?;

    let log_file = File::create(log_path)
        .with_context(|| format!("Failed to create daemon log: {}", log_path.display()))?;

    let child = Command::new(exe)
        .args([
            "__lsp-daemon",
            "--workspace-root",
            ws_str,
            "--daemon-dir",
            dir_str,
        ])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::from(log_file))
        .process_group(0)
        .spawn()
        .context("Failed to spawn LSP daemon process")?;

    Ok(child)
}

/// Wait for the daemon's `lsp.req` FIFO to appear (readiness signal).
/// Uses `child.try_wait()` each iteration for fast failure detection.
fn wait_for_daemon(
    daemon_dir: &Path,
    timeout: Duration,
    child: &mut Child,
    log_path: &Path,
) -> Result<()> {
    let start = Instant::now();
    let mut interval = Duration::from_millis(50);
    let pid = child.id();
    let req_fifo = daemon_dir.join("lsp.req");

    while start.elapsed() < timeout {
        if req_fifo.exists() {
            return Ok(());
        }

        // Check if daemon died before FIFOs appeared (try_wait reaps zombies)
        if let Ok(Some(_status)) = child.try_wait() {
            let tail = read_log_tail(log_path, 20);
            let log_section = if tail.is_empty() {
                "(no log output)".to_string()
            } else {
                tail
            };
            bail!(
                "LSP daemon (PID {pid}) died during startup.\n\
                 Daemon log:\n{log_section}"
            );
        }

        std::thread::sleep(interval);
        // Exponential backoff up to 500ms
        interval = (interval * 2).min(Duration::from_millis(500));
    }

    let tail = read_log_tail(log_path, 20);
    let log_section = if tail.is_empty() {
        "(no log output)".to_string()
    } else {
        tail
    };
    bail!(
        "Timed out waiting for LSP daemon after {}s.\n\
         Daemon dir: {}\n\
         Daemon log:\n{log_section}",
        timeout.as_secs(),
        daemon_dir.display()
    )
}

/// Read the last `max_lines` lines from a file. Returns empty string on any error.
fn read_log_tail(path: &Path, max_lines: usize) -> String {
    let Ok(content) = std::fs::read_to_string(path) else {
        return String::new();
    };
    let lines: Vec<&str> = content.lines().collect();
    let start = lines.len().saturating_sub(max_lines);
    lines[start..].join("\n")
}

/// Check if a process is alive via kill(pid, 0).
fn process_alive(pid: u32) -> bool {
    let Ok(pid) = libc::pid_t::try_from(pid) else {
        return false;
    };
    // SAFETY: kill(pid, 0) with signal 0 only checks process existence.
    unsafe { libc::kill(pid, 0) == 0 }
}

/// FNV-1a 64-bit hash of a path, hex-encoded. Deterministic across Rust versions.
fn short_hash(path: &Path) -> String {
    let bytes = path.as_os_str().as_encoded_bytes();
    let mut hash: u64 = 0xcbf29ce484222325;
    for &b in bytes {
        hash ^= b as u64;
        hash = hash.wrapping_mul(0x100000001b3);
    }
    format!("{hash:016x}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hash_deterministic() {
        let h1 = short_hash(Path::new("/home/user/project"));
        let h2 = short_hash(Path::new("/home/user/project"));
        assert_eq!(h1, h2);
    }

    #[test]
    fn hash_differs_for_different_paths() {
        let h1 = short_hash(Path::new("/home/user/project-a"));
        let h2 = short_hash(Path::new("/home/user/project-b"));
        assert_ne!(h1, h2);
    }

    #[test]
    fn hash_is_16_hex_chars() {
        let h = short_hash(Path::new("/some/path"));
        assert_eq!(h.len(), 16);
        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn log_tail_more_than_max() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.log");
        let content: String = (1..=30).map(|i| format!("line {i}\n")).collect();
        std::fs::write(&path, &content).unwrap();
        let tail = read_log_tail(&path, 20);
        let lines: Vec<&str> = tail.lines().collect();
        assert_eq!(lines.len(), 20);
        assert_eq!(lines[0], "line 11");
        assert_eq!(lines[19], "line 30");
    }

    #[test]
    fn log_tail_fewer_than_max() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.log");
        std::fs::write(&path, "line 1\nline 2\nline 3\n").unwrap();
        let tail = read_log_tail(&path, 20);
        let lines: Vec<&str> = tail.lines().collect();
        assert_eq!(lines.len(), 3);
        assert_eq!(lines[0], "line 1");
    }

    #[test]
    fn log_tail_nonexistent_file() {
        let tail = read_log_tail(Path::new("/nonexistent/file.log"), 20);
        assert!(tail.is_empty());
    }

    #[test]
    fn log_tail_empty_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("empty.log");
        std::fs::write(&path, "").unwrap();
        let tail = read_log_tail(&path, 20);
        assert!(tail.is_empty());
    }

    #[test]
    fn create_fifo_creates_pipe() {
        use std::os::unix::fs::FileTypeExt;
        let dir = tempfile::tempdir().unwrap();
        let fifo = dir.path().join("test.fifo");
        create_fifo(&fifo, 0o600).unwrap();
        assert!(std::fs::metadata(&fifo).unwrap().file_type().is_fifo());
    }

    #[test]
    fn create_fifo_idempotent() {
        let dir = tempfile::tempdir().unwrap();
        let fifo = dir.path().join("test.fifo");
        create_fifo(&fifo, 0o600).unwrap();
        create_fifo(&fifo, 0o600).unwrap(); // second call succeeds (EEXIST ignored)
    }

    #[test]
    fn flock_exclusive_blocks_second() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.lock");
        let f1 = File::create(&path).unwrap();
        flock_exclusive(&f1).unwrap();

        // Try non-blocking lock — should fail with EWOULDBLOCK
        let f2 = File::open(&path).unwrap();
        let ret = unsafe { libc::flock(f2.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
        assert_ne!(ret, 0);
        let err = std::io::Error::last_os_error();
        assert_eq!(err.raw_os_error(), Some(libc::EWOULDBLOCK));
    }

    #[test]
    fn set_nonblocking_toggles_flag() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.file");
        let f = File::create(&path).unwrap();

        set_nonblocking(&f, true).unwrap();
        let flags = unsafe { libc::fcntl(f.as_raw_fd(), libc::F_GETFL) };
        assert_ne!(flags & libc::O_NONBLOCK, 0);

        set_nonblocking(&f, false).unwrap();
        let flags = unsafe { libc::fcntl(f.as_raw_fd(), libc::F_GETFL) };
        assert_eq!(flags & libc::O_NONBLOCK, 0);
    }
}