first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -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;
})
}
}
+9
View File
@@ -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
+523 -53
View File
@@ -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"]
);
}