diff --git a/app/src/ai/blocklist/context_model.rs b/app/src/ai/blocklist/context_model.rs index 6f6143bb51..2e7838cf28 100644 --- a/app/src/ai/blocklist/context_model.rs +++ b/app/src/ai/blocklist/context_model.rs @@ -3,12 +3,12 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::str::FromStr; use std::sync::Arc; use ai::project_context::model::ProjectContextModel; use parking_lot::FairMutex; use warp_core::features::FeatureFlag; +use warp_util::local_or_remote_path::LocalOrRemotePath; use warpui::{ AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle, }; @@ -393,7 +393,14 @@ impl BlocklistAIContextModel { /// Returns `AIAgentContext` for the blocks to be included in the current AI query. /// If `is_user_query` is true, includes blocks, selected text, and images as context. /// If false, excludes these user-specific contexts but includes everything else. - pub fn pending_context(&self, app: &AppContext, is_user_query: bool) -> Vec { + pub fn pending_context( + &self, + app: &AppContext, + is_user_query: bool, + current_working_directory_location: Option<&LocalOrRemotePath>, + ) -> Vec { + // `pwd` is the shell-reported path used for directory context and local indexing. + // The location is passed separately because it preserves remote host identity for rules. let pwd = self.current_pwd(); let is_pwd_indexed = if cfg!(feature = "agent_mode_evals") { // In evals, we want to disable file outline based search. Full @@ -406,15 +413,8 @@ impl BlocklistAIContextModel { }) }; - let project_rules = if let Some(pwd) = pwd.clone().and_then(|path| { - PathBuf::from_str(&path) - .ok() - .and_then(|s| s.canonicalize().ok()) - }) { - ProjectContextModel::as_ref(app).find_applicable_rules(&pwd) - } else { - None - }; + let project_rules = current_working_directory_location + .and_then(|pwd| ProjectContextModel::as_ref(app).find_applicable_rules(pwd)); let mut context = Vec::new(); @@ -451,14 +451,14 @@ impl BlocklistAIContextModel { // Always include project rules if available if let Some(rules) = project_rules { context.push(AIAgentContext::ProjectRules { - root_path: rules.root_path.to_string_lossy().into(), + root_path: rules.root_path.display_path(), active_rules: rules .active_rules .into_iter() .map(|rule| { let line_count = rule.content.lines().count(); FileContext { - file_name: rule.path.to_string_lossy().into(), + file_name: rule.path.display_path(), content: AnyFileContent::StringContent(rule.content.clone()), line_range: None, last_modified: None, diff --git a/app/src/ai/blocklist/controller/input_context.rs b/app/src/ai/blocklist/controller/input_context.rs index 2e531c17d6..b58a244bd3 100644 --- a/app/src/ai/blocklist/controller/input_context.rs +++ b/app/src/ai/blocklist/controller/input_context.rs @@ -52,7 +52,12 @@ pub(super) fn input_context_for_request( additional_context: Vec, app: &AppContext, ) -> Arc<[AIAgentContext]> { - let mut context = context_model.pending_context(app, is_user_query); + let current_working_directory_location = active_session.current_working_directory_location(app); + let mut context = context_model.pending_context( + app, + is_user_query, + current_working_directory_location.as_ref(), + ); context.push(AIAgentContext::CurrentTime { current_time: Local::now(), diff --git a/app/src/ai/facts/view/mod.rs b/app/src/ai/facts/view/mod.rs index 6017e1895c..5a4337b808 100644 --- a/app/src/ai/facts/view/mod.rs +++ b/app/src/ai/facts/view/mod.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use warp_core::ui::appearance::Appearance; +use warp_util::local_or_remote_path::LocalOrRemotePath; use warpui::elements::{ Align, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CrossAxisAlignment, Expanded, Flex, MainAxisAlignment, MainAxisSize, ParentElement, @@ -55,7 +56,7 @@ impl std::fmt::Display for AIFactPage { pub enum AIFactViewEvent { Pane(PaneEvent), OpenSettings, - OpenFile(PathBuf), + OpenFile(LocalOrRemotePath), InitializeProject(PathBuf), } diff --git a/app/src/ai/facts/view/rule.rs b/app/src/ai/facts/view/rule.rs index 4f959c2984..880d384175 100644 --- a/app/src/ai/facts/view/rule.rs +++ b/app/src/ai/facts/view/rule.rs @@ -6,6 +6,7 @@ use markdown_parser::weight::CustomWeight; use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use warp_core::ui::appearance::{Appearance, AppearanceEvent}; use warp_core::ui::theme::color::internal_colors; +use warp_util::local_or_remote_path::LocalOrRemotePath; use warpui::elements::{ Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment, @@ -38,6 +39,7 @@ use crate::server::ids::{ClientId, SyncId}; use crate::server::sync_queue::SyncQueue; use crate::settings::{AISettings, AISettingsChangedEvent}; use crate::ui_components::icons::Icon; +use crate::util::path::display_path_with_host; use crate::view_components::action_button::{ActionButton, NakedTheme}; use crate::view_components::DismissibleToast; use crate::workspace::ToastStack; @@ -68,7 +70,7 @@ pub enum RuleViewEvent { AddRule, Edit(SyncId), OpenSettings, - OpenFile(PathBuf), + OpenFile(LocalOrRemotePath), InitializeProject(PathBuf), } @@ -79,7 +81,7 @@ pub enum RuleViewAction { Edit(SyncId), OpenSettings, SelectScope(RuleScope), - OpenFile(PathBuf), + OpenFile(LocalOrRemotePath), } #[derive(Default, Debug, Clone)] @@ -101,7 +103,7 @@ struct CloudRuleRow { /// plus an "Open file" button. #[derive(Debug, Clone)] struct FileBackedRow { - file_path: PathBuf, + file_path: LocalOrRemotePath, mouse_state: MouseStateHandle, } @@ -126,9 +128,9 @@ impl RuleRow { } RuleRow::FileBacked(row) => row .file_path - .to_str() - .map(|s| s.to_lowercase().contains(search_term)) - .unwrap_or(false), + .display_path() + .to_lowercase() + .contains(search_term), } } @@ -137,7 +139,9 @@ impl RuleRow { (RuleRow::Global(a), RuleRow::Global(b)) => { b.fact.metadata().revision.cmp(&a.fact.metadata().revision) } - (RuleRow::FileBacked(a), RuleRow::FileBacked(b)) => a.file_path.cmp(&b.file_path), + (RuleRow::FileBacked(a), RuleRow::FileBacked(b)) => { + a.file_path.display_path().cmp(&b.file_path.display_path()) + } _ => std::cmp::Ordering::Equal, } } @@ -704,8 +708,9 @@ impl RuleView { &self, project_row: FileBackedRow, appearance: &Appearance, + app: &AppContext, ) -> Option> { - let row_name = project_row.file_path.to_str().map(|s| s.to_string())?; + let row_name = display_path_with_host(&project_row.file_path, false, app); let mut row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) @@ -868,7 +873,9 @@ impl RuleView { RuleRow::Global(global_row) => { Some(self.render_global_rule_row(*global_row, appearance, app)) } - RuleRow::FileBacked(file_row) => self.render_file_backed_row(file_row, appearance), + RuleRow::FileBacked(file_row) => { + self.render_file_backed_row(file_row, appearance, app) + } }; if let Some(row) = row { diff --git a/app/src/ai/metadata_project_rules.rs b/app/src/ai/metadata_project_rules.rs new file mode 100644 index 0000000000..ad83bea69e --- /dev/null +++ b/app/src/ai/metadata_project_rules.rs @@ -0,0 +1,110 @@ +use std::collections::HashMap; + +use ai::project_context::model::ProjectRuleContents; +use futures::future::{BoxFuture, FutureExt as _}; +use remote_server::proto::{ + file_context_proto, FileContextProto, ReadFileContextFile, ReadFileContextRequest, +}; +use warp_util::local_or_remote_path::LocalOrRemotePath; +use warpui::{AppContext, SingletonEntity}; + +use crate::remote_server::manager::RemoteServerManager; + +pub(crate) fn read_project_rule_contents( + rule_paths: Vec, + ctx: &AppContext, +) -> BoxFuture<'static, anyhow::Result> { + match rule_paths.first() { + None => futures::future::ready(Ok(Vec::new())).boxed(), + Some(LocalOrRemotePath::Local(_)) => async move { + let mut contents = Vec::new(); + for path in rule_paths { + let Some(local_path) = path.to_local_path().map(std::path::Path::to_path_buf) + else { + anyhow::bail!("Project rule paths mixed local and remote locations"); + }; + match async_fs::read_to_string(&local_path).await { + Ok(content) => contents.push((path, content)), + Err(error) => log::debug!( + "Failed to read project rule file {}: {error}", + local_path.display() + ), + } + } + Ok(contents) + } + .boxed(), + Some(LocalOrRemotePath::Remote(remote)) => { + let host_id = remote.host_id.clone(); + let handle = RemoteServerManager::as_ref(ctx).host_request_handle(&host_id); + async move { + if rule_paths.iter().any(|path| { + !matches!( + path, + LocalOrRemotePath::Remote(candidate) if candidate.host_id == host_id + ) + }) { + anyhow::bail!("Project rule paths span multiple locations"); + } + let response = handle + .read_file_context(remote_rule_read_request(&rule_paths)) + .await?; + Ok(pair_remote_rule_paths_with_contents( + rule_paths, + response.file_contexts, + )) + } + .boxed() + } + } +} + +fn remote_rule_read_request(rule_paths: &[LocalOrRemotePath]) -> ReadFileContextRequest { + ReadFileContextRequest { + files: rule_paths + .iter() + .filter_map(|path| match path { + LocalOrRemotePath::Remote(remote) => Some(ReadFileContextFile { + path: remote.path.as_str().to_string(), + line_ranges: Vec::new(), + }), + LocalOrRemotePath::Local(_) => None, + }) + .collect(), + max_file_bytes: None, + max_batch_bytes: None, + } +} + +/// Pairs remote read responses with the original host-qualified paths. +/// +/// Responses may be reordered or omit unreadable files, and their file names do not include the +/// host ID. Matching by path preserves the correct host identity without relying on response order. +fn pair_remote_rule_paths_with_contents( + rule_paths: Vec, + file_contexts: Vec, +) -> Vec<(LocalOrRemotePath, String)> { + let content_by_path = file_contexts + .into_iter() + .filter_map(|file_context| { + let file_context_proto::Content::TextContent(content) = file_context.content? else { + return None; + }; + Some((file_context.file_name, content)) + }) + .collect::>(); + rule_paths + .into_iter() + .filter_map(|path| { + let LocalOrRemotePath::Remote(remote) = &path else { + return None; + }; + let content = content_by_path.get(remote.path.as_str())?.clone(); + Some((path, content)) + }) + .collect() +} + +#[cfg(test)] +#[path = "metadata_project_rules_tests.rs"] +mod tests; diff --git a/app/src/ai/metadata_project_rules_tests.rs b/app/src/ai/metadata_project_rules_tests.rs new file mode 100644 index 0000000000..04466e1885 --- /dev/null +++ b/app/src/ai/metadata_project_rules_tests.rs @@ -0,0 +1,89 @@ +use remote_server::proto::{file_context_proto, FileContextProto}; +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; + +use super::{pair_remote_rule_paths_with_contents, remote_rule_read_request}; + +fn remote_rule_path(host_id: &HostId, name: &str) -> LocalOrRemotePath { + LocalOrRemotePath::Remote(RemotePath::new( + host_id.clone(), + StandardizedPath::try_new(format!("/repo/{name}").as_str()).unwrap(), + )) +} + +fn remote_rule_file_context(path: &LocalOrRemotePath, content: &str) -> FileContextProto { + let LocalOrRemotePath::Remote(remote) = path else { + panic!("Expected a remote rule path"); + }; + + FileContextProto { + file_name: remote.path.as_str().to_string(), + content: Some(file_context_proto::Content::TextContent( + content.to_string(), + )), + line_range_start: None, + line_range_end: None, + last_modified_epoch_millis: None, + line_count: content.lines().count() as u32, + } +} + +#[test] +fn remote_rule_contents_match_reordered_responses_by_path() { + let host = HostId::new("test-host".to_string()); + let first_path = remote_rule_path(&host, "WARP.md"); + let second_path = remote_rule_path(&host, "nested/AGENTS.md"); + + let contents = pair_remote_rule_paths_with_contents( + vec![first_path.clone(), second_path.clone()], + vec![ + remote_rule_file_context(&second_path, "second rules"), + remote_rule_file_context(&first_path, "first rules"), + ], + ); + + assert_eq!( + contents, + vec![ + (first_path, "first rules".to_string()), + (second_path, "second rules".to_string()), + ] + ); +} + +#[test] +fn remote_rule_contents_keep_paths_aligned_after_missing_reads() { + let host = HostId::new("test-host".to_string()); + let missing_path = remote_rule_path(&host, "WARP.md"); + let present_path = remote_rule_path(&host, "nested/AGENTS.md"); + + let contents = pair_remote_rule_paths_with_contents( + vec![missing_path, present_path.clone()], + vec![remote_rule_file_context(&present_path, "present rules")], + ); + + assert_eq!(contents, vec![(present_path, "present rules".to_string())]); +} + +#[test] +fn remote_rule_read_request_preserves_discovered_paths() { + let host = HostId::new("test-host".to_string()); + let first_path = remote_rule_path(&host, "WARP.md"); + let second_path = remote_rule_path(&host, "nested/AGENTS.md"); + + let request = remote_rule_read_request(&[first_path.clone(), second_path.clone()]); + + assert_eq!(request.max_file_bytes, None); + assert_eq!(request.max_batch_bytes, None); + assert_eq!(request.files.len(), 2); + let LocalOrRemotePath::Remote(first_remote) = first_path else { + panic!("Expected a remote rule path"); + }; + let LocalOrRemotePath::Remote(second_remote) = second_path else { + panic!("Expected a remote rule path"); + }; + assert_eq!(request.files[0].path, first_remote.path.as_str()); + assert_eq!(request.files[1].path, second_remote.path.as_str()); +} diff --git a/app/src/ai/mod.rs b/app/src/ai/mod.rs index 6a022e60b1..588c322da8 100644 --- a/app/src/ai/mod.rs +++ b/app/src/ai/mod.rs @@ -31,6 +31,7 @@ pub mod harness_availability; pub(crate) mod harness_display; pub(crate) mod llms; pub(crate) mod local_harness_setup; +pub(crate) mod metadata_project_rules; pub mod onboarding; pub(crate) mod persisted_workspace; pub(crate) mod predict; diff --git a/app/src/ai/persisted_workspace.rs b/app/src/ai/persisted_workspace.rs index 1e546b5caf..3c554afd7c 100644 --- a/app/src/ai/persisted_workspace.rs +++ b/app/src/ai/persisted_workspace.rs @@ -34,6 +34,7 @@ use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; use crate::ai::codebase_auto_indexing::{ auto_index_candidate_roots, should_auto_index_codebase, CodebaseAutoIndexingSurface, }; +use crate::ai::metadata_project_rules::read_project_rule_contents; use crate::ai::AIRequestUsageModel; #[cfg(feature = "local_fs")] use crate::code::language_server_shutdown_manager::LanguageServerShutdownManager; @@ -670,7 +671,11 @@ impl PersistedWorkspace { #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] fn index_repo(&self, directory_path: PathBuf, ctx: &mut ModelContext) { ProjectContextModel::handle(ctx).update(ctx, |model, ctx| { - let _ = model.index_and_store_rules(directory_path.clone(), ctx); + let _ = model.index_and_store_rules( + directory_path.clone(), + read_project_rule_contents, + ctx, + ); }); if FeatureFlag::FullSourceCodeEmbedding.is_enabled() && UserWorkspaces::as_ref(ctx).is_codebase_context_enabled(ctx) diff --git a/app/src/code_review/code_review_view.rs b/app/src/code_review/code_review_view.rs index 4ae0b40f3a..afc8ca6f12 100644 --- a/app/src/code_review/code_review_view.rs +++ b/app/src/code_review/code_review_view.rs @@ -4092,14 +4092,13 @@ impl CodeReviewView { .with_margin_top(16.) .finish(), ); - } else if let Some(repo_path) = self.repo_path().and_then(LocalOrRemotePath::to_local_path) - { + } else if let Some(repo_path) = self.repo_path() { // Check for initialized project-scoped rules. if let Some(rules) = ProjectContextModel::as_ref(app).find_applicable_project_rules(repo_path) { if let Some(first_rule) = rules.active_rules.first() { - if let Some(file_name) = first_rule.path.file_name().and_then(|n| n.to_str()) { + if let Some(file_name) = first_rule.path.file_name() { zero_state_column.add_child( Container::new( Text::new( diff --git a/app/src/lib.rs b/app/src/lib.rs index 1a446a0ac5..337ddc1aa3 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -144,6 +144,7 @@ use ai::ambient_agents::scheduled::ScheduledAgentManager; use ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions}; use ai::execution_profiles::editor::ExecutionProfileEditorManager; use ai::execution_profiles::profiles::AIExecutionProfilesModel; +use ai::metadata_project_rules::read_project_rule_contents; use ai::persisted_workspace::PersistedWorkspace; use auth::auth_manager::AuthManager; use auth::auth_state::{AuthState, AuthStateProvider}; @@ -2017,7 +2018,11 @@ pub(crate) fn initialize_app( }); ctx.add_singleton_model(|ctx| { - ProjectContextModel::new_from_persisted(persisted_project_rules, ctx) + ProjectContextModel::new_from_persisted( + persisted_project_rules, + read_project_rule_contents, + ctx, + ) }); // Index global rules (e.g. ~/.agents/AGENTS.md) on a background task so diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 5967fe61fb..2f80ca468a 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -13788,7 +13788,7 @@ impl Input { let attachments: Vec = self .ai_context_model .as_ref(ctx) - .pending_context(ctx, true) + .pending_context(ctx, true, None) .into_iter() .filter_map(|context| match context { AIAgentContext::Block(block) => Some(AgentAttachment::BlockReference { diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 62e9001277..89e3faca20 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -22496,10 +22496,15 @@ impl TerminalView { ctx, ); - let context = self - .ai_context_model + let current_working_directory_location = self + .active_session .as_ref(ctx) - .pending_context(ctx, true /* is_user_query */); + .current_working_directory_location(ctx); + let context = self.ai_context_model.as_ref(ctx).pending_context( + ctx, + true, /* is_user_query */ + current_working_directory_location.as_ref(), + ); let code_review_input = AIAgentInput::CodeReview { context: context.into(), diff --git a/app/src/terminal/view/init_project/model.rs b/app/src/terminal/view/init_project/model.rs index acc31d1fda..c93d44ce54 100644 --- a/app/src/terminal/view/init_project/model.rs +++ b/app/src/terminal/view/init_project/model.rs @@ -6,7 +6,6 @@ use enum_iterator::Sequence; use lsp::supported_servers::LSPServerType; #[cfg(not(target_family = "wasm"))] use repo_metadata::repositories::DetectedRepositories; -#[cfg(not(target_family = "wasm"))] use warp_util::local_or_remote_path::LocalOrRemotePath; use warpui::{Entity, ModelContext, SingletonEntity as _}; @@ -187,7 +186,7 @@ impl InitProjectModel { && !*CodeSettings::as_ref(ctx).auto_indexing_enabled; let has_pending_project_scoped_rules = ProjectContextModel::as_ref(ctx) - .find_applicable_project_rules(path) + .find_applicable_project_rules(&LocalOrRemotePath::Local(path.to_path_buf())) .is_none(); has_pending_codebase_context || has_pending_project_scoped_rules diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 859ef28ca4..1674c661ae 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -5857,28 +5857,42 @@ impl Workspace { self.show_settings_with_section(Some(SettingsSection::WarpAgent), ctx); } #[allow(unused_variables)] - AIFactViewEvent::OpenFile(path) => { + AIFactViewEvent::OpenFile(location) => { #[cfg(feature = "local_fs")] { - let settings = EditorSettings::as_ref(ctx); - let target = resolve_file_target_with_editor_choice( - path, - *settings.open_file_editor, - *settings.prefer_markdown_viewer, - *settings.open_file_layout, - None, - ); - self.open_file_with_target( - path.clone(), - target, - None, - CodeSource::Link { - path: path.clone(), - range_start: None, - range_end: None, - }, - ctx, - ); + match location { + LocalOrRemotePath::Local(path) => { + let settings = EditorSettings::as_ref(ctx); + let target = resolve_file_target_with_editor_choice( + path, + *settings.open_file_editor, + *settings.prefer_markdown_viewer, + *settings.open_file_layout, + None, + ); + self.open_file_with_target( + path.clone(), + target, + None, + CodeSource::ProjectRules { + location: location.clone(), + }, + ctx, + ); + } + LocalOrRemotePath::Remote(_) => { + self.open_code( + CodeSource::ProjectRules { + location: location.clone(), + }, + EditorLayout::SplitPane, + None, + false, + &[], + ctx, + ); + } + } } } AIFactViewEvent::InitializeProject(path) => { diff --git a/crates/ai/src/project_context/dummy_global_rules.rs b/crates/ai/src/project_context/dummy_global_rules.rs index 99c8edbd29..730d8043a3 100644 --- a/crates/ai/src/project_context/dummy_global_rules.rs +++ b/crates/ai/src/project_context/dummy_global_rules.rs @@ -1,5 +1,4 @@ -use std::path::PathBuf; - +use warp_util::local_or_remote_path::LocalOrRemotePath; use warpui_core::ModelContext; use super::model::{ProjectContextModel, ProjectRule}; @@ -16,11 +15,11 @@ impl GlobalRules { std::iter::empty() } - pub(crate) fn paths(&self) -> impl Iterator + '_ { + pub(crate) fn paths(&self) -> impl Iterator + '_ { std::iter::empty() } - pub(crate) fn first_rule_parent(&self) -> Option { + pub(crate) fn first_rule_parent(&self) -> Option { None } } diff --git a/crates/ai/src/project_context/global_rules.rs b/crates/ai/src/project_context/global_rules.rs index 4c13a270fc..e5f1202ffd 100644 --- a/crates/ai/src/project_context/global_rules.rs +++ b/crates/ai/src/project_context/global_rules.rs @@ -7,6 +7,7 @@ use repo_metadata::{DirectoryWatcher, Repository, RepositoryUpdate}; use strum::IntoEnumIterator; use strum_macros::EnumIter; use warp_core::safe_warn; +use warp_util::local_or_remote_path::LocalOrRemotePath; use warp_util::standardized_path::StandardizedPath; use warpui_core::{ModelContext, ModelHandle, SingletonEntity}; use watcher::{HomeDirectoryWatcher, HomeDirectoryWatcherEvent}; @@ -77,15 +78,15 @@ impl GlobalRules { self.rules.values().cloned() } - pub(crate) fn paths(&self) -> impl Iterator + '_ { - self.rules.keys().cloned() + pub(crate) fn paths(&self) -> impl Iterator + '_ { + self.rules.keys().cloned().map(LocalOrRemotePath::Local) } - pub(crate) fn first_rule_parent(&self) -> Option { + pub(crate) fn first_rule_parent(&self) -> Option { self.rules .values() .next() - .and_then(|rule| rule.path.parent().map(|p| p.to_path_buf())) + .and_then(|rule| rule.path.parent()) } /// Index all configured global rule sources (see [`GlobalRuleSource`]). @@ -159,7 +160,8 @@ impl GlobalRules { me.global_rules.rules.insert( file_path.clone(), ProjectRule { - path: file_path.clone(), + // Global rule sources are watched under the local home directory. + path: LocalOrRemotePath::Local(file_path.clone()), content, }, ); diff --git a/crates/ai/src/project_context/model.rs b/crates/ai/src/project_context/model.rs index 1d34bde76e..75f94b7148 100644 --- a/crates/ai/src/project_context/model.rs +++ b/crates/ai/src/project_context/model.rs @@ -1,29 +1,60 @@ -use std::collections::HashMap; -#[cfg(feature = "local_fs")] -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use anyhow::Result; -use warpui_core::{Entity, ModelContext, SingletonEntity}; +use futures::future::BoxFuture; +use warp_util::local_or_remote_path::LocalOrRemotePath; +use warpui_core::{AppContext, Entity, ModelContext, SingletonEntity}; use super::GlobalRules; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { - use repo_metadata::{RepoMetadataEvent, RepoMetadataModel, RepositoryIdentifier}; + use repo_metadata::{ + RepoMetadataEvent, RepoMetadataModel, RepositoryIdentifier, StandingQueryContent, + }; + use warp_util::remote_path::RemotePath; use warp_util::standardized_path::StandardizedPath; } } -#[derive(Debug, Default, Clone)] +pub type ProjectRuleContents = Vec<(LocalOrRemotePath, String)>; +/// App-provided transport for reading the exact rule paths discovered by repository metadata. +/// +/// This remains injected because remote file reads are implemented in the app crate. +pub type ProjectRuleContentReader = fn( + Vec, + &AppContext, +) -> BoxFuture<'static, anyhow::Result>; + +#[cfg(feature = "local_fs")] +fn standing_project_rule_paths<'a>( + repo_id: &RepositoryIdentifier, + contents: impl IntoIterator, +) -> Vec { + contents + .into_iter() + .filter(|content| !content.is_directory) + .filter_map(|content| match repo_id { + RepositoryIdentifier::Local(_) => { + content.path.to_local_path().map(LocalOrRemotePath::Local) + } + RepositoryIdentifier::Remote(remote_root) => Some(LocalOrRemotePath::Remote( + RemotePath::new(remote_root.host_id.clone(), content.path.clone()), + )), + }) + .collect() +} + +#[derive(Debug, Clone)] pub struct ProjectRule { - pub path: PathBuf, + pub path: LocalOrRemotePath, pub content: String, } -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone)] struct RuleAtPath { - parent_path: PathBuf, + parent_path: LocalOrRemotePath, warp_md: Option, agents_md: Option, } @@ -34,9 +65,9 @@ impl RuleAtPath { } } -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone)] pub struct ProjectRulesResult { - pub root_path: PathBuf, + pub root_path: LocalOrRemotePath, pub active_rules: Vec, pub additional_rule_paths: Vec, } @@ -61,8 +92,8 @@ struct ProjectRules { } impl ProjectRules { - #[cfg(feature = "local_fs")] - fn all_rule_paths(&self) -> impl Iterator { + #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] + fn rule_paths(&self) -> impl Iterator { self.rules.iter().flat_map(|rule| { rule.warp_md .iter() @@ -70,8 +101,13 @@ impl ProjectRules { .map(|rule| &rule.path) }) } - #[cfg(feature = "local_fs")] - fn retain_rule_paths(&mut self, retained_paths: &HashSet) { + #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] + fn local_rule_paths(&self) -> impl Iterator + '_ { + self.rule_paths() + .filter_map(|path| path.to_local_path().map(Path::to_path_buf)) + } + #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] + fn retain_rule_paths(&mut self, retained_paths: &HashSet) { self.rules.retain_mut(|rule| { if rule .warp_md @@ -91,7 +127,7 @@ impl ProjectRules { }); } /// Finds the set of rules that are active in the given path and the set that are available to be applied. - fn find_active_or_applicable_rules(&self, path: &Path) -> FindRulesResult { + fn find_active_or_applicable_rules(&self, path: &LocalOrRemotePath) -> FindRulesResult { let mut active_rules = Vec::new(); let mut available_rule_paths = Vec::new(); @@ -102,7 +138,7 @@ impl ProjectRules { if path.starts_with(&rule.parent_path) { active_rules.push(respected_rule.clone()); } else { - available_rule_paths.push(respected_rule.path.to_string_lossy().to_string()); + available_rule_paths.push(respected_rule.path.display_path()); } } } @@ -116,11 +152,11 @@ impl ProjectRules { /// Upsert a rule to the set of project rules. This will create a new RuleAtPath entry if none exists and update the existing one /// otherwise. #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] - fn upsert_rule(&mut self, path: &Path, content: String) { + fn upsert_rule(&mut self, path: &LocalOrRemotePath, content: String) { let Some(parent) = path.parent() else { return; }; - let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + let Some(file_name) = path.file_name() else { return; }; @@ -130,7 +166,7 @@ impl ProjectRules { .find(|rule| rule.parent_path == parent); let rule_file = Some(ProjectRule { - path: path.to_path_buf(), + path: path.clone(), content, }); @@ -144,8 +180,9 @@ impl ProjectRules { } None => { let mut rule = RuleAtPath { - parent_path: parent.to_path_buf(), - ..Default::default() + parent_path: parent, + warp_md: None, + agents_md: None, }; if file_name.to_lowercase() == "warp.md" { rule.warp_md = rule_file; @@ -161,13 +198,14 @@ impl ProjectRules { /// Singleton model that keeps track of mapping between paths and rule files /// Currently supports WARP.md files, but designed to be extensible #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] -#[derive(Debug, Default)] +#[derive(Default)] pub struct ProjectContextModel { /// Mapping from directory path to list of rule files found in that directory - path_to_rules: HashMap, - /// Latest metadata-backed async refresh per project root. + path_to_rules: HashMap, + /// Latest metadata-backed async refresh per exact repository identity. + /// This uses the same identifier carried by metadata events rather than an arbitrary file path. #[cfg(feature = "local_fs")] - rule_refresh_generations: HashMap, + rule_refresh_generations: HashMap, #[cfg(feature = "local_fs")] next_rule_refresh_generation: u64, /// File-based global rules and their local watcher state. Kept separate @@ -231,43 +269,40 @@ impl ProjectContextModel { #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] pub fn new_from_persisted( persisted_rules: Vec, + project_rule_content_reader: ProjectRuleContentReader, ctx: &mut ModelContext, ) -> Self { + #[cfg_attr(not(feature = "local_fs"), allow(unused_mut))] + let mut model = Self::default(); #[cfg(feature = "local_fs")] { - ctx.subscribe_to_model( - &RepoMetadataModel::handle(ctx), - |me, event, ctx| match event { - RepoMetadataEvent::RepositoryUpdated { - id: RepositoryIdentifier::Local(repo_path), - } => me.refresh_project_rules_for_repo(repo_path.clone(), ctx), - RepoMetadataEvent::StandingQueryResultsUpdated { - id: RepositoryIdentifier::Local(repo_path), - delta, - } => { + ctx.subscribe_to_model(&RepoMetadataModel::handle(ctx), move |me, event, ctx| { + match event { + RepoMetadataEvent::RepositoryUpdated { id } => { + me.refresh_project_rules_for_repo( + id.clone(), + project_rule_content_reader, + ctx, + ); + } + RepoMetadataEvent::StandingQueryResultsUpdated { id, delta } => { if delta.project_rules_changed() { - me.refresh_project_rules_for_repo(repo_path.clone(), ctx); + me.refresh_project_rules_for_repo( + id.clone(), + project_rule_content_reader, + ctx, + ); } } - RepoMetadataEvent::RepositoryRemoved { - id: RepositoryIdentifier::Local(repo_path), - } => me.remove_project_rules_for_repo(repo_path, ctx), - RepoMetadataEvent::RepositoryUpdated { - id: RepositoryIdentifier::Remote(_), + RepoMetadataEvent::RepositoryRemoved { id } => { + me.remove_project_rules_for_repo(id, ctx); } - | RepoMetadataEvent::RepositoryRemoved { - id: RepositoryIdentifier::Remote(_), - } - | RepoMetadataEvent::StandingQueryResultsUpdated { - id: RepositoryIdentifier::Remote(_), - .. - } - | RepoMetadataEvent::FileTreeUpdated { .. } + RepoMetadataEvent::FileTreeUpdated { .. } | RepoMetadataEvent::FileTreeEntryUpdated { .. } | RepoMetadataEvent::UpdatingRepositoryFailed { .. } | RepoMetadataEvent::IncrementalUpdateReady { .. } => {} - }, - ); + } + }); ctx.spawn( async move { Self::read_persisted_rules(persisted_rules).await }, @@ -279,9 +314,20 @@ impl ProjectContextModel { ctx.emit(ProjectContextModelEvent::PathIndexed); }, ); + + // Remote snapshots may have arrived before this model subscribed to metadata events, + // so hydrate any remote repositories that are already tracked. + let remote_repo_ids = RepoMetadataModel::as_ref(ctx) + .remote_repository_ids(ctx) + .cloned() + .map(RepositoryIdentifier::Remote) + .collect::>(); + for repo_id in remote_repo_ids { + model.refresh_project_rules_for_repo(repo_id, project_rule_content_reader, ctx); + } } - Self::default() + model } /// Reconciles project rule contents from the repository metadata standing result set. @@ -289,6 +335,7 @@ impl ProjectContextModel { pub fn index_and_store_rules( &mut self, root_path: PathBuf, + project_rule_content_reader: ProjectRuleContentReader, ctx: &mut ModelContext, ) -> Result<()> { #[cfg(feature = "local_fs")] @@ -303,7 +350,7 @@ impl ProjectContextModel { metadata.index_lazy_loaded_path(&repo_path, ctx) })?; } - self.refresh_project_rules_for_repo(repo_path, ctx); + self.refresh_project_rules_for_repo(repo_id, project_rule_content_reader, ctx); } Ok(()) } @@ -311,81 +358,59 @@ impl ProjectContextModel { #[cfg(feature = "local_fs")] fn refresh_project_rules_for_repo( &mut self, - repo_path: StandardizedPath, + repo_id: RepositoryIdentifier, + project_rule_content_reader: ProjectRuleContentReader, ctx: &mut ModelContext, ) { - let Some(project_root) = repo_path.to_local_path() else { + if repo_id.to_local_or_remote_path().is_none() { return; }; - let id = RepositoryIdentifier::local(repo_path); - let rule_paths = RepoMetadataModel::as_ref(ctx) - .standing_query_results(&id, ctx) - .into_iter() - .flat_map(|results| results.project_rules()) - .filter(|content| !content.is_directory) - .filter_map(|content| content.path.to_local_path()) - .collect::>(); - let existing_rules = self - .path_to_rules - .get(&project_root) - .cloned() - .unwrap_or_default(); + let rule_paths = standing_project_rule_paths( + &repo_id, + RepoMetadataModel::as_ref(ctx) + .standing_query_results(&repo_id, ctx) + .into_iter() + .flat_map(|results| results.project_rules()), + ); + let read_rule_contents = project_rule_content_reader(rule_paths.clone(), ctx); self.next_rule_refresh_generation += 1; let refresh_generation = self.next_rule_refresh_generation; self.rule_refresh_generations - .insert(project_root.clone(), refresh_generation); - let project_root_for_read = project_root.clone(); - ctx.spawn( - async move { Self::read_standing_project_rules(rule_paths, existing_rules).await }, - move |me, rules, ctx| { - if me.rule_refresh_generations.get(&project_root_for_read) - != Some(&refresh_generation) - { - return; + .insert(repo_id.clone(), refresh_generation); + let repo_id_for_result = repo_id.clone(); + ctx.spawn(read_rule_contents, move |me, result, ctx| { + if me.rule_refresh_generations.get(&repo_id_for_result) != Some(&refresh_generation) { + return; + } + match result { + Ok(contents) => { + let Some(project_root) = repo_id_for_result.to_local_or_remote_path() else { + return; + }; + let existing_rules = me + .path_to_rules + .get(&project_root) + .cloned() + .unwrap_or_default(); + let rules = Self::reconcile_project_rules(rule_paths, contents, existing_rules); + me.apply_project_rules(repo_id_for_result, rules, ctx); } - let new_paths = rules.all_rule_paths().cloned().collect::>(); - let previous = me - .path_to_rules - .insert(project_root_for_read.clone(), rules) - .unwrap_or_default(); - let deleted_rules = previous - .all_rule_paths() - .filter(|path| !new_paths.contains(path)) - .cloned() - .collect(); - let discovered_rules = new_paths - .into_iter() - .map(|path| ProjectRulePath { - path, - project_root: project_root_for_read.clone(), - }) - .collect(); - ctx.emit(ProjectContextModelEvent::KnownRulesChanged(RulesDelta { - discovered_rules, - deleted_rules, - })); - ctx.emit(ProjectContextModelEvent::PathIndexed); - }, - ); + Err(error) => log::warn!("Failed to read project rules: {error}"), + } + }); } - #[cfg(feature = "local_fs")] - async fn read_standing_project_rules( - rule_paths: Vec, + #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] + fn reconcile_project_rules( + rule_paths: Vec, + rule_contents: ProjectRuleContents, mut existing_rules: ProjectRules, ) -> ProjectRules { let retained_paths = rule_paths.iter().cloned().collect::>(); existing_rules.retain_rule_paths(&retained_paths); - - for rule_path in rule_paths { - match async_fs::read_to_string(&rule_path).await { - Ok(content) => existing_rules.upsert_rule(&rule_path, content), - Err(error) => log::debug!( - "Failed to read project rule file {}: {error}", - rule_path.display() - ), - } + for (path, content) in rule_contents { + existing_rules.upsert_rule(&path, content); } existing_rules } @@ -393,21 +418,65 @@ impl ProjectContextModel { #[cfg(feature = "local_fs")] fn remove_project_rules_for_repo( &mut self, - repo_path: &StandardizedPath, + repo_id: &RepositoryIdentifier, ctx: &mut ModelContext, ) { - let Some(project_root) = repo_path.to_local_path() else { + self.rule_refresh_generations.remove(repo_id); + let Some(project_root) = repo_id.to_local_or_remote_path() else { return; }; - self.rule_refresh_generations.remove(&project_root); if let Some(rules) = self.path_to_rules.remove(&project_root) { - let deleted_rules = rules.all_rule_paths().cloned().collect(); + // KnownRulesChanged is consumed by local persistence and carries local PathBufs. + // Remote removals still update in-memory state and emit PathIndexed below. + if matches!(repo_id, RepositoryIdentifier::Local(_)) { + let deleted_rules = rules.local_rule_paths().collect(); + ctx.emit(ProjectContextModelEvent::KnownRulesChanged(RulesDelta { + discovered_rules: Vec::new(), + deleted_rules, + })); + } + ctx.emit(ProjectContextModelEvent::PathIndexed); + } + } + + #[cfg(feature = "local_fs")] + fn apply_project_rules( + &mut self, + repo_id: RepositoryIdentifier, + rules: ProjectRules, + ctx: &mut ModelContext, + ) { + let Some(project_root) = repo_id.to_local_or_remote_path() else { + return; + }; + if let RepositoryIdentifier::Local(local_root) = &repo_id { + let Some(local_root) = local_root.to_local_path() else { + return; + }; + let new_paths = rules.local_rule_paths().collect::>(); + let previous = self + .path_to_rules + .insert(project_root, rules) + .unwrap_or_default(); + let deleted_rules = previous + .local_rule_paths() + .filter(|path| !new_paths.contains(path)) + .collect(); + let discovered_rules = new_paths + .into_iter() + .map(|path| ProjectRulePath { + path, + project_root: local_root.clone(), + }) + .collect(); ctx.emit(ProjectContextModelEvent::KnownRulesChanged(RulesDelta { - discovered_rules: Vec::new(), + discovered_rules, deleted_rules, })); - ctx.emit(ProjectContextModelEvent::PathIndexed); + } else { + self.path_to_rules.insert(project_root, rules); } + ctx.emit(ProjectContextModelEvent::PathIndexed); } /// Index all configured global rule sources. @@ -425,8 +494,11 @@ impl ProjectContextModel { /// /// Use this for callers that need a project-initialization signal rather /// than the full rule context sent to agents. - pub fn find_applicable_project_rules(&self, path: &Path) -> Option { - let mut current_path = path.to_owned(); + pub fn find_applicable_project_rules( + &self, + path: &LocalOrRemotePath, + ) -> Option { + let mut current_path = path.clone(); // Walk upwards from `path` toward the filesystem root, stopping at the // first directory we have indexed project rules for. `path_to_rules` @@ -446,9 +518,7 @@ impl ProjectContextModel { }); } - if !current_path.pop() { - return None; - } + current_path = current_path.parent()?; } } @@ -464,7 +534,7 @@ impl ProjectContextModel { /// `AIAgentContext::ProjectRules` for an agent query. Callers that need /// a project-only signal should use /// [`Self::find_applicable_project_rules`] instead. - pub fn find_applicable_rules(&self, path: &Path) -> Option { + pub fn find_applicable_rules(&self, path: &LocalOrRemotePath) -> Option { let project_result = self.find_applicable_project_rules(path); // Layered precedence: global rules are always included alongside @@ -485,9 +555,8 @@ impl ProjectContextModel { } // Use the indexed project root when available; otherwise fall back to - // the parent of the first global rule (or empty). - let root_path = project_root - .unwrap_or_else(|| self.global_rules.first_rule_parent().unwrap_or_default()); + // the parent of the first global rule. + let root_path = project_root.or_else(|| self.global_rules.first_rule_parent())?; Some(ProjectRulesResult { root_path, @@ -499,14 +568,16 @@ impl ProjectContextModel { #[cfg(feature = "local_fs")] async fn read_persisted_rules( rule_paths: Vec, - ) -> HashMap { - let mut rules: HashMap = HashMap::new(); + ) -> HashMap { + let mut rules: HashMap = HashMap::new(); for rule in rule_paths { match async_fs::read_to_string(&rule.path).await { Ok(content) => { - let existing_rules = rules.entry(rule.project_root).or_default(); - existing_rules.upsert_rule(&rule.path, content); + let existing_rules = rules + .entry(LocalOrRemotePath::Local(rule.project_root)) + .or_default(); + existing_rules.upsert_rule(&LocalOrRemotePath::Local(rule.path), content); } Err(e) => { log::debug!( @@ -522,7 +593,7 @@ impl ProjectContextModel { rules } - pub fn indexed_rules(&self) -> impl Iterator + '_ { + pub fn indexed_rules(&self) -> impl Iterator + '_ { self.path_to_rules.values().flat_map(|rules| { rules.rules.iter().filter_map(|rules| { rules @@ -532,21 +603,22 @@ impl ProjectContextModel { }) } - /// Absolute paths of every indexed global rule file (e.g. `~/.agents/AGENTS.md`). + /// Absolute locations of every indexed global rule file (e.g. `~/.agents/AGENTS.md`). /// Iteration order is sorted by path because global rules are backed by a `BTreeMap`. - pub fn global_rule_paths(&self) -> impl Iterator + '_ { + pub fn global_rule_paths(&self) -> impl Iterator + '_ { self.global_rules.paths() } /// Returns the rule file paths associated with a specific workspace root path. pub fn rules_for_workspace(&self, workspace_path: &Path) -> Vec { self.path_to_rules - .get(workspace_path) + .get(&LocalOrRemotePath::Local(workspace_path.to_path_buf())) .into_iter() .flat_map(|rules| { rules.rules.iter().filter_map(|rule| { - rule.respected_rule() - .map(|project_rule| project_rule.path.clone()) + rule.respected_rule().and_then(|project_rule| { + project_rule.path.to_local_path().map(Path::to_path_buf) + }) }) }) .collect() diff --git a/crates/ai/src/project_context/model_tests.rs b/crates/ai/src/project_context/model_tests.rs index bc6307e3a2..062be54f59 100644 --- a/crates/ai/src/project_context/model_tests.rs +++ b/crates/ai/src/project_context/model_tests.rs @@ -1,11 +1,41 @@ 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()); @@ -15,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()); @@ -28,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 = 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 = 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"); } @@ -81,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"); } @@ -95,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"); } @@ -110,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"); } @@ -130,21 +160,21 @@ fn test_find_applicable_rules_complex_scenario() { // - /a/b/AGENTS.md 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(), 2); // Expect only WARP.md files to be included as they have higher priority. - 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, "a_warp"); - assert_eq!(result[1].path, PathBuf::from("/a/b/WARP.md")); + assert_eq!(result[1].path, local_path("/a/b/WARP.md")); assert_eq!(result[1].content, "ab_warp"); } @@ -152,14 +182,14 @@ fn test_find_applicable_rules_complex_scenario() { 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"); } @@ -167,22 +197,22 @@ 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 = 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 = 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 { @@ -295,37 +325,80 @@ fn test_merge_rediscovery_keeps_latest() { assert_eq!(delta.discovered_rules.len(), 1); assert!(delta.deleted_rules.is_empty()); } -#[cfg(feature = "local_fs")] + #[test] -fn test_failed_standing_rule_read_preserves_cached_content() { - let rule_path = PathBuf::from("/unavailable/project/WARP.md"); +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 = futures::executor::block_on(ProjectContextModel::read_standing_project_rules( + let rules = ProjectContextModel::reconcile_project_rules( vec![rule_path.clone()], + Vec::new(), existing_rules, - )); - let result = rules.find_active_or_applicable_rules(Path::new("/unavailable/project/main.rs")); + ); + 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"); } -#[cfg(feature = "local_fs")] #[test] fn test_rule_missing_from_standing_results_is_removed_from_cached_content() { - let rule_path = PathBuf::from("/unavailable/project/WARP.md"); + 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 = futures::executor::block_on(ProjectContextModel::read_standing_project_rules( - Vec::new(), - existing_rules, + 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!(rules.all_rule_paths().next().is_none()); + 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 @@ -335,7 +408,7 @@ fn insert_global_rule(model: &mut ProjectContextModel, path: &Path, content: &st model.global_rules.rules.insert( path.to_path_buf(), ProjectRule { - path: path.to_path_buf(), + path: LocalOrRemotePath::Local(path.to_path_buf()), content: content.to_string(), }, ); @@ -349,9 +422,35 @@ fn insert_project_rule( ) { let rules = model .path_to_rules - .entry(project_root.to_path_buf()) + .entry(LocalOrRemotePath::Local(project_root.to_path_buf())) .or_default(); - rules.upsert_rule(rule_path, content.to_string()); + 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] @@ -364,13 +463,13 @@ fn test_global_rule_alone_no_project_rules() { ); let result = model - .find_applicable_rules(Path::new("/some/project/file.rs")) + .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, - PathBuf::from("/home/u/.agents/AGENTS.md") + local_path("/home/u/.agents/AGENTS.md") ); assert_eq!(result.active_rules[0].content, "global_content"); assert!(result.additional_rule_paths.is_empty()); @@ -388,14 +487,14 @@ fn test_global_rule_layered_with_project_warp() { ); let result = model - .find_applicable_rules(Path::new("/repo/src/main.rs")) + .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, PathBuf::from("/repo")); + assert_eq!(result.root_path, local_path("/repo")); } #[test] @@ -418,7 +517,7 @@ fn test_in_dir_warp_shadows_agents_with_global() { ); let result = model - .find_applicable_rules(Path::new("/repo/src/main.rs")) + .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. @@ -430,7 +529,7 @@ fn test_in_dir_warp_shadows_agents_with_global() { #[test] fn test_no_rules_returns_none() { let model = ProjectContextModel::default(); - let result = model.find_applicable_rules(Path::new("/some/path/file.rs")); + let result = model.find_applicable_rules(&local_path("/some/path/file.rs")); assert!(result.is_none()); } @@ -440,11 +539,11 @@ fn test_global_rule_root_path_falls_back_to_parent() { insert_global_rule(&mut model, Path::new("/home/u/.agents/AGENTS.md"), "global"); let result = model - .find_applicable_rules(Path::new("/some/file.rs")) + .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, PathBuf::from("/home/u/.agents")); + assert_eq!(result.root_path, local_path("/home/u/.agents")); } #[test] @@ -462,7 +561,7 @@ fn test_multiple_global_rules_all_contribute() { ); let result = model - .find_applicable_rules(Path::new("/repo/src/main.rs")) + .find_applicable_rules(&local_path("/repo/src/main.rs")) .expect("globals should produce a result"); assert_eq!(result.active_rules.len(), 2);