use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use notify::Watcher;
use harn_serve::FilePromptCatalog;
pub(super) fn start_cache_refresh_watcher(
project_root: PathBuf,
config_path: PathBuf,
manifest_source_cache: Arc<Mutex<String>>,
prompt_catalog: Arc<Mutex<FilePromptCatalog>>,
) -> Option<notify::RecommendedWatcher> {
let project_root_for_callback = project_root.clone();
let watcher = notify::recommended_watcher(move |result: notify::Result<notify::Event>| {
let Ok(event) = result else {
return;
};
let prompt_changed = event.paths.iter().any(|path| {
!is_package_generation_path(path, &project_root_for_callback)
&& is_prompt_reload_path(path)
});
let manifest_changed = event.paths.iter().any(|path| {
!is_package_generation_path(path, &project_root_for_callback)
&& is_manifest_reload_path(path)
});
let package_changed = event
.paths
.iter()
.any(|path| is_package_reload_path(path.as_path(), &project_root_for_callback));
if !prompt_changed && !manifest_changed && !package_changed {
return;
}
if prompt_changed || manifest_changed || package_changed {
let manifest_source = std::fs::read_to_string(&config_path).unwrap_or_default();
refresh_manifest_derived_state_cache(
&project_root_for_callback,
&manifest_source_cache,
&prompt_catalog,
manifest_source,
);
}
})
.ok()?;
watch_with_deadline(watcher, &project_root)
}
const WATCH_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(10);
fn watch_with_deadline(
mut watcher: notify::RecommendedWatcher,
project_root: &Path,
) -> Option<notify::RecommendedWatcher> {
let (tx, rx) = std::sync::mpsc::channel();
let root = project_root.to_path_buf();
std::thread::spawn(move || {
let registered = watcher
.watch(&root, notify::RecursiveMode::Recursive)
.map(|()| watcher);
let _ = tx.send(registered);
});
match rx.recv_timeout(WATCH_REGISTRATION_TIMEOUT) {
Ok(Ok(watcher)) => Some(watcher),
Ok(Err(error)) => {
eprintln!("[harn] warning: filesystem watch unavailable: {error}");
None
}
Err(_) => {
eprintln!(
"[harn] warning: registering a filesystem watch on {} did not complete within {}s; \
continuing without automatic MCP catalog refresh",
project_root.display(),
WATCH_REGISTRATION_TIMEOUT.as_secs()
);
None
}
}
}
pub(super) fn refresh_manifest_derived_state_cache(
project_root: &Path,
manifest_source_cache: &Arc<Mutex<String>>,
prompt_catalog: &Arc<Mutex<FilePromptCatalog>>,
manifest_source: String,
) {
*manifest_source_cache
.lock()
.expect("manifest source poisoned") = manifest_source;
let updated = FilePromptCatalog::discover(project_root);
*prompt_catalog.lock().expect("prompt catalog poisoned") = updated;
}
fn is_prompt_reload_path(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "harn.toml" || name.ends_with(".harn.prompt"))
}
fn is_manifest_reload_path(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "harn.toml")
}
fn is_package_reload_path(path: &Path, project_root: &Path) -> bool {
let relative = path.strip_prefix(project_root).unwrap_or(path);
relative == Path::new(".harn").join("package-current.toml")
}
fn is_package_generation_path(path: &Path, project_root: &Path) -> bool {
let relative = path.strip_prefix(project_root).unwrap_or(path);
relative.starts_with(Path::new(".harn").join("package-generations"))
}
#[cfg(test)]
mod package_reload_tests {
use super::*;
#[test]
fn only_atomic_package_pointer_is_a_package_publication_event() {
let root = Path::new("workspace");
assert!(is_package_reload_path(
Path::new("workspace/.harn/package-current.toml"),
root
));
assert!(!is_package_reload_path(
Path::new("workspace/harn.lock"),
root
));
assert!(!is_package_reload_path(
Path::new("workspace/.harn/package-generations/generation-a/harn.lock"),
root
));
assert!(!is_package_reload_path(
Path::new("workspace/.harn/packages/acme/harn.toml"),
root
));
assert!(is_package_generation_path(
Path::new("workspace/.harn/package-generations/generation-a/packages/acme/harn.toml"),
root
));
}
}