#[cfg(feature = "daemon")]
use std::path::Path;
#[cfg(feature = "daemon")]
use crate::kit::{AsyncKit, AsyncReady, DaemonModule};
#[cfg(all(feature = "cli", feature = "daemon"))]
use crate::service::error::to_api_error;
#[cfg(feature = "daemon")]
use crate::service::error::CodeNexusError;
#[cfg(all(feature = "cli", feature = "daemon"))]
use crate::service::error::kit_not_initialized;
#[cfg(all(feature = "cli", feature = "daemon"))]
use crate::service::runtime::kit;
#[cfg(all(feature = "cli", feature = "daemon"))]
use sdforge::forge;
#[cfg(all(feature = "cli", feature = "daemon"))]
use sdforge::prelude::ApiError;
#[cfg(feature = "daemon")]
fn daemon_core(kit: &AsyncKit<AsyncReady>, path: &str, name: &str) -> Result<(), CodeNexusError> {
let watch_path = Path::new(path);
if !watch_path.exists() {
return Err(CodeNexusError::InvalidInput(format!(
"watch path does not exist: {}",
watch_path.display()
)));
}
let daemon = kit.require::<DaemonModule>()?;
daemon.start(watch_path, name)?;
Ok(())
}
#[cfg(all(feature = "cli", feature = "daemon"))]
#[forge(
name = "daemon",
version = "0.3.3",
description = "Run the file-watcher daemon for incremental indexing.",
cli = true
)]
async fn daemon(path: String, name: String) -> Result<(), ApiError> {
let kit = kit().ok_or_else(kit_not_initialized)?;
daemon_core(&kit, &path, &name).map_err(|e| to_api_error(e, "daemon_error"))
}
#[cfg(all(test, feature = "daemon"))]
mod tests {
use super::*;
use crate::kit::{build_kit, AsyncKit, AsyncReady, KitBootstrapConfig};
use std::fs;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use tempfile::TempDir;
fn write_file(dir: &std::path::Path, rel: &str, content: &str) {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn fresh_db_path() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("daemon_svc_testdb");
(dir, path)
}
fn build_kit_for_db(db: &str) -> AsyncKit<AsyncReady> {
let config = KitBootstrapConfig::new(PathBuf::from(db));
tokio::runtime::Runtime::new()
.unwrap()
.block_on(build_kit(&config))
.expect("build_kit")
}
fn build_kit_for_db_with_debounce(db: &str, debounce_ms: u64) -> AsyncKit<AsyncReady> {
let config = KitBootstrapConfig::new(PathBuf::from(db)).with_debounce_ms(debounce_ms);
tokio::runtime::Runtime::new()
.unwrap()
.block_on(build_kit(&config))
.expect("build_kit")
}
#[test]
fn daemon_core_returns_error_for_nonexistent_path() {
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db(db.to_str().unwrap());
let err = daemon_core(&kit, "/nonexistent/path/xyz", "demo")
.expect_err("nonexistent path should error");
assert!(matches!(err, CodeNexusError::InvalidInput(_)));
assert_eq!(err.exit_code(), 2, "input error → exit 2");
}
#[test]
fn daemon_core_error_message_contains_path() {
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db(db.to_str().unwrap());
let err = daemon_core(&kit, "/no/such/dir", "demo").expect_err("should error");
let msg = err.to_string();
assert!(
msg.contains("/no/such/dir"),
"error message should contain path: {msg}"
);
}
#[serial_test::serial(daemon_core)]
#[test]
fn daemon_core_starts_and_runs() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "main.rs", "fn main() {}\n");
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db_with_debounce(db.to_str().unwrap(), 200);
let watch_path = tmp.path().to_str().unwrap().to_string();
let handle = thread::spawn(move || daemon_core(&kit, &watch_path, "demo"));
thread::sleep(Duration::from_millis(500));
write_file(tmp.path(), "main.rs", "fn main() { /* v2 */ }\n");
thread::sleep(Duration::from_millis(800));
assert!(!handle.is_finished(), "daemon should still be running");
}
#[test]
fn daemon_core_accepts_custom_debounce() {
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db_with_debounce(db.to_str().unwrap(), 500);
let err = daemon_core(&kit, "/nonexistent/path/xyz", "demo")
.expect_err("should error on nonexistent path");
assert!(matches!(err, CodeNexusError::InvalidInput(_)));
}
#[test]
fn daemon_core_accepts_default_debounce() {
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db(db.to_str().unwrap());
let err = daemon_core(&kit, "/nonexistent/path/xyz", "demo")
.expect_err("should error on nonexistent path");
assert!(matches!(err, CodeNexusError::InvalidInput(_)));
}
#[serial_test::serial(daemon_core)]
#[test]
fn daemon_core_triggers_incremental_index_on_code_file_change() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "main.rs", "fn main() {}\n");
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db_with_debounce(db.to_str().unwrap(), 200);
let watch_path = tmp.path().to_str().unwrap().to_string();
let handle = thread::spawn(move || daemon_core(&kit, &watch_path, "demo"));
thread::sleep(Duration::from_millis(500));
write_file(tmp.path(), "main.rs", "fn main() { /* modified */ }\n");
thread::sleep(Duration::from_millis(1000));
assert!(!handle.is_finished(), "daemon should still be running");
}
#[serial_test::serial(daemon_core)]
#[test]
fn daemon_core_ignores_non_code_file_changes() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "main.rs", "fn main() {}\n");
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db_with_debounce(db.to_str().unwrap(), 200);
let watch_path = tmp.path().to_str().unwrap().to_string();
let handle = thread::spawn(move || daemon_core(&kit, &watch_path, "demo"));
thread::sleep(Duration::from_millis(500));
write_file(tmp.path(), "notes.txt", "hello world\n");
write_file(tmp.path(), "config.json", "{}\n");
thread::sleep(Duration::from_millis(500));
assert!(!handle.is_finished(), "daemon should still be running");
}
#[serial_test::serial(kit_init)]
#[cfg(feature = "cli")]
#[test]
fn daemon_wrapper_succeeds_via_init_kit() {
use crate::service::runtime::{init_kit, reset_kit_for_testing};
reset_kit_for_testing();
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "main.rs", "fn main() {}\n");
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db_with_debounce(db.to_str().unwrap(), 200);
init_kit(kit).expect("init_kit");
let watch_path = tmp.path().to_str().unwrap().to_string();
#[allow(clippy::result_large_err)]
let handle = thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("runtime");
rt.block_on(daemon(watch_path, "demo".to_string()))
});
thread::sleep(Duration::from_millis(500));
assert!(
!handle.is_finished(),
"daemon should be running — kit init succeeded"
);
reset_kit_for_testing();
}
#[serial_test::serial(kit_init)]
#[cfg(feature = "cli")]
#[test]
fn daemon_wrapper_fails_when_kit_not_initialized() {
use crate::service::runtime::reset_kit_for_testing;
reset_kit_for_testing();
let rt = tokio::runtime::Runtime::new().expect("runtime");
let result = rt.block_on(daemon(
"/nonexistent/path/xyz".to_string(),
"demo".to_string(),
));
assert!(result.is_err(), "wrapper should fail without kit");
reset_kit_for_testing();
}
}