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#[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
39fn 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 format!("{}", duration.as_secs())
47}
48
49fn pid_file_path(root: &Path) -> PathBuf {
51 root.join(".gabb").join("daemon.pid")
52}
53
54pub 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
67fn write_pid_file(root: &Path, pid_file: &PidFile) -> Result<()> {
69 let path = pid_file_path(root);
70 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
80fn 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
89pub fn is_process_running(pid: u32) -> bool {
91 unsafe { libc::kill(pid as i32, 0) == 0 }
93}
94
95fn lock_file_path(root: &Path) -> PathBuf {
97 root.join(".gabb").join("daemon.lock")
98}
99
100pub struct LockFileGuard {
102 _file: fs::File,
103 path: PathBuf,
104}
105
106impl Drop for LockFileGuard {
107 fn drop(&mut self) {
108 let _ = fs::remove_file(&self.path);
111 }
112}
113
114fn acquire_lock(root: &Path) -> Result<LockFileGuard> {
117 use std::os::unix::io::AsRawFd;
118
119 let path = lock_file_path(root);
120
121 if let Some(parent) = path.parent() {
123 fs::create_dir_all(parent)?;
124 }
125
126 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 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 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
160pub 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
174fn 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 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 remove_pid_file(&root)?;
195 }
196
197 let log_path = log_file
199 .map(|p| p.to_path_buf())
200 .unwrap_or_else(|| root.join(".gabb").join("daemon.log"));
201
202 fs::create_dir_all(root.join(".gabb"))?;
204
205 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 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 #[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 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
257fn 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 let _lock_guard = acquire_lock(&root)?;
265 debug!("Acquired workspace lock");
266
267 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 remove_pid_file(&root)?;
277 }
278
279 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 let root_for_cleanup = root.clone();
287
288 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 if rebuild && db_path.exists() {
309 info!("{}", RegenerationReason::UserRequested.message());
310 info!("Regenerating index...");
311 let _ = fs::remove_file(db_path);
312 }
313
314 let store = if rebuild {
316 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 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 }
360 Err(mpsc::RecvTimeoutError::Disconnected) => break,
361 }
362 }
363
364 remove_pid_file(&root_for_cleanup)?;
366 info!("Daemon stopped");
367
368 Ok(())
369}
370
371pub 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 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 remove_pid_file(&root)?;
423 }
424 }
425 }
426
427 Ok(())
428}
429
430pub 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 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 std::thread::sleep(Duration::from_millis(500));
444 } else {
445 remove_pid_file(&root)?;
447 }
448 }
449
450 start(&root, db_path, rebuild, true, None)
452}
453
454pub 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 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}