gabb_cli/
daemon.rs

1use crate::indexer::{build_full_index, index_one, is_indexed_file, remove_if_tracked};
2use crate::store::{DbOpenResult, IndexStore, RegenerationReason};
3use crate::OutputFormat;
4use anyhow::{bail, Context, Result};
5use log::{debug, info, warn};
6use notify::event::{ModifyKind, RenameMode};
7use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12use std::sync::mpsc;
13use std::time::Duration;
14
15/// PID file content structure
16#[derive(Debug, Serialize, Deserialize)]
17pub struct PidFile {
18    pub pid: u32,
19    pub version: String,
20    pub schema_version: String,
21    pub started_at: String,
22}
23
24impl PidFile {
25    fn new(pid: u32) -> Self {
26        Self {
27            pid,
28            version: env!("CARGO_PKG_VERSION").to_string(),
29            schema_version: format!(
30                "{}.{}",
31                crate::store::SCHEMA_MAJOR,
32                crate::store::SCHEMA_MINOR
33            ),
34            started_at: chrono_lite_now(),
35        }
36    }
37}
38
39/// Simple ISO 8601 timestamp without chrono dependency
40fn chrono_lite_now() -> String {
41    use std::time::SystemTime;
42    let duration = SystemTime::now()
43        .duration_since(SystemTime::UNIX_EPOCH)
44        .unwrap_or_default();
45    // Basic ISO format: we'll just use seconds since epoch for simplicity
46    format!("{}", duration.as_secs())
47}
48
49/// Get the path to the PID file for a workspace
50fn pid_file_path(root: &Path) -> PathBuf {
51    root.join(".gabb").join("daemon.pid")
52}
53
54/// Read the PID file for a workspace
55pub fn read_pid_file(root: &Path) -> Result<Option<PidFile>> {
56    let path = pid_file_path(root);
57    if !path.exists() {
58        return Ok(None);
59    }
60    let mut file = fs::File::open(&path)?;
61    let mut contents = String::new();
62    file.read_to_string(&mut contents)?;
63    let pid_file: PidFile = serde_json::from_str(&contents)?;
64    Ok(Some(pid_file))
65}
66
67/// Write the PID file for a workspace
68fn write_pid_file(root: &Path, pid_file: &PidFile) -> Result<()> {
69    let path = pid_file_path(root);
70    // Ensure .gabb directory exists
71    if let Some(parent) = path.parent() {
72        fs::create_dir_all(parent)?;
73    }
74    let mut file = fs::File::create(&path)?;
75    let contents = serde_json::to_string_pretty(pid_file)?;
76    file.write_all(contents.as_bytes())?;
77    Ok(())
78}
79
80/// Remove the PID file for a workspace
81fn remove_pid_file(root: &Path) -> Result<()> {
82    let path = pid_file_path(root);
83    if path.exists() {
84        fs::remove_file(&path)?;
85    }
86    Ok(())
87}
88
89/// Check if a process with the given PID is running
90pub fn is_process_running(pid: u32) -> bool {
91    // Use kill with signal 0 to check if process exists
92    unsafe { libc::kill(pid as i32, 0) == 0 }
93}
94
95/// Get the path to the lock file for a workspace
96fn lock_file_path(root: &Path) -> PathBuf {
97    root.join(".gabb").join("daemon.lock")
98}
99
100/// A guard that holds the lock file open and releases it on drop
101pub struct LockFileGuard {
102    _file: fs::File,
103    path: PathBuf,
104}
105
106impl Drop for LockFileGuard {
107    fn drop(&mut self) {
108        // Lock is automatically released when file is closed
109        // Optionally remove the lock file
110        let _ = fs::remove_file(&self.path);
111    }
112}
113
114/// Acquire an exclusive lock on the workspace.
115/// Returns a guard that releases the lock when dropped.
116fn acquire_lock(root: &Path) -> Result<LockFileGuard> {
117    use std::os::unix::io::AsRawFd;
118
119    let path = lock_file_path(root);
120
121    // Ensure .gabb directory exists
122    if let Some(parent) = path.parent() {
123        fs::create_dir_all(parent)?;
124    }
125
126    // Open or create the lock file
127    let file = fs::OpenOptions::new()
128        .read(true)
129        .write(true)
130        .create(true)
131        .truncate(false)
132        .open(&path)
133        .with_context(|| format!("failed to open lock file {}", path.display()))?;
134
135    // Try to acquire exclusive lock (non-blocking)
136    let fd = file.as_raw_fd();
137    let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
138
139    if result != 0 {
140        let err = std::io::Error::last_os_error();
141        if err.kind() == std::io::ErrorKind::WouldBlock {
142            bail!(
143                "Another daemon is already running for this workspace.\n\
144                 Use 'gabb daemon status' to check or 'gabb daemon stop' to stop it."
145            );
146        }
147        return Err(err).with_context(|| "failed to acquire lock");
148    }
149
150    // Write our PID to the lock file for debugging
151    use std::io::Seek;
152    let mut file = file;
153    file.set_len(0)?;
154    file.seek(std::io::SeekFrom::Start(0))?;
155    writeln!(file, "{}", std::process::id())?;
156
157    Ok(LockFileGuard { _file: file, path })
158}
159
160/// Start the indexing daemon
161pub fn start(
162    root: &Path,
163    db_path: &Path,
164    rebuild: bool,
165    background: bool,
166    log_file: Option<&Path>,
167) -> Result<()> {
168    if background {
169        return start_background(root, db_path, rebuild, log_file);
170    }
171    run_foreground(root, db_path, rebuild)
172}
173
174/// Start daemon in background (daemonize)
175fn start_background(
176    root: &Path,
177    db_path: &Path,
178    rebuild: bool,
179    log_file: Option<&Path>,
180) -> Result<()> {
181    let root = root
182        .canonicalize()
183        .with_context(|| format!("failed to canonicalize root {}", root.display()))?;
184
185    // Check if daemon is already running
186    if let Some(pid_info) = read_pid_file(&root)? {
187        if is_process_running(pid_info.pid) {
188            bail!(
189                "Daemon already running (PID {}). Use 'gabb daemon stop' first.",
190                pid_info.pid
191            );
192        }
193        // Stale PID file - remove it
194        remove_pid_file(&root)?;
195    }
196
197    // Determine log file path
198    let log_path = log_file
199        .map(|p| p.to_path_buf())
200        .unwrap_or_else(|| root.join(".gabb").join("daemon.log"));
201
202    // Ensure .gabb directory exists
203    fs::create_dir_all(root.join(".gabb"))?;
204
205    // Fork the process
206    use std::process::Command;
207    let db_arg = if db_path.is_absolute() {
208        db_path.to_path_buf()
209    } else {
210        root.join(db_path)
211    };
212
213    let exe = std::env::current_exe()?;
214    let mut cmd = Command::new(exe);
215    cmd.arg("daemon")
216        .arg("start")
217        .arg("--root")
218        .arg(&root)
219        .arg("--db")
220        .arg(&db_arg);
221
222    if rebuild {
223        cmd.arg("--rebuild");
224    }
225
226    // Redirect stdout/stderr to log file
227    let log_file_handle = fs::OpenOptions::new()
228        .create(true)
229        .append(true)
230        .open(&log_path)
231        .with_context(|| format!("failed to open log file {}", log_path.display()))?;
232
233    cmd.stdout(log_file_handle.try_clone()?);
234    cmd.stderr(log_file_handle);
235
236    // Detach from terminal
237    #[cfg(unix)]
238    {
239        use std::os::unix::process::CommandExt;
240        cmd.process_group(0);
241    }
242
243    let child = cmd.spawn().context("failed to spawn daemon process")?;
244
245    // Give the daemon a moment to start
246    std::thread::sleep(Duration::from_millis(100));
247
248    info!(
249        "Daemon started in background (PID {}). Log: {}",
250        child.id(),
251        log_path.display()
252    );
253
254    Ok(())
255}
256
257/// Run the daemon in the foreground
258fn run_foreground(root: &Path, db_path: &Path, rebuild: bool) -> Result<()> {
259    let root = root
260        .canonicalize()
261        .with_context(|| format!("failed to canonicalize root {}", root.display()))?;
262
263    // Acquire exclusive lock to prevent multiple daemons
264    let _lock_guard = acquire_lock(&root)?;
265    debug!("Acquired workspace lock");
266
267    // Check if daemon is already running (belt and suspenders with lock)
268    if let Some(pid_info) = read_pid_file(&root)? {
269        if is_process_running(pid_info.pid) {
270            bail!(
271                "Daemon already running (PID {}). Use 'gabb daemon stop' first.",
272                pid_info.pid
273            );
274        }
275        // Stale PID file - remove it
276        remove_pid_file(&root)?;
277    }
278
279    // Write PID file
280    let pid = std::process::id();
281    let pid_file = PidFile::new(pid);
282    write_pid_file(&root, &pid_file)?;
283    info!("Daemon started (PID {})", pid);
284
285    // Set up cleanup on exit
286    let root_for_cleanup = root.clone();
287
288    // Set up signal handling for graceful shutdown
289    let (shutdown_tx, shutdown_rx) = mpsc::channel();
290    #[cfg(unix)]
291    {
292        use std::sync::atomic::{AtomicBool, Ordering};
293        use std::sync::Arc;
294
295        let running = Arc::new(AtomicBool::new(true));
296        let r = running.clone();
297
298        ctrlc::set_handler(move || {
299            r.store(false, Ordering::SeqCst);
300            let _ = shutdown_tx.send(());
301        })
302        .ok();
303    }
304
305    info!("Opening index at {}", db_path.display());
306
307    // Handle explicit rebuild request
308    if rebuild && db_path.exists() {
309        info!("{}", RegenerationReason::UserRequested.message());
310        info!("Regenerating index...");
311        let _ = fs::remove_file(db_path);
312    }
313
314    // Try to open with version checking
315    let store = if rebuild {
316        // After explicit rebuild, just open fresh
317        IndexStore::open(db_path)?
318    } else {
319        match IndexStore::try_open(db_path)? {
320            DbOpenResult::Ready(store) => store,
321            DbOpenResult::NeedsRegeneration { reason, path } => {
322                warn!("{}", reason.message());
323                info!("Regenerating index (this may take a minute for large codebases)...");
324                if path.exists() {
325                    let _ = fs::remove_file(&path);
326                }
327                IndexStore::open(db_path)?
328            }
329        }
330    };
331
332    build_full_index(&root, &store)?;
333
334    let (tx, rx) = mpsc::channel();
335    let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
336        if tx.send(res).is_err() {
337            eprintln!("watcher channel closed");
338        }
339    })?;
340    watcher.watch(&root, RecursiveMode::Recursive)?;
341
342    info!("Watching {} for changes", root.display());
343    loop {
344        // Check for shutdown signal
345        if shutdown_rx.try_recv().is_ok() {
346            info!("Received shutdown signal");
347            break;
348        }
349
350        match rx.recv_timeout(Duration::from_secs(1)) {
351            Ok(Ok(event)) => {
352                if let Err(err) = handle_event(&root, &store, event) {
353                    warn!("failed to handle event: {err:#}");
354                }
355            }
356            Ok(Err(err)) => warn!("watch error: {err}"),
357            Err(mpsc::RecvTimeoutError::Timeout) => {
358                // continue loop to keep watcher alive
359            }
360            Err(mpsc::RecvTimeoutError::Disconnected) => break,
361        }
362    }
363
364    // Clean up PID file on exit
365    remove_pid_file(&root_for_cleanup)?;
366    info!("Daemon stopped");
367
368    Ok(())
369}
370
371/// Stop a running daemon
372pub fn stop(root: &Path, force: bool) -> Result<()> {
373    let root = root
374        .canonicalize()
375        .with_context(|| format!("failed to canonicalize root {}", root.display()))?;
376
377    let pid_info = read_pid_file(&root)?;
378    match pid_info {
379        None => {
380            info!("No daemon running (no PID file found)");
381            std::process::exit(1);
382        }
383        Some(pid_info) => {
384            if !is_process_running(pid_info.pid) {
385                info!("Daemon not running (stale PID file). Cleaning up.");
386                remove_pid_file(&root)?;
387                std::process::exit(1);
388            }
389
390            let signal = if force {
391                info!("Forcefully killing daemon (PID {})", pid_info.pid);
392                libc::SIGKILL
393            } else {
394                info!("Sending shutdown signal to daemon (PID {})", pid_info.pid);
395                libc::SIGTERM
396            };
397
398            unsafe {
399                libc::kill(pid_info.pid as i32, signal);
400            }
401
402            // Wait for process to exit (with timeout)
403            let max_wait = if force {
404                Duration::from_secs(2)
405            } else {
406                Duration::from_secs(10)
407            };
408            let start = std::time::Instant::now();
409
410            while is_process_running(pid_info.pid) && start.elapsed() < max_wait {
411                std::thread::sleep(Duration::from_millis(100));
412            }
413
414            if is_process_running(pid_info.pid) {
415                if !force {
416                    warn!("Daemon did not stop gracefully. Use --force to kill immediately.");
417                    std::process::exit(1);
418                }
419            } else {
420                info!("Daemon stopped");
421                // Clean up PID file if still present
422                remove_pid_file(&root)?;
423            }
424        }
425    }
426
427    Ok(())
428}
429
430/// Restart the daemon
431pub fn restart(root: &Path, db_path: &Path, rebuild: bool) -> Result<()> {
432    let root = root
433        .canonicalize()
434        .with_context(|| format!("failed to canonicalize root {}", root.display()))?;
435
436    // Try to stop existing daemon
437    if let Some(pid_info) = read_pid_file(&root)? {
438        if is_process_running(pid_info.pid) {
439            info!("Stopping existing daemon (PID {})", pid_info.pid);
440            stop(&root, false).ok();
441
442            // Wait a bit for clean shutdown
443            std::thread::sleep(Duration::from_millis(500));
444        } else {
445            // Stale PID file
446            remove_pid_file(&root)?;
447        }
448    }
449
450    // Start new daemon in background
451    start(&root, db_path, rebuild, true, None)
452}
453
454/// Show daemon status
455pub fn status(root: &Path, format: OutputFormat) -> Result<()> {
456    let root = root
457        .canonicalize()
458        .with_context(|| format!("failed to canonicalize root {}", root.display()))?;
459
460    let pid_info = read_pid_file(&root)?;
461
462    #[derive(Serialize)]
463    struct StatusOutput {
464        running: bool,
465        #[serde(skip_serializing_if = "Option::is_none")]
466        pid: Option<u32>,
467        workspace: String,
468        #[serde(skip_serializing_if = "Option::is_none")]
469        database: Option<String>,
470        #[serde(skip_serializing_if = "Option::is_none")]
471        version: Option<VersionInfo>,
472    }
473
474    #[derive(Serialize)]
475    struct VersionInfo {
476        daemon: String,
477        cli: String,
478        #[serde(rename = "match")]
479        matches: bool,
480        action: String,
481    }
482
483    let cli_version = env!("CARGO_PKG_VERSION").to_string();
484    let db_path = root.join(".gabb").join("index.db");
485
486    let status = match pid_info {
487        Some(pid_info) if is_process_running(pid_info.pid) => {
488            let version_match = pid_info.version == cli_version;
489            let action = if version_match {
490                "none"
491            } else {
492                "suggest_restart"
493            }
494            .to_string();
495
496            StatusOutput {
497                running: true,
498                pid: Some(pid_info.pid),
499                workspace: root.to_string_lossy().to_string(),
500                database: if db_path.exists() {
501                    Some(db_path.to_string_lossy().to_string())
502                } else {
503                    None
504                },
505                version: Some(VersionInfo {
506                    daemon: pid_info.version,
507                    cli: cli_version,
508                    matches: version_match,
509                    action,
510                }),
511            }
512        }
513        _ => StatusOutput {
514            running: false,
515            pid: None,
516            workspace: root.to_string_lossy().to_string(),
517            database: if db_path.exists() {
518                Some(db_path.to_string_lossy().to_string())
519            } else {
520                None
521            },
522            version: None,
523        },
524    };
525
526    match format {
527        OutputFormat::Json => {
528            println!("{}", serde_json::to_string_pretty(&status)?);
529        }
530        OutputFormat::Jsonl => {
531            println!("{}", serde_json::to_string(&status)?);
532        }
533        OutputFormat::Text | OutputFormat::Csv | OutputFormat::Tsv => {
534            if status.running {
535                println!("Daemon: running (PID {})", status.pid.unwrap_or(0));
536                if let Some(ref ver) = status.version {
537                    println!("Version: {} (CLI: {})", ver.daemon, ver.cli);
538                    if !ver.matches {
539                        println!("Warning: version mismatch - consider restarting daemon");
540                    }
541                }
542            } else {
543                println!("Daemon: not running");
544            }
545            println!("Workspace: {}", status.workspace);
546            if let Some(ref db) = status.database {
547                println!("Database: {}", db);
548            } else {
549                println!("Database: not found (index not created)");
550            }
551        }
552    }
553
554    // Exit with code 1 if not running (for scripting)
555    if !status.running {
556        std::process::exit(1);
557    }
558
559    Ok(())
560}
561
562fn handle_event(root: &Path, store: &IndexStore, event: Event) -> Result<()> {
563    let paths: Vec<PathBuf> = event
564        .paths
565        .into_iter()
566        .filter_map(|p| normalize_event_path(root, p))
567        .collect();
568
569    match event.kind {
570        EventKind::Modify(ModifyKind::Name(RenameMode::From)) | EventKind::Remove(_) => {
571            for path in paths {
572                remove_if_tracked(&path, store)?;
573            }
574        }
575        EventKind::Modify(ModifyKind::Name(RenameMode::To))
576        | EventKind::Create(_)
577        | EventKind::Modify(_) => {
578            for path in paths {
579                if is_indexed_file(&path) && path.is_file() {
580                    index_one(&path, store)?;
581                }
582            }
583        }
584        _ => debug!("ignoring event {:?}", event.kind),
585    }
586    Ok(())
587}
588
589fn normalize_event_path(root: &Path, path: PathBuf) -> Option<PathBuf> {
590    if path.is_absolute() {
591        Some(path)
592    } else {
593        Some(root.join(path))
594    }
595}