use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use notify::{ErrorKind as NotifyErrorKind, EventKind, RecursiveMode, Watcher};
use notify_debouncer_full::{
new_debouncer_opt, DebounceEventResult, DebouncedEvent, Debouncer, RecommendedCache,
};
use tokio::sync::{mpsc, oneshot};
use super::manifest::{expand_path, AgentPath, Manifest};
use super::monitor::ConfigChangeRequest;
const PRODUCTION_DEBOUNCE_MS: u64 = 500;
const POLL_FALLBACK_INTERVAL_SECS: u64 = 5;
const MAX_LINKED_DIRS: usize = 64;
const MAX_WALK_ENTRIES: usize = 4096;
#[derive(Default)]
struct LinkBudget {
links: usize,
entries: usize,
}
impl LinkBudget {
fn spent(&self) -> bool {
self.links >= MAX_LINKED_DIRS || self.entries >= MAX_WALK_ENTRIES
}
}
const HOME_IS_PRIVATE: bool = cfg!(target_os = "macos");
pub(crate) type WatchPlan = BTreeMap<PathBuf, bool>;
pub(crate) fn watch_plan(manifest: &Manifest, project_roots: &[PathBuf]) -> WatchPlan {
watch_plan_from(manifest, project_roots, dirs::home_dir())
}
fn watch_plan_from(
manifest: &Manifest,
project_roots: &[PathBuf],
home: Option<PathBuf>,
) -> WatchPlan {
let mut plan = WatchPlan::new();
let mut links = LinkBudget::default();
for agent in &manifest.agents {
for ap in &agent.paths {
if ap.is_project_scoped() {
for root in project_roots {
cover_template(
&mut plan,
&mut links,
&root.join("*"),
Some(root),
home.as_deref(),
);
for rel in &ap.paths_relative {
cover_template(
&mut plan,
&mut links,
&root.join(rel),
Some(root),
home.as_deref(),
);
}
for glob in &ap.paths_glob_relative {
cover_template(
&mut plan,
&mut links,
&root.join(glob),
Some(root),
home.as_deref(),
);
}
}
continue;
}
let templates = ap
.paths
.iter()
.chain(ap.json_slice_paths.iter().map(|s| &s.path))
.chain(ap.paths_glob.iter());
for template in templates {
if let Ok(expanded) = expand_path(template, None) {
cover_template(&mut plan, &mut links, &expanded, None, home.as_deref());
}
}
}
}
drop_covered(plan)
}
fn cover_template(
plan: &mut WatchPlan,
links: &mut LinkBudget,
pattern: &Path,
floor: Option<&Path>,
home: Option<&Path>,
) {
let (dir, recursive) = match glob_split(pattern) {
Some((prefix, rest)) => (prefix, rest > 1),
None => match pattern.parent() {
Some(parent) => (parent.to_path_buf(), false),
None => return,
},
};
let Some((anchor, recursive)) = resolve_anchor(&dir, recursive, floor, home) else {
return;
};
if HOME_IS_PRIVATE && Some(anchor.as_path()) == home {
if glob_split(pattern).is_none() && pattern.is_file() {
plan.entry(pattern.to_path_buf()).or_insert(false);
}
return;
}
if recursive {
let depth = glob_split(pattern).map_or(0, |(_, rest)| rest - 1);
cover_linked_dirs(plan, links, &anchor, depth, floor, home);
}
*plan.entry(anchor).or_insert(false) |= recursive;
}
fn cover_linked_dirs(
plan: &mut WatchPlan,
links: &mut LinkBudget,
dir: &Path,
depth: usize,
floor: Option<&Path>,
home: Option<&Path>,
) {
if depth == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if links.spent() {
return;
}
links.entries += 1;
let path = entry.path();
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_symlink() {
if path.is_dir() {
links.links += 1;
let recursive = depth > 1 && safe_to_recurse(&path, home, floor);
*plan.entry(path).or_insert(false) |= recursive;
}
} else if kind.is_dir() {
cover_linked_dirs(plan, links, &path, depth - 1, floor, home);
}
}
}
fn glob_split(pattern: &Path) -> Option<(PathBuf, usize)> {
let components: Vec<Component> = pattern.components().collect();
let first = components.iter().position(|c| {
c.as_os_str()
.to_string_lossy()
.contains(['*', '?', '[', '{'])
})?;
let prefix: PathBuf = components[..first].iter().collect();
Some((prefix, components.len() - first))
}
fn resolve_anchor(
dir: &Path,
recursive: bool,
floor: Option<&Path>,
home: Option<&Path>,
) -> Option<(PathBuf, bool)> {
let mut anchor = dir;
let mut recursive = recursive;
while !anchor.is_dir() {
recursive = false;
anchor = anchor.parent()?;
if floor.is_some_and(|f| !anchor.starts_with(f)) {
return None;
}
}
anchor.parent()?;
if Some(anchor) == home || Some(anchor) == floor || !safe_to_recurse(anchor, home, floor) {
recursive = false;
}
Some((anchor.to_path_buf(), recursive))
}
fn safe_to_recurse(anchor: &Path, home: Option<&Path>, floor: Option<&Path>) -> bool {
let Ok(real) = std::fs::canonicalize(anchor) else {
return false;
};
if real.parent().is_none() {
return false;
}
let real_home = home.and_then(|h| std::fs::canonicalize(h).ok());
if real_home.as_ref().is_some_and(|h| h.starts_with(&real)) {
return false;
}
if anchor.is_symlink() && !real_home.is_some_and(|h| real.starts_with(h)) {
return false;
}
match floor.map(std::fs::canonicalize) {
Some(Ok(real_floor)) => !real_floor.starts_with(&real),
Some(Err(_)) => false,
None => true,
}
}
fn watcher_config() -> notify::Config {
notify::Config::default().with_follow_symlinks(false)
}
fn drop_covered(plan: WatchPlan) -> WatchPlan {
let recursive: Vec<PathBuf> = plan
.iter()
.filter(|(_, r)| **r)
.map(|(d, _)| d.clone())
.collect();
plan.into_iter()
.filter(|(dir, _)| {
dir.is_symlink() || !recursive.iter().any(|r| dir != r && dir.starts_with(r))
})
.collect()
}
#[derive(Clone)]
pub struct WatchControl {
tx: std::sync::mpsc::Sender<WatchCmd>,
}
enum WatchCmd {
Apply(WatchPlan, oneshot::Sender<Applied>),
}
#[derive(Debug, Default)]
pub(crate) struct Applied {
pub armed: Vec<PathBuf>,
pub vanished: Vec<PathBuf>,
}
impl WatchControl {
pub(crate) async fn apply(&self, plan: WatchPlan) -> Applied {
let (reply_tx, reply_rx) = oneshot::channel();
if self.tx.send(WatchCmd::Apply(plan, reply_tx)).is_err() {
return Applied::default();
}
reply_rx.await.unwrap_or_default()
}
}
pub fn spawn_watch_thread(
request_tx: mpsc::Sender<ConfigChangeRequest>,
debounce_ms: u64,
) -> anyhow::Result<WatchControl> {
let (tx, rx) = std::sync::mpsc::channel::<WatchCmd>();
std::thread::Builder::new()
.name("ol-config-watch".into())
.spawn(move || {
let mut armer = Armer::new(request_tx, debounce_ms);
while let Ok(WatchCmd::Apply(plan, reply)) = rx.recv() {
let _ = reply.send(armer.reconcile(plan));
}
})?;
Ok(WatchControl { tx })
}
type Aliases = Arc<RwLock<Vec<(PathBuf, PathBuf)>>>;
struct Armer {
request_tx: mpsc::Sender<ConfigChangeRequest>,
debouncer: Option<Debouncer<notify::RecommendedWatcher, RecommendedCache>>,
poll: Option<notify::PollWatcher>,
armed: BTreeMap<PathBuf, (bool, bool)>,
aliases: Aliases,
}
impl Armer {
fn new(request_tx: mpsc::Sender<ConfigChangeRequest>, debounce_ms: u64) -> Self {
let aliases: Aliases = Arc::default();
let cb_tx = request_tx.clone();
let cb_aliases = aliases.clone();
let debounce_clamped = debounce_ms.clamp(50, 5000).max(PRODUCTION_DEBOUNCE_MS / 5);
let debouncer = new_debouncer_opt::<_, notify::RecommendedWatcher, _>(
Duration::from_millis(debounce_clamped),
None,
move |res: DebounceEventResult| match res {
Ok(events) => {
for ev in events {
if let Some(req) = event_to_request(&ev, &cb_aliases) {
if cb_tx.try_send(req).is_err() {
tracing::warn!(
"config_monitor: request channel full — skipping fs event"
);
}
}
}
}
Err(errors) => {
for e in errors {
tracing::debug!(error = %e, "config_monitor: debouncer reported error");
}
}
},
RecommendedCache::new(),
watcher_config(),
);
let debouncer = match debouncer {
Ok(d) => Some(d),
Err(e) => {
tracing::error!(
code = crate::error::ERR_INVENTORY_WATCHER_FAILED,
error = %e,
"failed to create config_monitor debouncer; falling back to poll-only"
);
crate::telemetry::capture_global(
crate::telemetry::Event::config_watcher_init_failed("other", None),
);
None
}
};
Self {
request_tx,
debouncer,
poll: None,
armed: BTreeMap::new(),
aliases,
}
}
fn reconcile(&mut self, plan: WatchPlan) -> Applied {
let vanished: Vec<PathBuf> = self
.armed
.keys()
.filter(|dir| !dir.exists())
.cloned()
.collect();
let stale: Vec<PathBuf> = self
.armed
.iter()
.filter(|(dir, (recursive, _))| plan.get(*dir) != Some(recursive))
.map(|(dir, _)| dir.clone())
.collect();
for dir in stale {
if let Some((_, polled)) = self.armed.remove(&dir) {
let _ = if polled {
self.poll.as_mut().map(|p| p.unwatch(&dir))
} else {
self.debouncer.as_mut().map(|d| d.unwatch(&dir))
};
}
}
let mut armed_now = Vec::new();
for (dir, recursive) in plan {
if self.armed.contains_key(&dir) {
continue;
}
if let Some(polled) = self.arm(&dir, recursive) {
self.armed.insert(dir.clone(), (recursive, polled));
armed_now.push(dir);
}
}
let aliases = self
.armed
.keys()
.filter_map(|dir| {
let canonical = std::fs::canonicalize(dir).ok()?;
(canonical != *dir).then(|| (canonical, dir.clone()))
})
.collect();
if let Ok(mut guard) = self.aliases.write() {
*guard = aliases;
}
Applied {
armed: armed_now,
vanished,
}
}
fn arm(&mut self, dir: &Path, recursive: bool) -> Option<bool> {
let mode = if recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
if let Some(debouncer) = self.debouncer.as_mut() {
match debouncer.watch(dir, mode) {
Ok(()) => return Some(false),
Err(e) if matches!(e.kind, NotifyErrorKind::MaxFilesWatch) => {
tracing::warn!(
code = crate::error::ERR_INVENTORY_WATCHER_FAILED,
path = %dir.display(),
"inotify max_user_watches exhausted; falling back to PollWatcher"
);
crate::telemetry::capture_global(
crate::telemetry::Event::config_watcher_init_failed("enospc", None),
);
}
Err(e) => {
tracing::warn!(
code = crate::error::ERR_INVENTORY_WATCHER_FAILED,
path = %dir.display(),
error = %e,
"config_monitor: watcher failed for path; skipping"
);
crate::telemetry::capture_global(
crate::telemetry::Event::config_watcher_init_failed(
os_error_class(&e),
None,
),
);
return None;
}
}
}
let poll = match self.poll.as_mut() {
Some(poll) => poll,
None => self.poll.insert(self.new_poll_watcher()?),
};
match poll.watch(dir, mode) {
Ok(()) => Some(true),
Err(e) => {
tracing::warn!(
path = %dir.display(),
error = %e,
"config_monitor: PollWatcher failed for fallback path"
);
None
}
}
}
fn new_poll_watcher(&self) -> Option<notify::PollWatcher> {
let cfg = watcher_config()
.with_poll_interval(Duration::from_secs(POLL_FALLBACK_INTERVAL_SECS))
.with_compare_contents(true);
let cb_tx = self.request_tx.clone();
let cb_aliases = self.aliases.clone();
notify::PollWatcher::new(
move |res: notify::Result<notify::Event>| {
if let Ok(ev) = res {
if let Some(req) = native_event_to_request(&ev, &cb_aliases) {
if cb_tx.try_send(req).is_err() {
tracing::warn!(
"config_monitor: request channel full — skipping poll event"
);
}
}
}
},
cfg,
)
.inspect_err(|e| {
tracing::warn!(error = %e, "config_monitor: PollWatcher could not be created");
})
.ok()
}
}
pub(crate) fn glob_expand(pattern: &Path) -> Vec<PathBuf> {
let pattern_str = match pattern.to_str() {
Some(s) => s,
None => return Vec::new(),
};
match glob::glob(pattern_str) {
Ok(iter) => iter.flatten().collect(),
Err(e) => {
tracing::debug!(
pattern = %pattern.display(),
error = %e,
"config_monitor: glob expansion failed"
);
Vec::new()
}
}
}
fn event_to_request(ev: &DebouncedEvent, aliases: &Aliases) -> Option<ConfigChangeRequest> {
native_event_to_request(&ev.event, aliases)
}
fn native_event_to_request(ev: ¬ify::Event, aliases: &Aliases) -> Option<ConfigChangeRequest> {
let paths = || unalias(&ev.paths, aliases);
match ev.kind {
EventKind::Create(_) => Some(ConfigChangeRequest::FsAdded(paths())),
EventKind::Modify(_) => Some(ConfigChangeRequest::FsModified(paths())),
EventKind::Remove(_) => Some(ConfigChangeRequest::FsRemoved(paths())),
_ => None,
}
}
fn unalias(paths: &[PathBuf], aliases: &Aliases) -> Vec<PathBuf> {
let Ok(aliases) = aliases.read() else {
return paths.to_vec();
};
let mut out = Vec::with_capacity(paths.len());
for p in paths {
for (canonical, watched) in aliases.iter() {
if let Ok(rest) = p.strip_prefix(canonical) {
out.push(watched.join(rest));
}
}
out.push(p.clone());
}
out
}
fn os_error_class(e: ¬ify::Error) -> &'static str {
match e.kind {
NotifyErrorKind::MaxFilesWatch => "enospc",
NotifyErrorKind::PathNotFound => "not_found",
NotifyErrorKind::Io(_) => "io",
_ => "other",
}
}
pub(crate) fn is_excluded(path: &Path, agent_path: &AgentPath, manifest: &Manifest) -> bool {
for agent in &manifest.agents {
let owns = agent.paths.iter().any(|ap| std::ptr::eq(ap, agent_path));
if !owns {
continue;
}
if let Some(exclude) = &agent.exclude {
for pat in &exclude.patterns {
if let Ok(expanded) = expand_path(pat, None) {
if path_matches_pattern(path, &expanded) {
return true;
}
}
}
}
break;
}
false
}
fn path_matches_pattern(path: &Path, pattern: &Path) -> bool {
let Some(pattern_str) = pattern.to_str() else {
return false;
};
let Some(path_str) = path.to_str() else {
return false;
};
glob::Pattern::new(pattern_str)
.map(|p| p.matches(path_str))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(body: &str) -> Manifest {
toml::from_str(body).unwrap()
}
fn slash(p: &Path) -> String {
p.display().to_string().replace('\\', "/")
}
#[test]
fn glob_split_finds_the_static_prefix() {
assert_eq!(
glob_split(Path::new("/a/skills/*/SKILL.md")),
Some((PathBuf::from("/a/skills"), 2))
);
assert_eq!(
glob_split(Path::new("/a/rules/*.md")),
Some((PathBuf::from("/a/rules"), 1))
);
assert_eq!(glob_split(Path::new("/a/CLAUDE.md")), None);
}
#[test]
fn plan_covers_files_globs_and_missing_directories() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("skills")).unwrap();
std::fs::create_dir_all(root.join("rules")).unwrap();
std::fs::create_dir_all(root.join("plugins/cache/m/p/v/skills")).unwrap();
let m = manifest(&format!(
r#"
[[agent]]
name = "a"
[[agent.path]]
kind = "rules"
paths = ["{r}/CLAUDE.md"]
paths_glob = "{r}/rules/*.md"
watch_strategy = "exact_file_and_glob"
[[agent.path]]
kind = "skill"
paths_glob = "{r}/skills/*/SKILL.md"
watch_strategy = "glob"
[[agent.path]]
kind = "skill"
paths_glob = "{r}/plugins/cache/*/*/*/skills/*/SKILL.md"
watch_strategy = "glob"
[[agent.path]]
kind = "command"
paths_glob = "{r}/missing/deeper/commands/*.md"
watch_strategy = "glob"
"#,
r = slash(root)
));
let plan = watch_plan(&m, &[]);
assert_eq!(
plan.get(root),
Some(&false),
"file parent + sentinel: {plan:?}"
);
assert_eq!(plan.get(&root.join("rules")), Some(&false));
assert_eq!(plan.get(&root.join("skills")), Some(&true));
assert_eq!(plan.get(&root.join("plugins/cache")), Some(&true));
assert!(
!plan.keys().any(|d| d.starts_with(root.join("missing"))),
"a missing prefix is covered by its nearest existing ancestor"
);
}
#[test]
fn a_recursive_ancestor_covers_its_descendants() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("skills/x")).unwrap();
let m = manifest(&format!(
r#"
[[agent]]
name = "a"
[[agent.path]]
kind = "skill"
paths_glob = "{r}/skills/*/SKILL.md"
watch_strategy = "glob"
[[agent.path]]
kind = "rules"
paths = ["{r}/skills/x/NOTE.md"]
watch_strategy = "exact_file"
"#,
r = slash(root)
));
let plan = watch_plan(&m, &[]);
assert_eq!(plan.get(&root.join("skills")), Some(&true));
assert!(!plan.contains_key(&root.join("skills/x")), "{plan:?}");
}
#[test]
fn home_and_the_filesystem_root_are_never_watched_recursively() {
let home = tempfile::tempdir().unwrap();
assert_eq!(
resolve_anchor(home.path(), true, None, Some(home.path())),
Some((home.path().to_path_buf(), false))
);
assert_eq!(resolve_anchor(Path::new("/"), true, None, None), None);
}
#[cfg(target_os = "macos")]
#[test]
fn home_itself_is_never_watched_on_macos() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
std::fs::write(home.join(".claude.json"), "{}").unwrap();
std::fs::create_dir_all(home.join(".claude/skills")).unwrap();
let m = manifest(&format!(
r#"
[[agent]]
name = "a"
[[agent.path]]
kind = "mcp"
paths = ["{h}/.claude.json", "{h}/.absent.json"]
watch_strategy = "exact_file"
[[agent.path]]
kind = "skill"
paths_glob = "{h}/.agents/skills/*/SKILL.md"
watch_strategy = "glob"
[[agent.path]]
kind = "skill"
paths_glob = "{h}/.claude/skills/*/SKILL.md"
watch_strategy = "glob"
[[agent.path]]
kind = "rules"
scope = "project"
paths_relative = ["AGENTS.md"]
watch_strategy = "exact_file"
"#,
h = slash(&home)
));
let plan = watch_plan_from(&m, std::slice::from_ref(&home), Some(home.clone()));
assert!(!plan.contains_key(&home), "{plan:?}");
assert_eq!(plan.get(&home.join(".claude.json")), Some(&false));
assert_eq!(plan.get(&home.join(".claude/skills")), Some(&true));
assert_eq!(
plan.len(),
2,
"no sentinel for a missing file or dir: {plan:?}"
);
}
#[test]
fn project_roots_get_their_subdirectories_and_never_the_repository() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("repo");
std::fs::create_dir_all(project.join(".agents/skills")).unwrap();
let m = manifest(
r#"
[[agent]]
name = "a"
[[agent.path]]
kind = "rules"
scope = "project"
paths_glob_relative = [".cursor/rules/*.mdc"]
watch_strategy = "glob"
[[agent.path]]
kind = "skill"
scope = "project"
paths_glob_relative = [".agents/skills/*/SKILL.md"]
watch_strategy = "glob"
[[agent.path]]
kind = "rules"
scope = "project"
paths_relative = ["AGENTS.md"]
watch_strategy = "exact_file"
"#,
);
assert!(watch_plan(&m, &[]).is_empty(), "no root, no project watch");
let plan = watch_plan(&m, std::slice::from_ref(&project));
assert_eq!(plan.get(&project), Some(&false), "{plan:?}");
assert_eq!(plan.get(&project.join(".agents/skills")), Some(&true));
assert!(
plan.keys().all(|d| d.starts_with(&project)),
"a project sentinel never climbs above its root: {plan:?}"
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_prefix_is_never_watched_recursively() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let project = home.join("repo");
std::fs::create_dir_all(project.join(".agents")).unwrap();
std::os::unix::fs::symlink(&home, project.join(".agents/skills")).unwrap();
let anchor = project.join(".agents/skills");
assert_eq!(
resolve_anchor(&anchor, true, Some(&project), Some(&home)),
Some((anchor.clone(), false))
);
std::fs::remove_file(&anchor).unwrap();
std::os::unix::fs::symlink(&project, &anchor).unwrap();
assert_eq!(
resolve_anchor(&anchor, true, Some(&project), Some(&home)),
Some((anchor.clone(), false))
);
let shared = home.join("shared-skills");
std::fs::create_dir_all(&shared).unwrap();
std::fs::remove_file(&anchor).unwrap();
std::os::unix::fs::symlink(&shared, &anchor).unwrap();
assert_eq!(
resolve_anchor(&anchor, true, Some(&project), Some(&home)),
Some((anchor.clone(), true))
);
std::fs::remove_file(&anchor).unwrap();
std::fs::create_dir_all(&anchor).unwrap();
assert_eq!(
resolve_anchor(&anchor, true, Some(&project), Some(&home)),
Some((anchor, true))
);
}
#[cfg(unix)]
#[test]
fn a_linked_plugin_below_a_recursive_watch_gets_its_own() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let root = home.join("agent");
let cache = root.join("plugins/cache/market");
std::fs::create_dir_all(&cache).unwrap();
let target = home.join("dev-plugin");
std::fs::create_dir_all(target.join("skills")).unwrap();
std::os::unix::fs::symlink(&target, cache.join("linked")).unwrap();
std::os::unix::fs::symlink("/", cache.join("root")).unwrap();
std::os::unix::fs::symlink("/usr", cache.join("usr")).unwrap();
let m = manifest(&format!(
r#"
[[agent]]
name = "a"
[[agent.path]]
kind = "skill"
paths_glob = "{r}/plugins/cache/*/*/skills/*/SKILL.md"
watch_strategy = "glob"
"#,
r = slash(&root)
));
let plan = watch_plan_from(&m, &[], Some(home));
assert_eq!(plan.get(&root.join("plugins/cache")), Some(&true));
assert_eq!(plan.get(&cache.join("linked")), Some(&true), "{plan:?}");
assert_eq!(plan.get(&cache.join("root")), Some(&false), "{plan:?}");
assert_eq!(plan.get(&cache.join("usr")), Some(&false), "{plan:?}");
}
#[cfg(unix)]
#[test]
fn a_repository_full_of_links_cannot_spend_the_watch_budget() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("repo");
let skills = project.join(".agents/skills");
std::fs::create_dir_all(&skills).unwrap();
let target = tmp.path().join("t");
std::fs::create_dir_all(&target).unwrap();
for i in 0..(MAX_LINKED_DIRS + 50) {
std::os::unix::fs::symlink(&target, skills.join(format!("l{i}"))).unwrap();
}
let m = manifest(
r#"
[[agent]]
name = "a"
[[agent.path]]
kind = "skill"
scope = "project"
paths_glob_relative = [".agents/skills/*/SKILL.md"]
watch_strategy = "glob"
"#,
);
let plan = watch_plan_from(&m, std::slice::from_ref(&project), Some(tmp.path().into()));
let linked = plan.keys().filter(|d| d.is_symlink()).count();
assert_eq!(linked, MAX_LINKED_DIRS, "{}", plan.len());
}
#[test]
fn unalias_rewrites_the_canonical_spelling() {
let aliases: Aliases = Arc::new(RwLock::new(vec![(
PathBuf::from("/private/var/x"),
PathBuf::from("/var/x"),
)]));
assert_eq!(
unalias(&[PathBuf::from("/private/var/x/a.md")], &aliases),
vec![
PathBuf::from("/var/x/a.md"),
PathBuf::from("/private/var/x/a.md")
]
);
assert_eq!(
unalias(&[PathBuf::from("/other/a.md")], &aliases),
vec![PathBuf::from("/other/a.md")]
);
}
#[test]
fn unalias_reports_every_spelling_of_one_directory() {
let aliases: Aliases = Arc::new(RwLock::new(vec![
(
PathBuf::from("/real/plugin"),
PathBuf::from("/home/u/cache/linked"),
),
(
PathBuf::from("/real/plugin"),
PathBuf::from("/home/u/other/linked"),
),
]));
let out = unalias(&[PathBuf::from("/real/plugin/SKILL.md")], &aliases);
assert!(out.contains(&PathBuf::from("/home/u/cache/linked/SKILL.md")));
assert!(out.contains(&PathBuf::from("/home/u/other/linked/SKILL.md")));
assert!(
out.contains(&PathBuf::from("/real/plugin/SKILL.md")),
"{out:?}"
);
}
#[test]
fn glob_expand_returns_empty_on_no_matches() {
let temp = tempfile::tempdir().unwrap();
let pattern = temp.path().join("*.no_such_extension");
let v = glob_expand(&pattern);
assert!(v.is_empty());
}
#[test]
fn os_error_class_maps_known_kinds() {
let io_err = notify::Error::new(NotifyErrorKind::Io(std::io::Error::other("x")));
assert_eq!(os_error_class(&io_err), "io");
let not_found = notify::Error::new(NotifyErrorKind::PathNotFound);
assert_eq!(os_error_class(¬_found), "not_found");
let max = notify::Error::new(NotifyErrorKind::MaxFilesWatch);
assert_eq!(os_error_class(&max), "enospc");
}
}