use std::ffi::OsStr;
use std::fs;
use std::path::Path;
use std::time::Duration;
use crate::config::Config;
use crate::store::Store;
use crate::store::marker::WATCH_MARKER_STALE_SECS;
use crate::store::marker::pending_index;
use crate::util::now_rfc3339;
pub(super) fn spawn_background_index(project_root: &Path, config: &Config) -> bool {
let Ok(store) = Store::new(project_root, config) else {
return false;
};
if store.full_index_running() {
return false;
}
if store.ensure_layout().is_err() {
return false;
}
let marker_path = store.pending_index_marker_path();
let manifest = store.read_manifest().ok().flatten();
if let Some(ref manifest) = manifest
&& manifest.chunk_count > 0
&& manifest.embedding_model != config.embedding.model
{
return false;
}
if let Some(ref manifest) = manifest
&& manifest.chunk_count > 0
&& !manifest.is_stale(config)
{
return false;
}
let prior_ts = manifest
.as_ref()
.and_then(|m| m.last_full_index_at.as_deref())
.unwrap_or("none");
let placeholder = format!("{prior_ts}\n{}\n0\n", now_rfc3339());
if !pending_index::try_claim(&marker_path, &placeholder) {
return false;
}
let Some(child_pid) = spawn_detached_claudix(project_root, [OsStr::new("index")]) else {
let _ = fs::remove_file(&marker_path);
return false;
};
let payload = format!("{prior_ts}\n{}\n{child_pid}\n", now_rfc3339());
let _ = fs::write(&marker_path, payload);
true
}
const BACKGROUND_SENTINEL: &str = "CLAUDIX_BACKGROUND";
pub(super) fn spawn_detached_claudix<const N: usize, S>(
project_root: &Path,
args: [S; N],
) -> Option<u32>
where
S: AsRef<OsStr>,
{
if std::env::var_os(BACKGROUND_SENTINEL).is_some() {
return None;
}
let binary = std::env::current_exe().ok()?;
#[cfg(test)]
let args = {
let _ = args;
[
OsStr::new("__claudix_no_such_test__"),
OsStr::new("--exact"),
]
};
spawn_detached_command(project_root, binary.as_os_str(), args)
}
pub(super) fn spawn_background_watch(project_root: &Path, config: &Config) -> bool {
if !config.watch || !config.hooks.auto_reembed_on_edit {
return false;
}
let Ok(store) = Store::new(project_root, config) else {
return false;
};
if store.ensure_layout().is_err() {
return false;
}
let marker_path = store.watch_marker_path();
let stale_after = Duration::from_secs(WATCH_MARKER_STALE_SECS);
if crate::store::marker::try_claim(&marker_path, stale_after).is_err() {
return false;
}
let Some(child_pid) = spawn_detached_claudix(project_root, [OsStr::new("watch")]) else {
let _ = fs::remove_file(&marker_path);
return false;
};
let _ = fs::write(&marker_path, child_pid.to_string());
true
}
pub(super) fn spawn_background_reindex_file(project_root: &Path, file_path: &str) {
spawn_detached_claudix(
project_root,
[OsStr::new("reindex-file"), OsStr::new(file_path)],
);
}
#[cfg(unix)]
fn spawn_detached_command<const N: usize, S>(
project_root: &Path,
binary: &OsStr,
args: [S; N],
) -> Option<u32>
where
S: AsRef<OsStr>,
{
use std::os::unix::process::CommandExt;
std::process::Command::new("nohup")
.arg(binary)
.args(args.iter().map(AsRef::as_ref))
.current_dir(project_root)
.env("CLAUDE_PROJECT_DIR", project_root)
.env(BACKGROUND_SENTINEL, "1")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.process_group(0)
.spawn()
.ok()
.map(|child| child.id())
}
#[cfg(windows)]
fn spawn_detached_command<const N: usize, S>(
project_root: &Path,
binary: &OsStr,
args: [S; N],
) -> Option<u32>
where
S: AsRef<OsStr>,
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
const DETACHED_PROCESS: u32 = 0x0000_0008;
std::process::Command::new(binary)
.args(args.iter().map(AsRef::as_ref))
.current_dir(project_root)
.env("CLAUDE_PROJECT_DIR", project_root)
.env(BACKGROUND_SENTINEL, "1")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.ok()
.map(|child| child.id())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Arc;
use std::time::SystemTime;
use tempfile::tempdir;
use crate::Claudix;
use crate::config::Config;
mod fixture {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/common/fixture.rs"
));
}
mod config_support {
use crate as claudix;
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/common/config_support.rs"
));
}
use config_support::stub_config;
use fixture::TestFixture;
fn write_config(project_root: &Path, config: &Config) {
let claude_dir = project_root.join(".claude");
assert!(fs::create_dir_all(&claude_dir).is_ok());
let config_text = toml::to_string(config);
assert!(config_text.is_ok());
assert!(
fs::write(
claude_dir.join("claudix.toml"),
config_text.ok().unwrap_or_default()
)
.is_ok()
);
}
#[test]
fn watch_marker_with_dead_pid_is_not_alive() {
let dir = tempdir().ok().unwrap_or_else(|| unreachable!());
let marker_path = dir.path().join("watch.pid");
fs::write(&marker_path, "0").unwrap_or_else(|_| unreachable!());
assert!(
!crate::store::marker::is_alive(
&marker_path,
Duration::from_secs(WATCH_MARKER_STALE_SECS)
),
"watch marker with dead PID must be reclaimable"
);
}
#[test]
fn watch_marker_with_live_pid_ignores_mtime() {
let dir = tempdir().ok().unwrap_or_else(|| unreachable!());
let marker_path = dir.path().join("watch.pid");
fs::write(&marker_path, std::process::id().to_string()).unwrap_or_else(|_| unreachable!());
let file = fs::OpenOptions::new()
.write(true)
.open(&marker_path)
.unwrap_or_else(|_| unreachable!());
let stale = SystemTime::now() - Duration::from_secs(WATCH_MARKER_STALE_SECS * 10);
file.set_modified(stale).unwrap_or_else(|_| unreachable!());
drop(file);
assert!(
crate::store::marker::is_alive(
&marker_path,
Duration::from_secs(WATCH_MARKER_STALE_SECS)
),
"watch marker with live PID must stay alive regardless of mtime"
);
}
#[test]
fn watcher_boot_window_marker_with_parent_pid_is_alive() {
let dir = tempdir().ok().unwrap_or_else(|| unreachable!());
let marker_path = dir.path().join("watch.pid");
fs::write(&marker_path, std::process::id().to_string()).unwrap_or_else(|_| unreachable!());
assert!(
crate::store::marker::is_alive(
&marker_path,
Duration::from_secs(WATCH_MARKER_STALE_SECS),
),
"marker with parent PID must be alive during pre-spawn window"
);
}
#[test]
fn watcher_boot_window_fresh_unparseable_marker_is_alive() {
let dir = tempdir().ok().unwrap_or_else(|| unreachable!());
let marker_path = dir.path().join("watch.pid");
fs::write(&marker_path, "").unwrap_or_else(|_| unreachable!());
assert!(
crate::store::marker::is_alive(
&marker_path,
Duration::from_secs(WATCH_MARKER_STALE_SECS),
),
"fresh unparseable marker must be treated as alive (boot-window cover)"
);
}
#[test]
fn watcher_boot_window_stale_unparseable_marker_is_dead() {
let dir = tempdir().ok().unwrap_or_else(|| unreachable!());
let marker_path = dir.path().join("watch.pid");
fs::write(&marker_path, "not-a-pid").unwrap_or_else(|_| unreachable!());
let stale = SystemTime::now() - Duration::from_secs(WATCH_MARKER_STALE_SECS + 1);
let _ = fs::File::open(&marker_path).and_then(|f| f.set_modified(stale));
assert!(
!crate::store::marker::is_alive(
&marker_path,
Duration::from_secs(WATCH_MARKER_STALE_SECS),
),
"stale unparseable marker must be reclaimable after timeout"
);
}
#[tokio::test]
async fn spawn_background_index_skips_when_manifest_fresh_and_populated()
-> crate::error::Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
Claudix::new(fixture.root().to_path_buf(), Arc::new(config.clone()))
.await?
.index_full(&mut ())
.await?;
let store = crate::store::Store::new(fixture.root(), &config)?;
let marker_path = store.pending_index_marker_path();
let _ = fs::remove_file(&marker_path);
assert!(
!spawn_background_index(fixture.root(), &config),
"fresh non-empty matching-model index must not respawn"
);
assert!(
!marker_path.exists(),
"no pending marker should be written when spawn is skipped"
);
Ok(())
}
#[tokio::test]
async fn spawn_background_index_runs_when_manifest_missing() -> crate::error::Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = crate::store::Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
assert!(
spawn_background_index(fixture.root(), &config),
"missing manifest must trigger a spawn"
);
Ok(())
}
}