mod client;
mod resolve;
mod search;
mod server;
mod types;
pub use client::{DaemonClient, SearchResultsIter};
pub use resolve::socket_path;
pub use search::{execute_search, execute_search_progressive};
pub use server::DaemonServer;
pub use types::{
ClientMessage, DaemonSockError, DaemonStatus, FileChange, FileOp, SearchQuery, SearchResults,
ServerMessage, ShutdownNotice,
};
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn socket_path_deterministic() {
let root = PathBuf::from("/tmp/test-project");
let p1 = socket_path(&root);
let p2 = socket_path(&root);
assert_eq!(p1, p2, "same root must produce same socket path");
}
#[test]
fn socket_path_different_roots() {
let r1 = PathBuf::from("/tmp/project-a");
let r2 = PathBuf::from("/tmp/project-b");
assert_ne!(socket_path(&r1), socket_path(&r2));
}
#[test]
fn socket_path_uses_xdg() {
unsafe { std::env::set_var("XDG_RUNTIME_DIR", "/tmp/xdg-test-runtime") };
let p = socket_path(std::path::Path::new("/tmp/some-project"));
assert!(p.starts_with("/tmp/xdg-test-runtime/ixd/"));
assert!(p.extension().is_some_and(|e| e == "sock"));
unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
}
#[test]
fn from_notify_kind_maps_rename_correctly() {
use notify::EventKind;
use notify::event::ModifyKind;
let kind = EventKind::Modify(ModifyKind::Name(notify::event::RenameMode::To));
assert_eq!(FileOp::from_notify_kind(kind), FileOp::Rename);
let kind = EventKind::Modify(ModifyKind::Name(notify::event::RenameMode::From));
assert_eq!(FileOp::from_notify_kind(kind), FileOp::Rename);
let kind = EventKind::Modify(ModifyKind::Name(notify::event::RenameMode::Both));
assert_eq!(FileOp::from_notify_kind(kind), FileOp::Rename);
let kind = EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content));
assert_eq!(FileOp::from_notify_kind(kind), FileOp::Modify);
let kind = EventKind::Create(notify::event::CreateKind::File);
assert_eq!(FileOp::from_notify_kind(kind), FileOp::Create);
let kind = EventKind::Remove(notify::event::RemoveKind::File);
assert_eq!(FileOp::from_notify_kind(kind), FileOp::Delete);
}
#[test]
fn server_message_ndjson_roundtrip() {
let msg = ServerMessage::Status {
pid: 1234,
status: "idle".to_string(),
files: 42,
daemon_status: None,
};
let json = serde_json::to_string(&msg).expect("serialize");
assert!(json.contains("\"t\":\"status\""), "tag field present");
assert!(
!json.contains("daemon_status"),
"daemon_status should be omitted when None"
);
let back: ServerMessage = serde_json::from_str(&json).expect("deserialize");
if let ServerMessage::Status {
pid,
status,
files,
daemon_status,
} = back
{
assert_eq!(pid, 1234);
assert_eq!(status, "idle");
assert_eq!(files, 42);
assert_eq!(daemon_status, None);
} else {
panic!("wrong variant after roundtrip");
}
}
#[test]
fn files_changed_roundtrip() {
let msg = ServerMessage::FilesChanged {
batch: vec![FileChange {
path: PathBuf::from("src/main.rs"),
mtime: 1_776_468_629,
op: FileOp::Modify,
}],
timestamp: 1_776_468_629,
};
let json = serde_json::to_string(&msg).expect("serialize");
let back: ServerMessage = serde_json::from_str(&json).expect("deserialize");
if let ServerMessage::FilesChanged { batch, timestamp } = back {
assert_eq!(batch.len(), 1);
assert_eq!(batch[0].path, PathBuf::from("src/main.rs"));
assert_eq!(timestamp, 1_776_468_629);
} else {
panic!("wrong variant");
}
}
#[test]
fn client_message_roundtrip() {
let msg = ClientMessage::HistoryQuery { since: 1000, id: 7 };
let json = serde_json::to_string(&msg).expect("serialize");
let back: ClientMessage = serde_json::from_str(&json).expect("deserialize");
if let ClientMessage::HistoryQuery { since, id } = back {
assert_eq!(since, 1000);
assert_eq!(id, 7);
} else {
panic!("wrong variant");
}
}
#[test]
fn server_client_connect_and_broadcast() {
use std::io::BufReader;
use std::os::unix::net::UnixStream;
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
let mut server = DaemonServer::new(&root).expect("create server");
let sp = server.path().to_path_buf();
let _ = server.start();
let stream = UnixStream::connect(&sp).expect("connect");
let mut client = DaemonClient {
stream: BufReader::new(stream),
};
std::thread::sleep(std::time::Duration::from_millis(200));
server.set_status(&DaemonStatus::Idle, 10);
server.broadcast(&ServerMessage::Status {
pid: 1234,
status: "idle".to_string(),
files: 10,
daemon_status: Some(DaemonStatus::Idle),
});
client
.stream
.get_mut()
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.expect("set timeout");
match client.recv() {
Ok(ServerMessage::Status {
pid,
status,
files,
daemon_status,
}) => {
assert_eq!(pid, 1234);
assert_eq!(status, "idle");
assert_eq!(files, 10);
assert!(daemon_status.is_some());
}
Ok(other) => panic!("expected Status, got {other:?}"),
Err(e) => panic!("recv failed: {e}"),
}
}
#[test]
fn client_query_status() {
use std::io::BufReader;
use std::os::unix::net::UnixStream;
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
let mut server = DaemonServer::new(&root).expect("create server");
let sp = server.path().to_path_buf();
let _ = server.start();
server.set_status(&DaemonStatus::Indexing { entropy: 42 }, 99);
let stream = UnixStream::connect(&sp).expect("connect");
let mut client = DaemonClient {
stream: BufReader::new(stream),
};
std::thread::sleep(std::time::Duration::from_millis(200));
client
.send(&ClientMessage::StatusQuery { id: 123 })
.expect("send query");
client
.stream
.get_mut()
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.expect("set timeout");
match client.recv() {
Ok(ServerMessage::QueryResult {
id,
status,
files,
changes_since,
daemon_status,
last_rebuild_at,
}) => {
eprintln!(
"[JSON] id={id}, status={status}, files={files}, daemon_status={daemon_status:?}, last_rebuild_at={last_rebuild_at:?}"
);
assert_eq!(id, 123);
assert_eq!(status, "indexing (entropy: 42)");
assert_eq!(files, 99);
assert!(changes_since.is_empty());
assert_eq!(daemon_status, Some(DaemonStatus::Indexing { entropy: 42 }));
assert_eq!(last_rebuild_at, None);
}
Ok(other) => panic!("expected QueryResult, got {other:?}"),
Err(e) => panic!("recv failed: {e}"),
}
}
#[test]
fn search_query_defaults_search_path_none() {
let json = r#"{"pattern":"hello"}"#;
let q: SearchQuery = serde_json::from_str(json).expect("deserialize");
assert!(q.search_path.is_none());
}
#[test]
fn search_query_roundtrip_with_search_path() {
let q = SearchQuery {
id: 42,
pattern: "findme".into(),
is_regex: false,
ignore_case: true,
word_boundary: false,
max_results: 10,
context_lines: 2,
file_types: vec!["rs".into()],
decompress: false,
multiline: false,
archive: false,
binary: false,
search_path: Some(PathBuf::from("/abs/path")),
progressive: false,
chunk_size_bytes: 0,
chunk_overlap_bytes: 0,
};
let json = serde_json::to_string(&q).expect("serialize");
let back: SearchQuery = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.id, 42);
assert_eq!(back.pattern, "findme");
assert!(back.ignore_case);
assert_eq!(back.max_results, 10);
assert_eq!(back.context_lines, 2);
assert_eq!(back.file_types, vec!["rs".to_string()]);
assert_eq!(back.search_path, Some(PathBuf::from("/abs/path")));
}
#[test]
fn search_query_omitting_search_path_is_backward_compatible() {
let old_json = r#"{
"pattern": "needle",
"is_regex": true,
"ignore_case": false,
"word_boundary": true,
"max_results": 0,
"context_lines": 3,
"file_types": [],
"decompress": false,
"multiline": true,
"archive": false,
"binary": false
}"#;
let q: SearchQuery = serde_json::from_str(old_json).expect("deserialize old client");
assert_eq!(q.pattern, "needle");
assert!(q.is_regex);
assert!(q.multiline);
assert_eq!(q.context_lines, 3);
assert!(
q.search_path.is_none(),
"missing search_path in old client \u{2192} None"
);
}
#[test]
fn test_shutdown_protocol() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
let mut server = DaemonServer::new(&root).expect("create server");
let _ = server.start();
server.shutdown_notify("test_signal", 100);
std::thread::sleep(std::time::Duration::from_millis(150));
server.set_status(&DaemonStatus::Idle, 0);
}
#[test]
fn test_client_shutdown_ack() {
use std::io::BufReader;
use std::os::unix::net::UnixStream;
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
let mut server = DaemonServer::new(&root).expect("create server");
let sp = server.path().to_path_buf();
let _ = server.start();
let stream = UnixStream::connect(&sp).expect("connect");
let mut client = DaemonClient {
stream: BufReader::new(stream),
};
client
.send(&ClientMessage::Shutdown { ack: true })
.expect("send shutdown ack");
std::thread::sleep(std::time::Duration::from_millis(50));
server.set_status(&DaemonStatus::Idle, 0);
}
}