#![doc = ""]
#![doc = include_str!("../README.md")]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use serde::Serialize;
use tracing::{debug, info, warn};
use cartog_core::detect_language;
use cartog_db::Database;
use cartog_indexer as indexer;
use cartog_indexer::is_ignored_dirname;
use cartog_rag as rag;
mod stale;
pub use stale::{StaleSnapshot, StaleState};
pub struct WatchConfig {
pub root: PathBuf,
pub debounce: Duration,
pub rag_override: Option<bool>,
pub rag_delay: Duration,
pub rag_config: rag::EmbeddingProviderConfig,
pub redact: indexer::RedactionConfig,
pub json_events: bool,
pub pid_lock_dir: Option<PathBuf>,
pub pid_lock_slot: Option<String>,
pub skip_migrations: bool,
pub stale: Option<Arc<StaleState>>,
}
impl WatchConfig {
pub fn new(root: PathBuf) -> Self {
Self {
root,
debounce: Duration::from_secs(5),
rag_override: None,
rag_delay: Duration::from_secs(30),
rag_config: rag::EmbeddingProviderConfig::default(),
redact: indexer::RedactionConfig::default(),
json_events: false,
pid_lock_dir: None,
pid_lock_slot: None,
skip_migrations: false,
stale: None,
}
}
}
pub const WATCH_LOCK_SLOT: &str = "watch";
fn resolve_watch_rag(override_: Option<bool>, embedding_count: u32) -> bool {
override_.unwrap_or(embedding_count > 0)
}
#[derive(Debug, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
enum WatchEvent<'a> {
Started {
root: &'a str,
debounce_ms: u128,
rag: bool,
rag_delay_s: u64,
},
Reindex {
files_indexed: u32,
files_skipped: u32,
files_removed: u32,
symbols_added: u32,
edges_added: u32,
edges_resolved: u32,
duration_ms: u128,
},
ReindexFailed { error: String },
RagEmbedded {
symbols_embedded: u32,
symbols_skipped: u32,
total_content_symbols: u32,
duration_ms: u128,
},
RagFailed { error: String },
Shutdown,
}
fn emit_event(event: &WatchEvent<'_>) {
if let Ok(line) = serde_json::to_string(event) {
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "{line}");
let _ = out.flush();
}
}
pub struct WatchHandle {
shutdown: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl WatchHandle {
pub fn stop(mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.thread.take() {
let _ = handle.join();
}
}
}
impl Drop for WatchHandle {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.thread.take() {
let deadline = std::time::Instant::now() + Duration::from_millis(1500);
while !handle.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(25));
}
if handle.is_finished() {
let _ = handle.join();
}
}
}
}
fn validate_pid_lock_config(config: &WatchConfig) -> Result<()> {
match (
config.pid_lock_dir.is_some(),
config.pid_lock_slot.is_some(),
) {
(true, false) => anyhow::bail!(
"WatchConfig::pid_lock_dir is set but pid_lock_slot is None; \
refusing to claim the global watch slot — pass a DB-scoped slot \
(e.g. `cartog::state::slot_for_db(\"watch\", db_path)`)"
),
(false, true) => anyhow::bail!(
"WatchConfig::pid_lock_slot is set but pid_lock_dir is None; \
a slot without a directory is silently ignored — either set \
both fields or clear both to run in untracked mode"
),
_ => Ok(()),
}
}
pub fn spawn_watch(config: WatchConfig, db_path: &str) -> Result<WatchHandle> {
let root = config
.root
.canonicalize()
.context("cannot resolve watch root")?;
if !root.is_dir() {
anyhow::bail!("watch target is not a directory: {}", root.display());
}
validate_pid_lock_config(&config)?;
let db_path = db_path.to_string();
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_clone = Arc::clone(&shutdown);
let thread = std::thread::Builder::new()
.name("cartog-watch".into())
.spawn(move || {
if let Err(e) = watch_loop(config, &root, &db_path, &shutdown_clone) {
warn!(error = %e, "watch loop exited with error");
}
})
.context("failed to spawn watch thread")?;
Ok(WatchHandle {
shutdown,
thread: Some(thread),
})
}
pub fn run_watch(config: WatchConfig, db_path: &str) -> Result<()> {
validate_pid_lock_config(&config)?;
let root = config
.root
.canonicalize()
.context("cannot resolve watch root")?;
if !root.is_dir() {
anyhow::bail!("watch target is not a directory: {}", root.display());
}
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_clone = Arc::clone(&shutdown);
install_ctrlc_handler(&shutdown_clone);
watch_loop(config, &root, db_path, &shutdown)
}
fn install_ctrlc_handler(flag: &Arc<AtomicBool>) {
let flag = Arc::clone(flag);
let _ = ctrlc::set_handler(move || {
flag.store(true, Ordering::SeqCst);
});
}
fn watch_loop(
config: WatchConfig,
root: &Path,
db_path: &str,
shutdown: &AtomicBool,
) -> Result<()> {
validate_pid_lock_config(&config)?;
let watch_slot: Option<&str> = config.pid_lock_slot.as_deref();
let _lock: Option<cartog_process_lock::ProcessLock> =
match (config.pid_lock_dir.as_deref(), watch_slot) {
(Some(dir), Some(slot)) => match cartog_process_lock::ProcessLock::acquire(dir, slot) {
Ok(lock) => Some(lock),
Err(cartog_process_lock::AcquireError::Held(held)) => {
anyhow::bail!(
"another cartog process holds the watch lock at {} (slot {}, PID {}); \
stop it before running `cartog watch`",
dir.display(),
held.slot,
held.pid,
);
}
Err(cartog_process_lock::AcquireError::Io(e)) => {
return Err(e).with_context(|| {
format!("failed to acquire watch PID lock at {}", dir.display())
});
}
},
_ => None,
};
let db = if config.skip_migrations {
Database::open_existing_rw(db_path)
.context("failed to open database for watcher (existing-rw)")?
} else {
Database::open(db_path, config.rag_config.resolved_dimension())
.context("failed to open database for watcher")?
};
let rag_override = config.rag_override;
let rag_enabled = |db: &Database| -> bool {
resolve_watch_rag(
rag_override,
db.embedding_count().unwrap_or_else(|e| {
warn!(error = %e, "failed to read embedding count; auto-embed off");
0
}),
)
};
info!(
path = %root.display(),
debounce_ms = config.debounce.as_millis(),
rag = rag_enabled(&db),
rag_delay_s = config.rag_delay.as_secs(),
"starting watch"
);
if config.json_events {
emit_event(&WatchEvent::Started {
root: &root.to_string_lossy(),
debounce_ms: config.debounce.as_millis(),
rag: rag_enabled(&db),
rag_delay_s: config.rag_delay.as_secs(),
});
}
let mut initial_pending = 0u32;
let initial_start = Instant::now();
match indexer::index_directory(
&db,
root,
false,
false,
None,
None,
config.redact,
&std::collections::HashMap::new(),
) {
Ok(r) => {
info!(
files = r.files_indexed,
skipped = r.files_skipped,
removed = r.files_removed,
symbols = r.symbols_added,
"initial index complete"
);
if config.json_events {
emit_event(&WatchEvent::Reindex {
files_indexed: r.files_indexed,
files_skipped: r.files_skipped,
files_removed: r.files_removed,
symbols_added: r.symbols_added,
edges_added: r.edges_added,
edges_resolved: r.edges_resolved,
duration_ms: initial_start.elapsed().as_millis(),
});
}
if rag_enabled(&db) {
match db.symbols_needing_embeddings() {
Ok(needing) => initial_pending = needing.len() as u32,
Err(e) => warn!(error = %e, "failed to check embedding status"),
}
if initial_pending == 0
&& rag::indexer::embedding_format_upgrade_pending(&db).unwrap_or(false)
{
initial_pending = db.symbol_content_count().unwrap_or(1).max(1);
}
}
if let Some(s) = &config.stale {
s.note_reindex(s.change_seq(), initial_pending);
}
}
Err(e) => {
warn!(error = %e, "initial index failed");
if config.json_events {
emit_event(&WatchEvent::ReindexFailed {
error: e.to_string(),
});
}
}
}
let (tx, rx) = std::sync::mpsc::channel();
let mut debouncer =
new_debouncer(config.debounce, tx).context("failed to create file watcher")?;
debouncer
.watcher()
.watch(root, notify::RecursiveMode::Recursive)
.context("failed to start watching directory")?;
info!("watching for changes (Ctrl+C to stop)");
let mut rag_provider: Option<Box<dyn rag::provider::EmbeddingProvider>> = None;
let ensure_provider =
|provider: &mut Option<Box<dyn rag::provider::EmbeddingProvider>>| -> bool {
if provider.is_none() {
match rag::create_embedding_provider(&config.rag_config) {
Ok(p) => {
if let Err(e) =
db.reconcile_embedding_fingerprint(&rag::fingerprint_of(p.as_ref()))
{
warn!(error = %e, "failed to reconcile embedding fingerprint");
return false;
}
*provider = Some(p);
true
}
Err(e) => {
warn!(error = %e, "failed to create embedding provider");
false
}
}
} else {
true
}
};
let mut rag_pending = initial_pending > 0;
let mut last_index_time: Option<Instant> = rag_pending.then(Instant::now);
loop {
if shutdown.load(Ordering::SeqCst) {
break;
}
let poll_timeout = if rag_pending {
Duration::from_millis(500) } else {
Duration::from_secs(1) };
match rx.recv_timeout(poll_timeout) {
Ok(Ok(events)) => {
let relevant = events.iter().any(|event| {
event.kind == DebouncedEventKind::Any && is_relevant_path(&event.path, root)
});
if relevant {
debug!(
count = events.len(),
"file change events received, re-indexing"
);
let caught_up_to = config.stale.as_ref().map(|s| {
s.note_change();
s.change_seq()
});
let reindex_start = Instant::now();
match indexer::index_directory(
&db,
root,
false,
false,
None,
None,
config.redact,
&std::collections::HashMap::new(),
) {
Ok(r) => {
if r.files_indexed > 0 || r.files_removed > 0 {
info!(
files = r.files_indexed,
skipped = r.files_skipped,
removed = r.files_removed,
symbols = r.symbols_added,
"re-indexed"
);
}
if config.json_events && (r.files_indexed > 0 || r.files_removed > 0) {
emit_event(&WatchEvent::Reindex {
files_indexed: r.files_indexed,
files_skipped: r.files_skipped,
files_removed: r.files_removed,
symbols_added: r.symbols_added,
edges_added: r.edges_added,
edges_resolved: r.edges_resolved,
duration_ms: reindex_start.elapsed().as_millis(),
});
}
let mut pending_count = 0u32;
if rag_enabled(&db) {
match db.symbols_needing_embeddings() {
Ok(needing) if !needing.is_empty() => {
debug!(
pending = needing.len(),
"symbols need embedding, starting RAG timer"
);
pending_count = needing.len() as u32;
rag_pending = true;
last_index_time = Some(Instant::now());
}
Ok(_) => {
rag_pending = false;
}
Err(e) => {
warn!(error = %e, "failed to check embedding status");
}
}
}
if let (Some(s), Some(seq)) = (&config.stale, caught_up_to) {
s.note_reindex(seq, pending_count);
}
}
Err(e) => {
warn!(error = %e, "re-index failed");
if config.json_events {
emit_event(&WatchEvent::ReindexFailed {
error: e.to_string(),
});
}
}
}
}
}
Ok(Err(error)) => {
warn!(error = %error, "file watcher error");
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if rag_pending {
if let Some(last) = last_index_time {
if last.elapsed() >= config.rag_delay {
info!("RAG delay elapsed, embedding pending symbols");
if !ensure_provider(&mut rag_provider) {
rag_pending = false;
last_index_time = None;
continue;
}
if let Some(ref mut provider) = rag_provider {
let embed_start = Instant::now();
match rag::indexer::index_embeddings(
&db,
provider.as_mut(),
false,
None,
None,
) {
Ok(r) => {
info!(
embedded = r.symbols_embedded,
skipped = r.symbols_skipped,
"RAG embedding complete"
);
if config.json_events {
emit_event(&WatchEvent::RagEmbedded {
symbols_embedded: r.symbols_embedded,
symbols_skipped: r.symbols_skipped,
total_content_symbols: r.total_content_symbols,
duration_ms: embed_start.elapsed().as_millis(),
});
}
if let Some(s) = &config.stale {
s.clear_rag_pending();
}
}
Err(e) => {
warn!(error = %e, "RAG embedding failed");
if config.json_events {
emit_event(&WatchEvent::RagFailed {
error: e.to_string(),
});
}
}
}
}
rag_pending = false;
last_index_time = None;
}
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
warn!("file watcher channel disconnected");
break;
}
}
}
if rag_pending {
info!("flushing pending RAG embeddings before shutdown");
ensure_provider(&mut rag_provider);
if let Some(ref mut provider) = rag_provider {
let embed_start = Instant::now();
match rag::indexer::index_embeddings(&db, provider.as_mut(), false, None, None) {
Ok(r) => {
info!(embedded = r.symbols_embedded, "final RAG flush complete");
if config.json_events {
emit_event(&WatchEvent::RagEmbedded {
symbols_embedded: r.symbols_embedded,
symbols_skipped: r.symbols_skipped,
total_content_symbols: r.total_content_symbols,
duration_ms: embed_start.elapsed().as_millis(),
});
}
}
Err(e) => {
warn!(error = %e, "final RAG flush failed");
if config.json_events {
emit_event(&WatchEvent::RagFailed {
error: e.to_string(),
});
}
}
}
}
}
info!("watch stopped");
if config.json_events {
emit_event(&WatchEvent::Shutdown);
}
Ok(())
}
fn is_relevant_path(path: &Path, root: &Path) -> bool {
if detect_language(path).is_none() {
return false;
}
let relative = match path.strip_prefix(root) {
Ok(rel) => rel,
Err(_) => return false,
};
if let Some(parent) = relative.parent() {
for component in parent.components() {
if let std::path::Component::Normal(name) = component {
if let Some(name_str) = name.to_str() {
if is_ignored_dirname(name_str) {
return false;
}
}
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_relevant_python_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/src/main.py"), &root));
}
#[test]
fn test_relevant_python_stub() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/src/types.pyi"), &root));
}
#[test]
fn test_relevant_typescript_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/src/app.ts"), &root));
}
#[test]
fn test_relevant_tsx_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/src/App.tsx"), &root));
}
#[test]
fn test_relevant_javascript_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/src/index.js"), &root));
}
#[test]
fn test_relevant_jsx_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/src/App.jsx"), &root));
}
#[test]
fn test_relevant_mjs_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/src/utils.mjs"), &root));
}
#[test]
fn test_relevant_cjs_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(
Path::new("/project/src/config.cjs"),
&root
));
}
#[test]
fn test_relevant_rust_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/src/lib.rs"), &root));
}
#[test]
fn test_relevant_go_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/cmd/main.go"), &root));
}
#[test]
fn test_relevant_ruby_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(
Path::new("/project/lib/service.rb"),
&root
));
}
#[test]
fn test_relevant_java_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(
Path::new("/project/src/UserService.java"),
&root
));
}
#[test]
fn test_irrelevant_json_file() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(Path::new("/project/package.json"), &root));
}
#[test]
fn test_relevant_markdown_file() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/README.md"), &root));
assert!(is_relevant_path(
Path::new("/project/docs/design.md"),
&root
));
}
#[test]
fn test_irrelevant_toml_file() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(Path::new("/project/Cargo.toml"), &root));
}
#[test]
fn test_irrelevant_yaml_file() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/.github/ci.yml"),
&root
));
}
#[test]
fn test_irrelevant_no_extension() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(Path::new("/project/Makefile"), &root));
}
#[test]
fn test_ignored_node_modules() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/node_modules/pkg/index.js"),
&root
));
}
#[test]
fn test_ignored_git_dir() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/.git/hooks/pre-commit.py"),
&root
));
}
#[test]
fn test_ignored_target_dir() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/target/debug/build.rs"),
&root
));
}
#[test]
fn test_ignored_pycache() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/src/__pycache__/mod.py"),
&root
));
}
#[test]
fn test_ignored_nested_vendor() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/lib/vendor/gem/lib.rb"),
&root
));
}
#[test]
fn test_ignored_venv() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/.venv/lib/site.py"),
&root
));
assert!(!is_relevant_path(
Path::new("/project/venv/lib/site.py"),
&root
));
}
#[test]
fn test_ignored_env() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/.env/lib/site.py"),
&root
));
assert!(!is_relevant_path(
Path::new("/project/env/lib/site.py"),
&root
));
}
#[test]
fn test_ignored_dist_build() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/dist/bundle.js"),
&root
));
assert!(!is_relevant_path(
Path::new("/project/build/output.js"),
&root
));
}
#[test]
fn test_ignored_next_nuxt() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/.next/server/app.js"),
&root
));
assert!(!is_relevant_path(
Path::new("/project/.nuxt/dist/app.js"),
&root
));
}
#[test]
fn test_ignored_mypy_pytest_tox() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/.mypy_cache/3.11/mod.py"),
&root
));
assert!(!is_relevant_path(
Path::new("/project/.pytest_cache/v/test.py"),
&root
));
assert!(!is_relevant_path(
Path::new("/project/.tox/py311/lib.py"),
&root
));
}
#[test]
fn test_ignored_hg_svn() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/.hg/store/data.py"),
&root
));
assert!(!is_relevant_path(
Path::new("/project/.svn/entries.py"),
&root
));
}
#[test]
fn test_hidden_dir_ignored() {
let root = PathBuf::from("/project");
assert!(!is_relevant_path(
Path::new("/project/.hidden/script.py"),
&root
));
}
#[test]
fn test_root_level_file_allowed() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(Path::new("/project/setup.py"), &root));
}
#[test]
fn test_deeply_nested_file_allowed() {
let root = PathBuf::from("/project");
assert!(is_relevant_path(
Path::new("/project/src/auth/tokens/validate.py"),
&root
));
}
#[test]
fn test_path_outside_root_rejected() {
let root = PathBuf::from("/project");
assert!(
!is_relevant_path(Path::new("/other/project/main.py"), &root),
"files outside root should be rejected"
);
}
#[test]
fn test_path_sibling_of_root_rejected() {
let root = PathBuf::from("/workspace/project-a");
assert!(
!is_relevant_path(Path::new("/workspace/project-b/main.py"), &root),
"files in sibling directory should be rejected"
);
}
#[test]
fn test_path_partial_prefix_rejected() {
let root = PathBuf::from("/project");
assert!(
!is_relevant_path(Path::new("/project-b/main.py"), &root),
"partial prefix match should be rejected (strip_prefix handles this correctly)"
);
}
#[test]
fn test_config_defaults() {
let config = WatchConfig::new(PathBuf::from("."));
assert_eq!(config.debounce, Duration::from_secs(5));
assert_eq!(config.rag_override, None);
assert_eq!(config.rag_delay, Duration::from_secs(30));
assert!(!config.json_events);
}
#[test]
fn auto_detect_embeds_only_when_repo_has_embeddings() {
assert!(resolve_watch_rag(None, 5));
assert!(!resolve_watch_rag(None, 0));
}
#[test]
fn explicit_override_beats_embedding_count() {
assert!(!resolve_watch_rag(Some(false), 100));
assert!(resolve_watch_rag(Some(true), 0));
}
#[test]
fn test_watch_event_started_shape() {
let e = WatchEvent::Started {
root: "/proj",
debounce_ms: 5000,
rag: true,
rag_delay_s: 30,
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains("\"event\":\"started\""));
assert!(s.contains("\"root\":\"/proj\""));
assert!(s.contains("\"debounce_ms\":5000"));
assert!(s.contains("\"rag\":true"));
assert!(s.contains("\"rag_delay_s\":30"));
}
#[test]
fn test_watch_event_reindex_shape() {
let e = WatchEvent::Reindex {
files_indexed: 1,
files_skipped: 2,
files_removed: 0,
symbols_added: 10,
edges_added: 4,
edges_resolved: 3,
duration_ms: 42,
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains("\"event\":\"reindex\""));
assert!(s.contains("\"files_indexed\":1"));
assert!(s.contains("\"duration_ms\":42"));
}
#[test]
fn test_watch_event_shutdown_shape() {
let s = serde_json::to_string(&WatchEvent::Shutdown).unwrap();
assert_eq!(s, "{\"event\":\"shutdown\"}");
}
#[test]
fn test_config_custom_values() {
let mut config = WatchConfig::new(PathBuf::from("/my/project"));
config.debounce = Duration::from_secs(5);
config.rag_override = Some(true);
config.rag_delay = Duration::from_secs(60);
assert_eq!(config.root, PathBuf::from("/my/project"));
assert_eq!(config.debounce, Duration::from_secs(5));
assert_eq!(config.rag_override, Some(true));
assert_eq!(config.rag_delay, Duration::from_secs(60));
}
#[test]
fn test_spawn_watch_nonexistent_dir() {
let config = WatchConfig::new(PathBuf::from("/nonexistent/path/xyz"));
let result = spawn_watch(config, ":memory:");
assert!(result.is_err(), "should fail for nonexistent directory");
}
#[test]
fn test_spawn_watch_file_not_dir() {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let config = WatchConfig::new(manifest);
let result = spawn_watch(config, ":memory:");
assert!(
result.is_err(),
"should fail when target is a file, not dir"
);
}
#[test]
fn validate_pid_lock_accepts_both_none() {
let config = WatchConfig::new(PathBuf::from("."));
assert!(
validate_pid_lock_config(&config).is_ok(),
"untracked mode (both None) is valid"
);
}
#[test]
fn validate_pid_lock_accepts_both_set() {
let mut config = WatchConfig::new(PathBuf::from("."));
config.pid_lock_dir = Some(PathBuf::from("/tmp/cartog-locks"));
config.pid_lock_slot = Some("watch-0123456789abcdef".to_string());
assert!(
validate_pid_lock_config(&config).is_ok(),
"both fields set is valid"
);
}
#[test]
fn validate_pid_lock_rejects_dir_without_slot() {
let mut config = WatchConfig::new(PathBuf::from("."));
config.pid_lock_dir = Some(PathBuf::from("/tmp/cartog-locks"));
let err = validate_pid_lock_config(&config).expect_err("dir without slot must fail");
assert!(
err.to_string().contains("pid_lock_slot is None"),
"error names the missing slot: {err}"
);
}
#[test]
fn validate_pid_lock_rejects_slot_without_dir() {
let mut config = WatchConfig::new(PathBuf::from("."));
config.pid_lock_slot = Some("watch-0123456789abcdef".to_string());
let err = validate_pid_lock_config(&config).expect_err("slot without dir must fail");
assert!(
err.to_string().contains("pid_lock_dir is None"),
"error names the missing directory: {err}"
);
}
#[test]
fn test_is_ignored_dirname_known_dirs() {
let ignored = [
".git",
".hg",
".svn",
"node_modules",
"__pycache__",
".mypy_cache",
".pytest_cache",
".tox",
".venv",
"venv",
".env",
"env",
"target",
"dist",
"build",
".next",
".nuxt",
"vendor",
];
for name in &ignored {
assert!(is_ignored_dirname(name), "{name} should be ignored");
}
}
#[test]
fn test_is_ignored_dirname_hidden_dirs() {
assert!(is_ignored_dirname(".hidden"));
assert!(is_ignored_dirname(".cache"));
assert!(is_ignored_dirname(".config"));
}
#[test]
fn test_is_ignored_dirname_allowed_dirs() {
let allowed = [
"src", "lib", "tests", "docs", "app", "cmd", "internal", "pkg",
];
for name in &allowed {
assert!(!is_ignored_dirname(name), "{name} should NOT be ignored");
}
}
#[test]
fn test_is_ignored_dirname_case_sensitive() {
assert!(!is_ignored_dirname("Target"));
assert!(!is_ignored_dirname("NODE_MODULES"));
assert!(!is_ignored_dirname("Vendor"));
}
#[test]
fn test_watch_handle_drop_signals_shutdown() {
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_clone = Arc::clone(&shutdown);
let handle = WatchHandle {
shutdown: shutdown_clone,
thread: None,
};
assert!(!shutdown.load(Ordering::SeqCst));
drop(handle);
assert!(
shutdown.load(Ordering::SeqCst),
"drop should set shutdown flag"
);
}
#[test]
fn test_watch_handle_stop_signals_and_joins() {
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_clone = Arc::clone(&shutdown);
let shutdown_for_thread = Arc::clone(&shutdown);
let thread = std::thread::spawn(move || {
while !shutdown_for_thread.load(Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(10));
}
});
let handle = WatchHandle {
shutdown: shutdown_clone,
thread: Some(thread),
};
handle.stop(); assert!(shutdown.load(Ordering::SeqCst));
}
}