first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxyui_core::ModelContext;
|
||||
|
||||
use super::model::{ProjectContextModel, ProjectRule};
|
||||
|
||||
/// No-op stand-in for non-`local_fs` builds. File-based global rules require
|
||||
/// filesystem watchers that don't exist on WASM, so callers see an empty
|
||||
/// view here.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct GlobalRules;
|
||||
|
||||
impl GlobalRules {
|
||||
pub(crate) fn index(&mut self, _ctx: &mut ModelContext<ProjectContextModel>) {}
|
||||
pub(crate) fn active_rules(&self) -> impl Iterator<Item = ProjectRule> + '_ {
|
||||
std::iter::empty()
|
||||
}
|
||||
|
||||
pub(crate) fn paths(&self) -> impl Iterator<Item = LocalOrRemotePath> + '_ {
|
||||
std::iter::empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_channel::Sender;
|
||||
use repo_metadata::repository::{RepositorySubscriber, SubscriberId};
|
||||
use repo_metadata::{DirectoryWatcher, Repository, RepositoryUpdate};
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::EnumIter;
|
||||
use galaxy_core::safe_warn;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use galaxyui_core::{ModelContext, ModelHandle, SingletonEntity};
|
||||
use watcher::{HomeDirectoryWatcher, HomeDirectoryWatcherEvent};
|
||||
|
||||
use super::model::{GlobalRulesDelta, ProjectContextModel, ProjectContextModelEvent, ProjectRule};
|
||||
|
||||
/// A well-known location under `$HOME` that may contain a global rule file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter)]
|
||||
enum GlobalRuleSource {
|
||||
/// `~/.agents/AGENTS.md`.
|
||||
Agents,
|
||||
}
|
||||
|
||||
impl GlobalRuleSource {
|
||||
/// Display name (used in safe logs that don't expose user paths).
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Agents => "agents",
|
||||
}
|
||||
}
|
||||
|
||||
/// Subdirectory under `$HOME`, e.g. `".agents"`.
|
||||
fn home_subdir(self) -> &'static str {
|
||||
match self {
|
||||
Self::Agents => ".agents",
|
||||
}
|
||||
}
|
||||
|
||||
/// File name within the subdir, e.g. `"AGENTS.md"`.
|
||||
fn file_pattern(self) -> &'static str {
|
||||
match self {
|
||||
Self::Agents => "AGENTS.md",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GlobalSourceWatcherState {
|
||||
repository: ModelHandle<Repository>,
|
||||
subscriber_id: SubscriberId,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GlobalRulesUpdate {
|
||||
/// The [`GlobalRuleSource`] variant that produced this update. The
|
||||
/// receiver uses it to look up the matching `home_subdir`/`file_pattern`
|
||||
/// without needing a per-source channel.
|
||||
source: GlobalRuleSource,
|
||||
update: RepositoryUpdate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct GlobalRules {
|
||||
/// Global rule files keyed by absolute file path. Populated from
|
||||
/// [`GlobalRuleSource`]. Independent of project-level rule indexing.
|
||||
/// Stored in a `BTreeMap` so iteration order is deterministic.
|
||||
pub(super) rules: BTreeMap<PathBuf, ProjectRule>,
|
||||
/// Active home-subdir directory watchers, keyed by the absolute subdir
|
||||
/// path (e.g. `~/.agents`).
|
||||
source_watchers: HashMap<PathBuf, GlobalSourceWatcherState>,
|
||||
/// Sender used by global-rule directory subscribers to push updates back
|
||||
/// into the model's main-thread stream handler.
|
||||
updates_tx: Option<Sender<GlobalRulesUpdate>>,
|
||||
}
|
||||
|
||||
impl GlobalRules {
|
||||
pub(crate) fn active_rules(&self) -> impl Iterator<Item = ProjectRule> + '_ {
|
||||
self.rules.values().cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn paths(&self) -> impl Iterator<Item = LocalOrRemotePath> + '_ {
|
||||
self.rules.keys().cloned().map(LocalOrRemotePath::Local)
|
||||
}
|
||||
|
||||
/// Index all configured global rule sources (see [`GlobalRuleSource`]).
|
||||
///
|
||||
/// All disk I/O is dispatched through `ctx.spawn` so this method does not
|
||||
/// block startup. Subscribes to [`HomeDirectoryWatcher`] to react to
|
||||
/// creation/deletion of the home subdirs at runtime, and registers a
|
||||
/// [`DirectoryWatcher`] per existing subdir for incremental updates.
|
||||
///
|
||||
/// Idempotent: subsequent calls are a no-op once the channel is initialized.
|
||||
pub(crate) fn index(&mut self, ctx: &mut ModelContext<ProjectContextModel>) {
|
||||
if self.updates_tx.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
log::debug!("Home directory not found; skipping global rules indexing");
|
||||
return;
|
||||
};
|
||||
|
||||
// Set up the channel that all per-source subscribers push into.
|
||||
let (tx, rx) = async_channel::unbounded::<GlobalRulesUpdate>();
|
||||
self.updates_tx = Some(tx);
|
||||
|
||||
ctx.spawn_stream_local(
|
||||
rx,
|
||||
|me, update, ctx| {
|
||||
me.global_rules
|
||||
.handle_global_rules_update(update.source, update.update, ctx);
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
|
||||
// React to creation/deletion of home subdirs at runtime.
|
||||
ctx.subscribe_to_model(&HomeDirectoryWatcher::handle(ctx), |me, _, event, ctx| {
|
||||
me.global_rules
|
||||
.handle_home_dir_event_for_global_rules(event, ctx);
|
||||
});
|
||||
|
||||
for source in GlobalRuleSource::iter() {
|
||||
let subdir_path = home_dir.join(source.home_subdir());
|
||||
let target_file = subdir_path.join(source.file_pattern());
|
||||
|
||||
// Initial async read; if the file doesn't exist yet, the watcher
|
||||
// will pick it up on creation.
|
||||
Self::spawn_global_rule_read(target_file, ctx);
|
||||
|
||||
if subdir_path.exists() {
|
||||
self.register_global_source_watcher(source, &subdir_path, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async read of a single global rule file. The async block runs on a
|
||||
/// background executor; the main-thread callback updates model state once
|
||||
/// the read completes.
|
||||
fn spawn_global_rule_read(file_path: PathBuf, ctx: &mut ModelContext<ProjectContextModel>) {
|
||||
ctx.spawn(
|
||||
async move {
|
||||
// `read_to_string` returning `Err` (e.g. NotFound, permission
|
||||
// denied, file replaced with a non-regular file) is converted
|
||||
// to `None`; the callback below decides whether that means
|
||||
// "insert/refresh" or "drop a previously-known entry."
|
||||
let content = async_fs::read_to_string(&file_path).await.ok();
|
||||
(file_path, content)
|
||||
},
|
||||
move |me, (file_path, content_opt), ctx| match content_opt {
|
||||
Some(content) => {
|
||||
// Read succeeded: insert (or replace) the rule and notify
|
||||
// subscribers.
|
||||
me.global_rules.rules.insert(
|
||||
file_path.clone(),
|
||||
ProjectRule {
|
||||
// Global rule sources are watched under the local home directory.
|
||||
path: LocalOrRemotePath::Local(file_path.clone()),
|
||||
content,
|
||||
},
|
||||
);
|
||||
ctx.emit(ProjectContextModelEvent::GlobalRulesChanged(
|
||||
GlobalRulesDelta {
|
||||
discovered_rules: vec![file_path],
|
||||
deleted_rules: vec![],
|
||||
},
|
||||
));
|
||||
}
|
||||
None => {
|
||||
// Drop cached content if file is now unreadable; no-op if it never existed.
|
||||
if me.global_rules.rules.remove(&file_path).is_some() {
|
||||
ctx.emit(ProjectContextModelEvent::GlobalRulesChanged(
|
||||
GlobalRulesDelta {
|
||||
discovered_rules: vec![],
|
||||
deleted_rules: vec![file_path],
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Register a `DirectoryWatcher` on the given home subdir for incremental
|
||||
/// updates. Idempotent: subsequent calls for an already-watched subdir are
|
||||
/// a no-op (the `subdir_path` key dedups by directory rather than by
|
||||
/// source, so multiple sources sharing a `home_subdir` would only register
|
||||
/// the watcher once — a future change can fan out to multiple file
|
||||
/// patterns by extending the value).
|
||||
///
|
||||
/// The subdir must exist on disk before this is called.
|
||||
/// `DirectoryWatcher::add_directory` rejects non-existent paths, and
|
||||
/// runtime creation is handled by `handle_home_dir_event_for_global_rules`,
|
||||
/// which calls back here once the subdir appears.
|
||||
fn register_global_source_watcher(
|
||||
&mut self,
|
||||
source: GlobalRuleSource,
|
||||
subdir_path: &Path,
|
||||
ctx: &mut ModelContext<ProjectContextModel>,
|
||||
) {
|
||||
// If the subdir is already being watched, return early.
|
||||
if self.source_watchers.contains_key(subdir_path) {
|
||||
return;
|
||||
}
|
||||
|
||||
let (Some(update_tx), Ok(std_path)) = (
|
||||
self.updates_tx.clone(),
|
||||
StandardizedPath::from_local_canonicalized(subdir_path),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let repo_handle = match DirectoryWatcher::handle(ctx)
|
||||
.update(ctx, |watcher, ctx| watcher.add_directory(std_path, ctx))
|
||||
{
|
||||
Ok(handle) => handle,
|
||||
Err(err) => {
|
||||
// `safe_warn!` because the path contains the user's home dir,
|
||||
// which is PII; we only want the full path on dogfood builds.
|
||||
// The error itself can also embed the canonicalized path
|
||||
// (e.g. `RepoMetadataError::RepoNotFound(...)`), so we keep
|
||||
// it out of the safe branch as well — only the source name
|
||||
// is safe to send to Sentry.
|
||||
safe_warn!(
|
||||
safe: (
|
||||
"Failed to register {} for global rules watching",
|
||||
source.name()
|
||||
),
|
||||
full: (
|
||||
"Failed to register {} for global rules watching: {err}",
|
||||
subdir_path.display()
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let subscriber = Box::new(GlobalRulesRepositorySubscriber { source, update_tx });
|
||||
|
||||
let start = repo_handle.update(ctx, |repo, ctx| repo.start_watching(subscriber, ctx));
|
||||
let subscriber_id = start.subscriber_id;
|
||||
let subdir_path_owned = subdir_path.to_path_buf();
|
||||
|
||||
self.source_watchers.insert(
|
||||
subdir_path_owned.clone(),
|
||||
GlobalSourceWatcherState {
|
||||
repository: repo_handle.clone(),
|
||||
subscriber_id,
|
||||
},
|
||||
);
|
||||
|
||||
let cleanup_key = subdir_path_owned.clone();
|
||||
let subdir_for_log = subdir_path_owned;
|
||||
ctx.spawn(start.registration_future, move |me, res, ctx| {
|
||||
if let Err(err) = res {
|
||||
// Same PII shape as the registration error above: the path
|
||||
// and the error can both contain the user's home dir, so
|
||||
// both stay in the `full` branch only.
|
||||
safe_warn!(
|
||||
safe: (
|
||||
"Failed to start watching {} for global rules",
|
||||
source.name()
|
||||
),
|
||||
full: (
|
||||
"Failed to start watching {} for global rules: {err}",
|
||||
subdir_for_log.display()
|
||||
)
|
||||
);
|
||||
// Remove the stored watcher since registration failed.
|
||||
if let Some(state) = me.global_rules.source_watchers.remove(&cleanup_key) {
|
||||
state.repository.update(ctx, |repo, ctx| {
|
||||
repo.stop_watching(state.subscriber_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle an incremental update for the given global source.
|
||||
fn handle_global_rules_update(
|
||||
&mut self,
|
||||
source: GlobalRuleSource,
|
||||
update: RepositoryUpdate,
|
||||
ctx: &mut ModelContext<ProjectContextModel>,
|
||||
) {
|
||||
if update.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
return;
|
||||
};
|
||||
let target_file = home_dir
|
||||
.join(source.home_subdir())
|
||||
.join(source.file_pattern());
|
||||
|
||||
let was_deleted = update.deleted.iter().any(|f| f.path == target_file)
|
||||
|| update.moved.values().any(|f| f.path == target_file);
|
||||
let was_added_or_modified = update.added_or_modified().any(|f| f.path == target_file)
|
||||
|| update.moved.keys().any(|f| f.path == target_file);
|
||||
|
||||
// If the file was deleted, remove it from the cached content and emit a change event.
|
||||
if was_deleted && self.rules.remove(&target_file).is_some() {
|
||||
ctx.emit(ProjectContextModelEvent::GlobalRulesChanged(
|
||||
GlobalRulesDelta {
|
||||
discovered_rules: vec![],
|
||||
deleted_rules: vec![target_file.clone()],
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// If the file was added or modified, spawn a read to update the cached content.
|
||||
if was_added_or_modified {
|
||||
Self::spawn_global_rule_read(target_file, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// React to creation/deletion of the registered home subdirs at runtime.
|
||||
fn handle_home_dir_event_for_global_rules(
|
||||
&mut self,
|
||||
event: &HomeDirectoryWatcherEvent,
|
||||
ctx: &mut ModelContext<ProjectContextModel>,
|
||||
) {
|
||||
let HomeDirectoryWatcherEvent::HomeFilesChanged(fs_event) = event;
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
log::warn!("Home directory not found; skipping global rules home dir event");
|
||||
return;
|
||||
};
|
||||
|
||||
for source in GlobalRuleSource::iter() {
|
||||
let subdir_path = home_dir.join(source.home_subdir());
|
||||
|
||||
let subdir_deleted = fs_event.deleted.contains(&subdir_path)
|
||||
|| fs_event.moved.values().any(|v| v == &subdir_path);
|
||||
if subdir_deleted {
|
||||
if let Some(state) = self.source_watchers.remove(&subdir_path) {
|
||||
state.repository.update(ctx, |repo, ctx| {
|
||||
repo.stop_watching(state.subscriber_id, ctx);
|
||||
});
|
||||
}
|
||||
let target_file = subdir_path.join(source.file_pattern());
|
||||
if self.rules.remove(&target_file).is_some() {
|
||||
ctx.emit(ProjectContextModelEvent::GlobalRulesChanged(
|
||||
GlobalRulesDelta {
|
||||
discovered_rules: vec![],
|
||||
deleted_rules: vec![target_file],
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let subdir_added =
|
||||
fs_event.added.contains(&subdir_path) || fs_event.moved.contains_key(&subdir_path);
|
||||
if subdir_added {
|
||||
let target_file = subdir_path.join(source.file_pattern());
|
||||
// Kick off the read first, then register the watcher for subsequent edits.
|
||||
Self::spawn_global_rule_read(target_file, ctx);
|
||||
self.register_global_source_watcher(source, &subdir_path, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscriber for a single global rules home subdir (e.g. `~/.agents`).
|
||||
/// Tags every update with the originating [`GlobalRuleSource`] variant so the
|
||||
/// model can dispatch to the right entry without per-source channels.
|
||||
struct GlobalRulesRepositorySubscriber {
|
||||
source: GlobalRuleSource,
|
||||
update_tx: Sender<GlobalRulesUpdate>,
|
||||
}
|
||||
|
||||
impl RepositorySubscriber for GlobalRulesRepositorySubscriber {
|
||||
fn on_scan(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> std::pin::Pin<Box<dyn std::prelude::rust_2024::Future<Output = ()> + Send + 'static>> {
|
||||
// Initial-state read is performed separately by `spawn_global_rule_read`,
|
||||
// so the on_scan event is intentionally a no-op.
|
||||
Box::pin(async {})
|
||||
}
|
||||
|
||||
fn on_files_updated(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
update: &repo_metadata::RepositoryUpdate,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> std::pin::Pin<Box<dyn std::prelude::rust_2024::Future<Output = ()> + Send + 'static>> {
|
||||
let tx = self.update_tx.clone();
|
||||
let source = self.source;
|
||||
let update = update.clone();
|
||||
Box::pin(async move {
|
||||
let _ = tx.send(GlobalRulesUpdate { source, update }).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1 +1,10 @@
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
mod global_rules;
|
||||
pub(crate) use global_rules::GlobalRules;
|
||||
} else {
|
||||
mod dummy_global_rules;
|
||||
pub(crate) use dummy_global_rules::GlobalRules;
|
||||
}
|
||||
}
|
||||
pub mod model;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,41 @@
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warp_util::host_id::HostId;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use warp_util::remote_path::RemotePath;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
fn local_path(path: &str) -> LocalOrRemotePath {
|
||||
LocalOrRemotePath::Local(PathBuf::from(path))
|
||||
}
|
||||
|
||||
fn insert_remote_project_rule(
|
||||
model: &mut ProjectContextModel,
|
||||
host_id: &str,
|
||||
project_root: &str,
|
||||
rule_path: &str,
|
||||
content: &str,
|
||||
) {
|
||||
let rules = model
|
||||
.path_to_rules
|
||||
.entry(remote_path(host_id, project_root))
|
||||
.or_default();
|
||||
rules.upsert_rule(&remote_path(host_id, rule_path), content.to_string());
|
||||
}
|
||||
|
||||
fn remote_path(host_id: &str, path: &str) -> LocalOrRemotePath {
|
||||
LocalOrRemotePath::Remote(RemotePath::new(
|
||||
HostId::new(host_id.to_string()),
|
||||
StandardizedPath::try_new(path).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_applicable_rules_empty_rules() {
|
||||
let rules = ProjectRules { rules: vec![] };
|
||||
let path = PathBuf::from("/a/b/c/file.rs");
|
||||
let path = local_path("/a/b/c/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert!(result.is_empty());
|
||||
@@ -14,10 +45,10 @@ fn test_find_applicable_rules_empty_rules() {
|
||||
fn test_find_applicable_rules_no_matching_rules() {
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("/x/y/WARP.md"), "content1".to_string());
|
||||
rules.upsert_rule(Path::new("/z/AGENTS.md"), "content2".to_string());
|
||||
rules.upsert_rule(&local_path("/x/y/WARP.md"), "content1".to_string());
|
||||
rules.upsert_rule(&local_path("/z/AGENTS.md"), "content2".to_string());
|
||||
|
||||
let path = PathBuf::from("/a/b/c/file.rs");
|
||||
let path = local_path("/a/b/c/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert!(result.is_empty());
|
||||
@@ -27,52 +58,52 @@ fn test_find_applicable_rules_no_matching_rules() {
|
||||
fn test_find_applicable_rules_single_matching_rule() {
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("/a/WARP.md"), "content1".to_string());
|
||||
rules.upsert_rule(Path::new("/x/AGENTS.md"), "content2".to_string());
|
||||
rules.upsert_rule(&local_path("/a/WARP.md"), "content1".to_string());
|
||||
rules.upsert_rule(&local_path("/x/AGENTS.md"), "content2".to_string());
|
||||
|
||||
let path = PathBuf::from("/a/b/c/file.rs");
|
||||
let path = local_path("/a/b/c/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, PathBuf::from("/a/WARP.md"));
|
||||
assert_eq!(result[0].path, local_path("/a/WARP.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_applicable_rules_includes_all_ancestor_rules() {
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("/a/WARP.md"), "root_warp".to_string());
|
||||
rules.upsert_rule(Path::new("/a/b/WARP.md"), "nested_warp".to_string());
|
||||
rules.upsert_rule(Path::new("/a/b/c/WARP.md"), "deep_warp".to_string());
|
||||
rules.upsert_rule(&local_path("/a/WARP.md"), "root_warp".to_string());
|
||||
rules.upsert_rule(&local_path("/a/b/WARP.md"), "nested_warp".to_string());
|
||||
rules.upsert_rule(&local_path("/a/b/c/WARP.md"), "deep_warp".to_string());
|
||||
|
||||
let path = PathBuf::from("/a/b/c/d/file.rs");
|
||||
let path = local_path("/a/b/c/d/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert_eq!(result.len(), 3);
|
||||
|
||||
// All should be WARP.md files (same priority), order is not specified by depth
|
||||
// Just verify all expected rules are present
|
||||
let paths: Vec<PathBuf> = result.iter().map(|r| r.path.clone()).collect();
|
||||
assert!(paths.contains(&PathBuf::from("/a/WARP.md")));
|
||||
assert!(paths.contains(&PathBuf::from("/a/b/WARP.md")));
|
||||
assert!(paths.contains(&PathBuf::from("/a/b/c/WARP.md")));
|
||||
let paths: Vec<LocalOrRemotePath> = result.iter().map(|r| r.path.clone()).collect();
|
||||
assert!(paths.contains(&local_path("/a/WARP.md")));
|
||||
assert!(paths.contains(&local_path("/a/b/WARP.md")));
|
||||
assert!(paths.contains(&local_path("/a/b/c/WARP.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_applicable_rules_multiple_patterns() {
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("/a/b/AGENTS.md"), "agents_content".to_string());
|
||||
rules.upsert_rule(Path::new("/a/WARP.md"), "warp_content".to_string());
|
||||
rules.upsert_rule(&local_path("/a/b/AGENTS.md"), "agents_content".to_string());
|
||||
rules.upsert_rule(&local_path("/a/WARP.md"), "warp_content".to_string());
|
||||
|
||||
let path = PathBuf::from("/a/b/file.rs");
|
||||
let path = local_path("/a/b/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert_eq!(result.len(), 2);
|
||||
|
||||
assert_eq!(result[0].path, PathBuf::from("/a/b/AGENTS.md"));
|
||||
assert_eq!(result[0].path, local_path("/a/b/AGENTS.md"));
|
||||
assert_eq!(result[0].content, "agents_content");
|
||||
assert_eq!(result[1].path, PathBuf::from("/a/WARP.md"));
|
||||
assert_eq!(result[1].path, local_path("/a/WARP.md"));
|
||||
assert_eq!(result[1].content, "warp_content");
|
||||
}
|
||||
|
||||
@@ -80,13 +111,13 @@ fn test_find_applicable_rules_multiple_patterns() {
|
||||
fn test_find_applicable_rules_exact_path_match() {
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("/a/b/WARP.md"), "exact_match".to_string());
|
||||
rules.upsert_rule(&local_path("/a/b/WARP.md"), "exact_match".to_string());
|
||||
|
||||
let path = PathBuf::from("/a/b/file.rs");
|
||||
let path = local_path("/a/b/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, PathBuf::from("/a/b/WARP.md"));
|
||||
assert_eq!(result[0].path, local_path("/a/b/WARP.md"));
|
||||
assert_eq!(result[0].content, "exact_match");
|
||||
}
|
||||
|
||||
@@ -94,14 +125,14 @@ fn test_find_applicable_rules_exact_path_match() {
|
||||
fn test_find_applicable_rules_ignores_deeper_paths() {
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("/a/WARP.md"), "applicable".to_string());
|
||||
rules.upsert_rule(Path::new("/a/b/c/d/e/WARP.md"), "too_deep".to_string()); // Path doesn't contain /a/b
|
||||
rules.upsert_rule(&local_path("/a/WARP.md"), "applicable".to_string());
|
||||
rules.upsert_rule(&local_path("/a/b/c/d/e/WARP.md"), "too_deep".to_string()); // Path doesn't contain /a/b
|
||||
|
||||
let path = PathBuf::from("/a/b/file.rs");
|
||||
let path = local_path("/a/b/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, PathBuf::from("/a/WARP.md"));
|
||||
assert_eq!(result[0].path, local_path("/a/WARP.md"));
|
||||
assert_eq!(result[0].content, "applicable");
|
||||
}
|
||||
|
||||
@@ -109,13 +140,13 @@ fn test_find_applicable_rules_ignores_deeper_paths() {
|
||||
fn test_find_applicable_rules_handles_root_path() {
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("/WARP.md"), "root_rule".to_string());
|
||||
rules.upsert_rule(&local_path("/WARP.md"), "root_rule".to_string());
|
||||
|
||||
let path = PathBuf::from("/a/b/file.rs");
|
||||
let path = local_path("/a/b/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, PathBuf::from("/WARP.md"));
|
||||
assert_eq!(result[0].path, local_path("/WARP.md"));
|
||||
assert_eq!(result[0].content, "root_rule");
|
||||
}
|
||||
|
||||
@@ -129,36 +160,36 @@ fn test_find_applicable_rules_complex_scenario() {
|
||||
// All ancestor rule files should be included.
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("/a/WARP.md"), "a_warp".to_string());
|
||||
rules.upsert_rule(Path::new("/a/AGENTS.md"), "a_agents".to_string());
|
||||
rules.upsert_rule(Path::new("/a/b/WARP.md"), "ab_warp".to_string());
|
||||
rules.upsert_rule(Path::new("/a/b/AGENTS.md"), "ab_agents".to_string());
|
||||
rules.upsert_rule(Path::new("/x/WARP.md"), "irrelevant".to_string()); // Should be ignored
|
||||
rules.upsert_rule(&local_path("/a/WARP.md"), "a_warp".to_string());
|
||||
rules.upsert_rule(&local_path("/a/AGENTS.md"), "a_agents".to_string());
|
||||
rules.upsert_rule(&local_path("/a/b/WARP.md"), "ab_warp".to_string());
|
||||
rules.upsert_rule(&local_path("/a/b/AGENTS.md"), "ab_agents".to_string());
|
||||
rules.upsert_rule(&local_path("/x/WARP.md"), "irrelevant".to_string()); // Should be ignored
|
||||
|
||||
let path = PathBuf::from("/a/b/c/file.rs");
|
||||
let path = local_path("/a/b/c/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert_eq!(result.len(), 4);
|
||||
|
||||
let paths: Vec<PathBuf> = result.iter().map(|r| r.path.clone()).collect();
|
||||
assert!(paths.contains(&PathBuf::from("/a/WARP.md")));
|
||||
assert!(paths.contains(&PathBuf::from("/a/AGENTS.md")));
|
||||
assert!(paths.contains(&PathBuf::from("/a/b/WARP.md")));
|
||||
assert!(paths.contains(&PathBuf::from("/a/b/AGENTS.md")));
|
||||
// Expect only WARP.md files to be included as they have higher priority.
|
||||
assert_eq!(result[0].path, local_path("/a/WARP.md"));
|
||||
assert_eq!(result[0].content, "a_warp");
|
||||
assert_eq!(result[1].path, local_path("/a/b/WARP.md"));
|
||||
assert_eq!(result[1].content, "ab_warp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_applicable_rules_handles_unknown_file_patterns() {
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("/a/WARP.md"), "known_pattern".to_string());
|
||||
rules.upsert_rule(Path::new("/a/UNKNOWN.md"), "unknown_pattern".to_string());
|
||||
let path = PathBuf::from("/a/file.rs");
|
||||
rules.upsert_rule(&local_path("/a/WARP.md"), "known_pattern".to_string());
|
||||
rules.upsert_rule(&local_path("/a/UNKNOWN.md"), "unknown_pattern".to_string());
|
||||
let path = local_path("/a/file.rs");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert_eq!(result.len(), 1);
|
||||
|
||||
assert_eq!(result[0].path, PathBuf::from("/a/WARP.md"));
|
||||
assert_eq!(result[0].path, local_path("/a/WARP.md"));
|
||||
assert_eq!(result[0].content, "known_pattern");
|
||||
}
|
||||
|
||||
@@ -166,20 +197,459 @@ fn test_find_applicable_rules_handles_unknown_file_patterns() {
|
||||
fn test_find_applicable_rules_with_relative_paths() {
|
||||
let mut rules = ProjectRules::default();
|
||||
|
||||
rules.upsert_rule(Path::new("src/WARP.md"), "src_warp".to_string());
|
||||
rules.upsert_rule(&local_path("src/WARP.md"), "src_warp".to_string());
|
||||
rules.upsert_rule(
|
||||
Path::new("src/components/WARP.md"),
|
||||
&local_path("src/components/WARP.md"),
|
||||
"components_warp".to_string(),
|
||||
);
|
||||
|
||||
let path = PathBuf::from("src/components/Button.tsx");
|
||||
let path = local_path("src/components/Button.tsx");
|
||||
|
||||
let result = rules.find_active_or_applicable_rules(&path).active_rules;
|
||||
assert_eq!(result.len(), 2);
|
||||
|
||||
// Both are WARP.md files (same priority), order within same priority is not guaranteed
|
||||
// Just verify both rules are present
|
||||
let paths: Vec<PathBuf> = result.iter().map(|r| r.path.clone()).collect();
|
||||
assert!(paths.contains(&PathBuf::from("src/WARP.md")));
|
||||
assert!(paths.contains(&PathBuf::from("src/components/WARP.md")));
|
||||
let paths: Vec<LocalOrRemotePath> = result.iter().map(|r| r.path.clone()).collect();
|
||||
assert!(paths.contains(&local_path("src/WARP.md")));
|
||||
assert!(paths.contains(&local_path("src/components/WARP.md")));
|
||||
}
|
||||
|
||||
fn make_rule_path(path: &str) -> ProjectRulePath {
|
||||
ProjectRulePath {
|
||||
path: PathBuf::from(path),
|
||||
project_root: PathBuf::from("/project"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_independent_deltas() {
|
||||
let mut delta = RulesDelta {
|
||||
discovered_rules: vec![make_rule_path("/a/WARP.md")],
|
||||
deleted_rules: vec![],
|
||||
};
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![],
|
||||
deleted_rules: vec![PathBuf::from("/b/WARP.md")],
|
||||
});
|
||||
|
||||
assert_eq!(delta.discovered_rules.len(), 1);
|
||||
assert_eq!(delta.discovered_rules[0].path, PathBuf::from("/a/WARP.md"));
|
||||
assert_eq!(delta.deleted_rules, vec![PathBuf::from("/b/WARP.md")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_add_then_delete_yields_delete() {
|
||||
let mut delta = RulesDelta {
|
||||
discovered_rules: vec![make_rule_path("/a/WARP.md")],
|
||||
deleted_rules: vec![],
|
||||
};
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![],
|
||||
deleted_rules: vec![PathBuf::from("/a/WARP.md")],
|
||||
});
|
||||
|
||||
assert!(delta.discovered_rules.is_empty());
|
||||
assert_eq!(delta.deleted_rules, vec![PathBuf::from("/a/WARP.md")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_delete_then_add_yields_add() {
|
||||
let mut delta = RulesDelta {
|
||||
discovered_rules: vec![],
|
||||
deleted_rules: vec![PathBuf::from("/a/WARP.md")],
|
||||
};
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![make_rule_path("/a/WARP.md")],
|
||||
deleted_rules: vec![],
|
||||
});
|
||||
|
||||
assert_eq!(delta.discovered_rules.len(), 1);
|
||||
assert_eq!(delta.discovered_rules[0].path, PathBuf::from("/a/WARP.md"));
|
||||
assert!(delta.deleted_rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_add_delete_add_yields_add() {
|
||||
let mut delta = RulesDelta::default();
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![make_rule_path("/a/WARP.md")],
|
||||
deleted_rules: vec![],
|
||||
});
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![],
|
||||
deleted_rules: vec![PathBuf::from("/a/WARP.md")],
|
||||
});
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![make_rule_path("/a/WARP.md")],
|
||||
deleted_rules: vec![],
|
||||
});
|
||||
|
||||
assert_eq!(delta.discovered_rules.len(), 1);
|
||||
assert_eq!(delta.discovered_rules[0].path, PathBuf::from("/a/WARP.md"));
|
||||
assert!(delta.deleted_rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_delete_add_delete_yields_delete() {
|
||||
let mut delta = RulesDelta::default();
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![],
|
||||
deleted_rules: vec![PathBuf::from("/a/WARP.md")],
|
||||
});
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![make_rule_path("/a/WARP.md")],
|
||||
deleted_rules: vec![],
|
||||
});
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![],
|
||||
deleted_rules: vec![PathBuf::from("/a/WARP.md")],
|
||||
});
|
||||
|
||||
assert!(delta.discovered_rules.is_empty());
|
||||
assert_eq!(delta.deleted_rules, vec![PathBuf::from("/a/WARP.md")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_rediscovery_keeps_latest() {
|
||||
let mut delta = RulesDelta {
|
||||
discovered_rules: vec![make_rule_path("/a/WARP.md")],
|
||||
deleted_rules: vec![],
|
||||
};
|
||||
// A second discovery of the same path (content update) should deduplicate.
|
||||
delta.merge(RulesDelta {
|
||||
discovered_rules: vec![make_rule_path("/a/WARP.md")],
|
||||
deleted_rules: vec![],
|
||||
});
|
||||
|
||||
assert_eq!(delta.discovered_rules.len(), 1);
|
||||
assert!(delta.deleted_rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_rule_content_preserves_cached_content_while_path_is_standing() {
|
||||
let rule_path = local_path("/unavailable/project/WARP.md");
|
||||
let mut existing_rules = ProjectRules::default();
|
||||
existing_rules.upsert_rule(&rule_path, "cached content".to_string());
|
||||
|
||||
let rules = ProjectContextModel::reconcile_project_rules(
|
||||
vec![rule_path.clone()],
|
||||
Vec::new(),
|
||||
existing_rules,
|
||||
);
|
||||
let result = rules.find_active_or_applicable_rules(&local_path("/unavailable/project/main.rs"));
|
||||
|
||||
assert_eq!(result.active_rules.len(), 1);
|
||||
assert_eq!(result.active_rules[0].path, rule_path);
|
||||
assert_eq!(result.active_rules[0].content, "cached content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rule_missing_from_standing_results_is_removed_from_cached_content() {
|
||||
let rule_path = local_path("/unavailable/project/WARP.md");
|
||||
let mut existing_rules = ProjectRules::default();
|
||||
existing_rules.upsert_rule(&rule_path, "cached content".to_string());
|
||||
|
||||
let rules =
|
||||
ProjectContextModel::reconcile_project_rules(Vec::new(), Vec::new(), existing_rules);
|
||||
assert!(rules.rule_paths().next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reconcile_project_rules_hydrates_local_and_remote_paths() {
|
||||
let local_rule_path = local_path("/local/WARP.md");
|
||||
let remote_rule_path = remote_path("host-a", "/remote/AGENTS.md");
|
||||
|
||||
let rules = ProjectContextModel::reconcile_project_rules(
|
||||
vec![local_rule_path.clone(), remote_rule_path.clone()],
|
||||
vec![
|
||||
(local_rule_path.clone(), "local content".to_string()),
|
||||
(remote_rule_path.clone(), "remote content".to_string()),
|
||||
],
|
||||
ProjectRules::default(),
|
||||
);
|
||||
|
||||
let local_result = rules.find_active_or_applicable_rules(&local_path("/local/main.rs"));
|
||||
assert_eq!(local_result.active_rules.len(), 1);
|
||||
assert_eq!(local_result.active_rules[0].path, local_rule_path);
|
||||
assert_eq!(local_result.active_rules[0].content, "local content");
|
||||
|
||||
let remote_result =
|
||||
rules.find_active_or_applicable_rules(&remote_path("host-a", "/remote/main.rs"));
|
||||
assert_eq!(remote_result.active_rules.len(), 1);
|
||||
assert_eq!(remote_result.active_rules[0].path, remote_rule_path);
|
||||
assert_eq!(remote_result.active_rules[0].content, "remote content");
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_remote_standing_results_preserve_host_qualified_rule_paths() {
|
||||
let host = HostId::new("test-host".to_string());
|
||||
let repo_id = RepositoryIdentifier::Remote(RemotePath::new(
|
||||
host.clone(),
|
||||
StandardizedPath::try_new("/repo").unwrap(),
|
||||
));
|
||||
let rule_path = StandardizedPath::try_new("/repo/nested/WARP.md").unwrap();
|
||||
let contents = [
|
||||
StandingQueryContent::file(rule_path.clone()),
|
||||
StandingQueryContent::directory(StandardizedPath::try_new("/repo/nested").unwrap()),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
standing_project_rule_paths(&repo_id, &contents),
|
||||
vec![LocalOrRemotePath::Remote(RemotePath::new(host, rule_path))]
|
||||
);
|
||||
}
|
||||
|
||||
// Helper for global-rules tests: inserts a synthetic global rule directly into
|
||||
// the model. Bypasses the watcher infrastructure (which requires the warpui
|
||||
// runtime) so we can exercise `find_applicable_rules`'s layering logic.
|
||||
fn insert_global_rule(model: &mut ProjectContextModel, path: &Path, content: &str) {
|
||||
model.global_rules.rules.insert(
|
||||
path.to_path_buf(),
|
||||
ProjectRule {
|
||||
path: LocalOrRemotePath::Local(path.to_path_buf()),
|
||||
content: content.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn insert_project_rule(
|
||||
model: &mut ProjectContextModel,
|
||||
project_root: &Path,
|
||||
rule_path: &Path,
|
||||
content: &str,
|
||||
) {
|
||||
let rules = model
|
||||
.path_to_rules
|
||||
.entry(LocalOrRemotePath::Local(project_root.to_path_buf()))
|
||||
.or_default();
|
||||
rules.upsert_rule(
|
||||
&LocalOrRemotePath::Local(rule_path.to_path_buf()),
|
||||
content.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remote_project_rules_require_matching_host() {
|
||||
let mut model = ProjectContextModel::default();
|
||||
insert_remote_project_rule(
|
||||
&mut model,
|
||||
"host-a",
|
||||
"/repo",
|
||||
"/repo/WARP.md",
|
||||
"remote_project_rule",
|
||||
);
|
||||
|
||||
let same_host = model
|
||||
.find_applicable_project_rules(&remote_path("host-a", "/repo/src/main.rs"))
|
||||
.expect("same-host remote rule should apply");
|
||||
assert_eq!(same_host.root_path, remote_path("host-a", "/repo"));
|
||||
assert_eq!(same_host.active_rules.len(), 1);
|
||||
assert_eq!(same_host.active_rules[0].content, "remote_project_rule");
|
||||
|
||||
let other_host =
|
||||
model.find_applicable_project_rules(&remote_path("host-b", "/repo/src/main.rs"));
|
||||
assert!(other_host.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_rule_alone_no_project_rules() {
|
||||
let mut model = ProjectContextModel::default();
|
||||
insert_global_rule(
|
||||
&mut model,
|
||||
Path::new("/home/u/.agents/AGENTS.md"),
|
||||
"global_content",
|
||||
);
|
||||
|
||||
let result = model
|
||||
.find_applicable_rules(&local_path("/some/project/file.rs"))
|
||||
.expect("global rule should produce a result");
|
||||
|
||||
assert_eq!(result.active_rules.len(), 1);
|
||||
assert_eq!(
|
||||
result.active_rules[0].path,
|
||||
local_path("/home/u/.agents/AGENTS.md")
|
||||
);
|
||||
assert_eq!(result.active_rules[0].content, "global_content");
|
||||
assert!(result.additional_rule_paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_rule_layered_with_project_warp() {
|
||||
let mut model = ProjectContextModel::default();
|
||||
insert_global_rule(&mut model, Path::new("/home/u/.agents/AGENTS.md"), "global");
|
||||
insert_project_rule(
|
||||
&mut model,
|
||||
Path::new("/repo"),
|
||||
Path::new("/repo/WARP.md"),
|
||||
"project_warp",
|
||||
);
|
||||
|
||||
let result = model
|
||||
.find_applicable_rules(&local_path("/repo/src/main.rs"))
|
||||
.expect("layered rules should produce a result");
|
||||
|
||||
// Layered precedence: global first, then project rules.
|
||||
assert_eq!(result.active_rules.len(), 2);
|
||||
assert_eq!(result.active_rules[0].content, "global");
|
||||
assert_eq!(result.active_rules[1].content, "project_warp");
|
||||
assert_eq!(result.root_path, local_path("/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_in_dir_warp_shadows_agents_with_global() {
|
||||
let mut model = ProjectContextModel::default();
|
||||
insert_global_rule(&mut model, Path::new("/home/u/.agents/AGENTS.md"), "global");
|
||||
// Both WARP.md and AGENTS.md in the same project directory: WARP.md should
|
||||
// shadow AGENTS.md (existing in-directory behavior preserved).
|
||||
insert_project_rule(
|
||||
&mut model,
|
||||
Path::new("/repo"),
|
||||
Path::new("/repo/WARP.md"),
|
||||
"project_warp",
|
||||
);
|
||||
insert_project_rule(
|
||||
&mut model,
|
||||
Path::new("/repo"),
|
||||
Path::new("/repo/AGENTS.md"),
|
||||
"project_agents",
|
||||
);
|
||||
|
||||
let result = model
|
||||
.find_applicable_rules(&local_path("/repo/src/main.rs"))
|
||||
.expect("layered rules should produce a result");
|
||||
|
||||
// Expect: [global, project WARP.md]. project AGENTS.md is shadowed.
|
||||
assert_eq!(result.active_rules.len(), 2);
|
||||
assert_eq!(result.active_rules[0].content, "global");
|
||||
assert_eq!(result.active_rules[1].content, "project_warp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_rules_returns_none() {
|
||||
let model = ProjectContextModel::default();
|
||||
let result = model.find_applicable_rules(&local_path("/some/path/file.rs"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_rule_root_path_falls_back_to_parent() {
|
||||
let mut model = ProjectContextModel::default();
|
||||
insert_global_rule(&mut model, Path::new("/home/u/.agents/AGENTS.md"), "global");
|
||||
|
||||
let result = model
|
||||
.find_applicable_rules(&local_path("/some/file.rs"))
|
||||
.expect("global rule should produce a result");
|
||||
|
||||
// No project root indexed; root_path falls back to parent of the global rule.
|
||||
assert_eq!(result.root_path, local_path("/home/u/.agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_global_rules_all_contribute() {
|
||||
let mut model = ProjectContextModel::default();
|
||||
insert_global_rule(
|
||||
&mut model,
|
||||
Path::new("/home/u/.agents/AGENTS.md"),
|
||||
"agents_global",
|
||||
);
|
||||
insert_global_rule(
|
||||
&mut model,
|
||||
Path::new("/home/u/.warp/WARP.md"),
|
||||
"warp_global",
|
||||
);
|
||||
|
||||
let result = model
|
||||
.find_applicable_rules(&local_path("/repo/src/main.rs"))
|
||||
.expect("globals should produce a result");
|
||||
|
||||
assert_eq!(result.active_rules.len(), 2);
|
||||
let contents: Vec<&str> = result
|
||||
.active_rules
|
||||
.iter()
|
||||
.map(|r| r.content.as_str())
|
||||
.collect();
|
||||
assert!(contents.contains(&"agents_global"));
|
||||
assert!(contents.contains(&"warp_global"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remote_global_rules_only_layer_for_matching_remote_host() {
|
||||
let mut model = ProjectContextModel::default();
|
||||
insert_global_rule(
|
||||
&mut model,
|
||||
Path::new("/home/local/.agents/AGENTS.md"),
|
||||
"local_global",
|
||||
);
|
||||
insert_remote_project_rule(
|
||||
&mut model,
|
||||
"host-a",
|
||||
"/repo",
|
||||
"/repo/WARP.md",
|
||||
"remote_project",
|
||||
);
|
||||
let host_a = HostId::new("host-a".to_string());
|
||||
model.set_remote_global_rules(
|
||||
host_a.clone(),
|
||||
vec![ProjectRule {
|
||||
path: remote_path("host-a", "/home/remote/.agents/AGENTS.md"),
|
||||
content: "remote_global".to_string(),
|
||||
}],
|
||||
);
|
||||
model.set_remote_global_rules(
|
||||
HostId::new("host-b".to_string()),
|
||||
vec![ProjectRule {
|
||||
path: remote_path("host-b", "/home/remote/.agents/AGENTS.md"),
|
||||
content: "other_remote_global".to_string(),
|
||||
}],
|
||||
);
|
||||
|
||||
let matching = model
|
||||
.find_applicable_rules(&remote_path("host-a", "/repo/src/main.rs"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
matching
|
||||
.active_rules
|
||||
.iter()
|
||||
.map(|rule| rule.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["local_global", "remote_global", "remote_project"]
|
||||
);
|
||||
|
||||
let other_host = model
|
||||
.find_applicable_rules(&remote_path("host-b", "/repo/src/main.rs"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
other_host
|
||||
.active_rules
|
||||
.iter()
|
||||
.map(|rule| rule.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["local_global", "other_remote_global"]
|
||||
);
|
||||
|
||||
let local = model
|
||||
.find_applicable_rules(&local_path("/repo/src/main.rs"))
|
||||
.unwrap();
|
||||
assert_eq!(local.active_rules.len(), 1);
|
||||
assert_eq!(local.active_rules[0].content, "local_global");
|
||||
|
||||
assert_eq!(
|
||||
model.global_rule_paths().collect::<Vec<_>>(),
|
||||
[local_path("/home/local/.agents/AGENTS.md")]
|
||||
);
|
||||
|
||||
model.set_remote_global_rules(host_a, Vec::new());
|
||||
let replaced = model
|
||||
.find_applicable_rules(&remote_path("host-a", "/repo/src/main.rs"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
replaced
|
||||
.active_rules
|
||||
.iter()
|
||||
.map(|rule| rule.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["local_global", "remote_project"]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user