From 706ca0e5578b3e726d9a01cb835198e0749f72ef Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Fri, 29 May 2026 12:45:39 -0700 Subject: [PATCH 1/6] Unify local and remote project rule discovery Co-Authored-By: Oz --- app/src/ai/blocklist/context_model.rs | 36 ++- .../ai/blocklist/controller/input_context.rs | 7 +- app/src/ai/facts/view/mod.rs | 3 +- app/src/ai/facts/view/rule.rs | 23 +- app/src/ai/metadata_project_rules.rs | 268 ++++++++++++++++++ app/src/ai/metadata_project_rules_tests.rs | 113 ++++++++ app/src/ai/mod.rs | 1 + app/src/ai/persisted_workspace.rs | 21 +- app/src/code_review/code_review_view.rs | 2 +- app/src/lib.rs | 2 + app/src/workspace/view.rs | 54 ++-- crates/ai/src/project_context/global_rules.rs | 6 +- crates/ai/src/project_context/model.rs | 139 ++++++--- crates/ai/src/project_context/model_tests.rs | 186 ++++++++---- 14 files changed, 707 insertions(+), 154 deletions(-) create mode 100644 app/src/ai/metadata_project_rules.rs create mode 100644 app/src/ai/metadata_project_rules_tests.rs diff --git a/app/src/ai/blocklist/context_model.rs b/app/src/ai/blocklist/context_model.rs index 6f6143bb51..b299fc10d6 100644 --- a/app/src/ai/blocklist/context_model.rs +++ b/app/src/ai/blocklist/context_model.rs @@ -9,6 +9,7 @@ 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, }; @@ -394,6 +395,25 @@ impl BlocklistAIContextModel { /// 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 { + let current_working_directory_location = self.current_pwd().and_then(|path| { + PathBuf::from_str(&path) + .ok() + .and_then(|path| path.canonicalize().ok()) + .map(LocalOrRemotePath::Local) + }); + self.pending_context_for_location( + app, + is_user_query, + current_working_directory_location.as_ref(), + ) + } + + pub fn pending_context_for_location( + &self, + app: &AppContext, + is_user_query: bool, + current_working_directory_location: Option<&LocalOrRemotePath>, + ) -> Vec { 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 +426,9 @@ 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_at_location(pwd) + }); let mut context = Vec::new(); @@ -451,14 +465,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..76d6f7b855 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_for_location( + 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..e3af6b4cc8 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, @@ -68,7 +69,7 @@ pub enum RuleViewEvent { AddRule, Edit(SyncId), OpenSettings, - OpenFile(PathBuf), + OpenFile(LocalOrRemotePath), InitializeProject(PathBuf), } @@ -79,7 +80,7 @@ pub enum RuleViewAction { Edit(SyncId), OpenSettings, SelectScope(RuleScope), - OpenFile(PathBuf), + OpenFile(LocalOrRemotePath), } #[derive(Default, Debug, Clone)] @@ -101,7 +102,7 @@ struct CloudRuleRow { /// plus an "Open file" button. #[derive(Debug, Clone)] struct FileBackedRow { - file_path: PathBuf, + file_path: LocalOrRemotePath, mouse_state: MouseStateHandle, } @@ -126,9 +127,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 +138,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, } } @@ -219,7 +222,7 @@ impl RuleView { .as_ref(ctx) .global_rule_paths() .map(|p| FileBackedRow { - file_path: p, + file_path: LocalOrRemotePath::Local(p), mouse_state: Default::default(), }) .collect(); @@ -245,7 +248,7 @@ impl RuleView { .as_ref(ctx) .global_rule_paths() .map(|p| FileBackedRow { - file_path: p, + file_path: LocalOrRemotePath::Local(p), mouse_state: Default::default(), }) .collect(); @@ -705,7 +708,7 @@ impl RuleView { project_row: FileBackedRow, appearance: &Appearance, ) -> Option> { - let row_name = project_row.file_path.to_str().map(|s| s.to_string())?; + let row_name = project_row.file_path.display_path(); let mut row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) diff --git a/app/src/ai/metadata_project_rules.rs b/app/src/ai/metadata_project_rules.rs new file mode 100644 index 0000000000..030ffdbed0 --- /dev/null +++ b/app/src/ai/metadata_project_rules.rs @@ -0,0 +1,268 @@ +use std::collections::HashMap; + +use ai::project_context::model::{ProjectContextModel, ProjectRule}; +use futures::future::{BoxFuture, FutureExt as _}; +use remote_server::proto::{ + file_context_proto, FileContextProto, ReadFileContextFile, ReadFileContextRequest, +}; +use repo_metadata::{ + RepoMetadataEvent, RepoMetadataModel, RepositoryIdentifier, StandingQueryContent, +}; +use warp_util::local_or_remote_path::LocalOrRemotePath; +use warp_util::remote_path::RemotePath; +use warpui::{AppContext, Entity, ModelContext, SingletonEntity}; + +use crate::remote_server::manager::RemoteServerManager; + +pub(crate) struct MetadataProjectRulesModel { + refresh_generations: HashMap, + next_refresh_generation: u64, +} + +type ProjectRuleContentsFuture = + BoxFuture<'static, anyhow::Result>>; + +impl MetadataProjectRulesModel { + pub(crate) fn new(ctx: &mut ModelContext) -> Self { + ctx.subscribe_to_model(&RepoMetadataModel::handle(ctx), |me, event, ctx| { + me.handle_repo_metadata_event(event, ctx); + }); + + let repo_ids = RepoMetadataModel::as_ref(ctx) + .remote_repository_ids(ctx) + .cloned() + .map(RepositoryIdentifier::Remote) + .collect::>(); + let mut model = Self { + refresh_generations: HashMap::new(), + next_refresh_generation: 0, + }; + for repo_id in repo_ids { + model.refresh_project_rules_for_repo(&repo_id, ctx); + } + model + } + + fn handle_repo_metadata_event( + &mut self, + event: &RepoMetadataEvent, + ctx: &mut ModelContext, + ) { + match event { + RepoMetadataEvent::RepositoryUpdated { + id: repo_id @ RepositoryIdentifier::Remote(_), + } => self.refresh_project_rules_for_repo(repo_id, ctx), + RepoMetadataEvent::StandingQueryResultsUpdated { + id: repo_id @ RepositoryIdentifier::Remote(_), + delta, + } => { + if delta.project_rules_changed() { + self.refresh_project_rules_for_repo(repo_id, ctx); + } + } + RepoMetadataEvent::RepositoryRemoved { + id: repo_id @ RepositoryIdentifier::Remote(_), + } => self.clear_project_rules_for_removed_repository(repo_id, ctx), + RepoMetadataEvent::RepositoryUpdated { + id: RepositoryIdentifier::Local(_), + } + | RepoMetadataEvent::RepositoryRemoved { + id: RepositoryIdentifier::Local(_), + } + | RepoMetadataEvent::StandingQueryResultsUpdated { + id: RepositoryIdentifier::Local(_), + .. + } + | RepoMetadataEvent::FileTreeUpdated { .. } + | RepoMetadataEvent::FileTreeEntryUpdated { .. } + | RepoMetadataEvent::UpdatingRepositoryFailed { .. } + | RepoMetadataEvent::IncrementalUpdateReady { .. } => {} + } + } + + fn refresh_project_rules_for_repo( + &mut self, + repo_id: &RepositoryIdentifier, + ctx: &mut ModelContext, + ) { + let RepositoryIdentifier::Remote(remote_root) = repo_id else { + return; + }; + let refresh_generation = self.advance_refresh_generation(repo_id); + let rule_paths = remote_project_rule_paths( + repo_id, + RepoMetadataModel::as_ref(ctx) + .standing_query_results(repo_id, ctx) + .into_iter() + .flat_map(|results| results.project_rules()), + ); + if rule_paths.is_empty() { + self.apply_project_rules_if_current( + repo_id, + refresh_generation, + remote_root.clone(), + Vec::new(), + ctx, + ); + return; + } + let Some(read_rule_contents) = read_remote_project_rule_contents(rule_paths, ctx) else { + return; + }; + let repo_id_for_result = repo_id.clone(); + let remote_root_for_result = remote_root.clone(); + ctx.spawn( + async move { + let rule_contents = read_rule_contents.await?; + Ok::, anyhow::Error>(build_project_rules(rule_contents)) + }, + move |me, hydrated_rules, ctx| match hydrated_rules { + Ok(rules) => me.apply_project_rules_if_current( + &repo_id_for_result, + refresh_generation, + remote_root_for_result, + rules, + ctx, + ), + Err(error) => log::warn!("Failed to read remote project rules: {error}"), + }, + ); + } + + fn clear_project_rules_for_removed_repository( + &mut self, + repo_id: &RepositoryIdentifier, + ctx: &mut ModelContext, + ) { + self.refresh_generations.remove(repo_id); + let RepositoryIdentifier::Remote(remote_root) = repo_id else { + return; + }; + ProjectContextModel::handle(ctx).update(ctx, |model, ctx| { + model.clear_remote_project_rules_for_removed_metadata_root(remote_root.clone(), ctx); + }); + } + + fn advance_refresh_generation(&mut self, repo_id: &RepositoryIdentifier) -> u64 { + self.next_refresh_generation += 1; + self.refresh_generations + .insert(repo_id.clone(), self.next_refresh_generation); + self.next_refresh_generation + } + + fn apply_project_rules_if_current( + &mut self, + repo_id: &RepositoryIdentifier, + refresh_generation: u64, + remote_root: RemotePath, + rules: Vec, + ctx: &mut ModelContext, + ) { + if self.refresh_generations.get(repo_id) != Some(&refresh_generation) { + return; + } + ProjectContextModel::handle(ctx).update(ctx, |model, ctx| { + model.replace_remote_project_rules_from_metadata(remote_root, rules, ctx); + }); + } +} + +impl Entity for MetadataProjectRulesModel { + type Event = (); +} + +impl SingletonEntity for MetadataProjectRulesModel {} + +fn remote_project_rule_paths<'a>( + repo_id: &RepositoryIdentifier, + contents: impl IntoIterator, +) -> Vec { + let RepositoryIdentifier::Remote(remote_root) = repo_id else { + return Vec::new(); + }; + contents + .into_iter() + .filter(|content| !content.is_directory) + .map(|content| { + LocalOrRemotePath::Remote(RemotePath::new( + remote_root.host_id.clone(), + content.path.clone(), + )) + }) + .collect() +} + +fn read_remote_project_rule_contents( + rule_paths: Vec, + ctx: &AppContext, +) -> Option { + let LocalOrRemotePath::Remote(remote) = rule_paths.first()? else { + return None; + }; + let handle = RemoteServerManager::as_ref(ctx).host_request_handle(&remote.host_id); + Some( + async move { + let response = handle + .read_file_context(remote_rule_read_request(&rule_paths)) + .await?; + Ok(match_remote_project_rule_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, + } +} + +fn match_remote_project_rule_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() +} + +fn build_project_rules(rule_contents: Vec<(LocalOrRemotePath, String)>) -> Vec { + rule_contents + .into_iter() + .map(|(path, content)| ProjectRule { 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..b78dd5fc04 --- /dev/null +++ b/app/src/ai/metadata_project_rules_tests.rs @@ -0,0 +1,113 @@ +use remote_server::proto::{file_context_proto, FileContextProto}; +use repo_metadata::{RepositoryIdentifier, StandingQueryContent, StandingQueryResults}; +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::{ + build_project_rules, match_remote_project_rule_contents, remote_project_rule_paths, + 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 rules = build_project_rules(match_remote_project_rule_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!(rules.len(), 2); + assert_eq!(rules[0].path, first_path); + assert_eq!(rules[0].content, "first rules"); + assert_eq!(rules[1].path, second_path); + assert_eq!(rules[1].content, "second rules"); +} + +#[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 rules = build_project_rules(match_remote_project_rule_contents( + vec![missing_path, present_path.clone()], + vec![remote_rule_file_context(&present_path, "present rules")], + )); + + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].path, present_path); + assert_eq!(rules[0].content, "present rules"); +} + +#[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()); +} + +#[test] +fn 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 mut results = StandingQueryResults::default(); + results.insert_project_rule(StandingQueryContent::file(rule_path.clone())); + results.insert_project_rule(StandingQueryContent::directory( + StandardizedPath::try_new("/repo/nested").unwrap(), + )); + + assert_eq!( + remote_project_rule_paths(&repo_id, results.project_rules()), + vec![LocalOrRemotePath::Remote(RemotePath::new(host, rule_path))] + ); +} 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..a89b5fc29d 100644 --- a/app/src/ai/persisted_workspace.rs +++ b/app/src/ai/persisted_workspace.rs @@ -314,7 +314,7 @@ impl PersistedWorkspace { let DetectedRepositoriesEvent::DetectedGitRepo { repository, .. } = event; let repo_path = repository.as_ref(ctx).root_dir().to_local_path_lossy(); - me.index_repo(repo_path, ctx); + me.index_repo(repo_path, false, ctx); }); } @@ -668,10 +668,17 @@ 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); - }); + fn index_repo( + &self, + directory_path: PathBuf, + index_project_rules: bool, + ctx: &mut ModelContext, + ) { + if index_project_rules { + ProjectContextModel::handle(ctx).update(ctx, |model, ctx| { + let _ = model.index_and_store_rules(directory_path.clone(), ctx); + }); + } if FeatureFlag::FullSourceCodeEmbedding.is_enabled() && UserWorkspaces::as_ref(ctx).is_codebase_context_enabled(ctx) && *CodeSettings::as_ref(ctx).auto_indexing_enabled @@ -711,7 +718,9 @@ impl PersistedWorkspace { } self.persist_metadata_for_index(&path); - self.index_repo(path.clone(), ctx); + // Explicitly added folders may not be metadata-backed repositories, so retain direct + // project-rule scanning for this manual-workspace path. + self.index_repo(path.clone(), true, ctx); ctx.emit(PersistedWorkspaceEvent::WorkspaceAdded { path }); } diff --git a/app/src/code_review/code_review_view.rs b/app/src/code_review/code_review_view.rs index 4ae0b40f3a..158441723a 100644 --- a/app/src/code_review/code_review_view.rs +++ b/app/src/code_review/code_review_view.rs @@ -4099,7 +4099,7 @@ impl CodeReviewView { 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..6be7080fc2 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::MetadataProjectRulesModel; use ai::persisted_workspace::PersistedWorkspace; use auth::auth_manager::AuthManager; use auth::auth_state::{AuthState, AuthStateProvider}; @@ -2019,6 +2020,7 @@ pub(crate) fn initialize_app( ctx.add_singleton_model(|ctx| { ProjectContextModel::new_from_persisted(persisted_project_rules, ctx) }); + ctx.add_singleton_model(MetadataProjectRulesModel::new); // Index global rules (e.g. ~/.agents/AGENTS.md) on a background task so // they are available to subsequent agent queries. 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/global_rules.rs b/crates/ai/src/project_context/global_rules.rs index 4c13a270fc..74e7a7a0a3 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}; @@ -85,7 +86,8 @@ impl GlobalRules { self.rules .values() .next() - .and_then(|rule| rule.path.parent().map(|p| p.to_path_buf())) + .and_then(|rule| rule.path.parent()) + .and_then(|parent| parent.to_local_path().map(Path::to_path_buf)) } /// Index all configured global rule sources (see [`GlobalRuleSource`]). @@ -159,7 +161,7 @@ impl GlobalRules { me.global_rules.rules.insert( file_path.clone(), ProjectRule { - path: file_path.clone(), + 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..32976ea513 100644 --- a/crates/ai/src/project_context/model.rs +++ b/crates/ai/src/project_context/model.rs @@ -4,6 +4,8 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use anyhow::Result; +use warp_util::local_or_remote_path::LocalOrRemotePath; +use warp_util::remote_path::RemotePath; use warpui_core::{Entity, ModelContext, SingletonEntity}; use super::GlobalRules; @@ -15,15 +17,15 @@ cfg_if::cfg_if! { } } -#[derive(Debug, Default, Clone)] +#[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 +36,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, } @@ -62,28 +64,31 @@ struct ProjectRules { impl ProjectRules { #[cfg(feature = "local_fs")] - fn all_rule_paths(&self) -> impl Iterator { + fn local_rule_paths(&self) -> impl Iterator + '_ { self.rules.iter().flat_map(|rule| { rule.warp_md .iter() .chain(rule.agents_md.iter()) - .map(|rule| &rule.path) + .filter_map(|rule| rule.path.to_local_path().map(Path::to_path_buf)) }) } + #[cfg(feature = "local_fs")] fn retain_rule_paths(&mut self, retained_paths: &HashSet) { self.rules.retain_mut(|rule| { if rule .warp_md .as_ref() - .is_some_and(|rule| !retained_paths.contains(&rule.path)) + .and_then(|rule| rule.path.to_local_path()) + .is_some_and(|path| !retained_paths.contains(path)) { rule.warp_md = None; } if rule .agents_md .as_ref() - .is_some_and(|rule| !retained_paths.contains(&rule.path)) + .and_then(|rule| rule.path.to_local_path()) + .is_some_and(|path| !retained_paths.contains(path)) { rule.agents_md = None; } @@ -91,7 +96,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 +107,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 +121,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 +135,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 +149,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; @@ -164,7 +170,7 @@ impl ProjectRules { #[derive(Debug, Default)] pub struct ProjectContextModel { /// Mapping from directory path to list of rule files found in that directory - path_to_rules: HashMap, + path_to_rules: HashMap, /// Latest metadata-backed async refresh per project root. #[cfg(feature = "local_fs")] rule_refresh_generations: HashMap, @@ -325,9 +331,10 @@ impl ProjectContextModel { .filter(|content| !content.is_directory) .filter_map(|content| content.path.to_local_path()) .collect::>(); + let project_root_location = LocalOrRemotePath::Local(project_root.clone()); let existing_rules = self .path_to_rules - .get(&project_root) + .get(&project_root_location) .cloned() .unwrap_or_default(); @@ -344,15 +351,17 @@ impl ProjectContextModel { { return; } - let new_paths = rules.all_rule_paths().cloned().collect::>(); + let new_paths = rules.local_rule_paths().collect::>(); let previous = me .path_to_rules - .insert(project_root_for_read.clone(), rules) + .insert( + LocalOrRemotePath::Local(project_root_for_read.clone()), + rules, + ) .unwrap_or_default(); let deleted_rules = previous - .all_rule_paths() + .local_rule_paths() .filter(|path| !new_paths.contains(path)) - .cloned() .collect(); let discovered_rules = new_paths .into_iter() @@ -380,7 +389,9 @@ impl ProjectContextModel { for rule_path in rule_paths { match async_fs::read_to_string(&rule_path).await { - Ok(content) => existing_rules.upsert_rule(&rule_path, content), + Ok(content) => { + existing_rules.upsert_rule(&LocalOrRemotePath::Local(rule_path), content) + } Err(error) => log::debug!( "Failed to read project rule file {}: {error}", rule_path.display() @@ -400,8 +411,11 @@ impl ProjectContextModel { 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(); + if let Some(rules) = self + .path_to_rules + .remove(&LocalOrRemotePath::Local(project_root)) + { + let deleted_rules = rules.local_rule_paths().collect(); ctx.emit(ProjectContextModelEvent::KnownRulesChanged(RulesDelta { discovered_rules: Vec::new(), deleted_rules, @@ -426,7 +440,16 @@ 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(); + self.find_applicable_project_rules_at_location(&LocalOrRemotePath::Local( + path.to_path_buf(), + )) + } + + pub fn find_applicable_project_rules_at_location( + &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 +469,7 @@ impl ProjectContextModel { }); } - if !current_path.pop() { - return None; - } + current_path = current_path.parent()?; } } @@ -465,7 +486,14 @@ impl ProjectContextModel { /// a project-only signal should use /// [`Self::find_applicable_project_rules`] instead. pub fn find_applicable_rules(&self, path: &Path) -> Option { - let project_result = self.find_applicable_project_rules(path); + self.find_applicable_rules_at_location(&LocalOrRemotePath::Local(path.to_path_buf())) + } + + pub fn find_applicable_rules_at_location( + &self, + path: &LocalOrRemotePath, + ) -> Option { + let project_result = self.find_applicable_project_rules_at_location(path); // Layered precedence: global rules are always included alongside // project rules. `global_rules` is a `BTreeMap`, so iteration is @@ -486,8 +514,9 @@ 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()); + let root_path = project_root.unwrap_or_else(|| { + LocalOrRemotePath::Local(self.global_rules.first_rule_parent().unwrap_or_default()) + }); Some(ProjectRulesResult { root_path, @@ -499,14 +528,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 +553,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 @@ -541,16 +572,42 @@ impl ProjectContextModel { /// 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() } + + pub fn replace_remote_project_rules_from_metadata( + &mut self, + remote_root: RemotePath, + rules: Vec, + ctx: &mut ModelContext, + ) { + let mut project_rules = ProjectRules::default(); + for rule in rules { + project_rules.upsert_rule(&rule.path, rule.content); + } + self.path_to_rules + .insert(LocalOrRemotePath::Remote(remote_root), project_rules); + ctx.emit(ProjectContextModelEvent::PathIndexed); + } + + pub fn clear_remote_project_rules_for_removed_metadata_root( + &mut self, + remote_root: RemotePath, + ctx: &mut ModelContext, + ) { + self.path_to_rules + .remove(&LocalOrRemotePath::Remote(remote_root)); + ctx.emit(ProjectContextModelEvent::PathIndexed); + } } impl Entity for ProjectContextModel { diff --git a/crates/ai/src/project_context/model_tests.rs b/crates/ai/src/project_context/model_tests.rs index bc6307e3a2..99f6d6cdfd 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 { @@ -300,16 +330,22 @@ fn test_merge_rediscovery_keeps_latest() { fn test_failed_standing_rule_read_preserves_cached_content() { let rule_path = PathBuf::from("/unavailable/project/WARP.md"); let mut existing_rules = ProjectRules::default(); - existing_rules.upsert_rule(&rule_path, "cached content".to_string()); + existing_rules.upsert_rule( + &LocalOrRemotePath::Local(rule_path.clone()), + "cached content".to_string(), + ); let rules = futures::executor::block_on(ProjectContextModel::read_standing_project_rules( vec![rule_path.clone()], 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].path, + LocalOrRemotePath::Local(rule_path) + ); assert_eq!(result.active_rules[0].content, "cached content"); } @@ -318,14 +354,16 @@ fn test_failed_standing_rule_read_preserves_cached_content() { fn test_rule_missing_from_standing_results_is_removed_from_cached_content() { let rule_path = PathBuf::from("/unavailable/project/WARP.md"); let mut existing_rules = ProjectRules::default(); - existing_rules.upsert_rule(&rule_path, "cached content".to_string()); + existing_rules.upsert_rule( + &LocalOrRemotePath::Local(rule_path), + "cached content".to_string(), + ); let rules = futures::executor::block_on(ProjectContextModel::read_standing_project_rules( Vec::new(), existing_rules, )); - - assert!(rules.all_rule_paths().next().is_none()); + assert!(rules.local_rule_paths().next().is_none()); } // Helper for global-rules tests: inserts a synthetic global rule directly into @@ -335,7 +373,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 +387,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_at_location(&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_at_location(&remote_path("host-b", "/repo/src/main.rs")); + assert!(other_host.is_none()); } #[test] @@ -370,7 +434,7 @@ fn test_global_rule_alone_no_project_rules() { 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()); @@ -395,7 +459,7 @@ fn test_global_rule_layered_with_project_warp() { 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] @@ -444,7 +508,7 @@ fn test_global_rule_root_path_falls_back_to_parent() { .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] From 1862fbe63c9dade27b9e76a5b24bb93582ad84f4 Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Mon, 1 Jun 2026 00:51:33 -0700 Subject: [PATCH 2/6] format name Co-Authored-By: Oz --- app/src/ai/facts/view/rule.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/ai/facts/view/rule.rs b/app/src/ai/facts/view/rule.rs index e3af6b4cc8..f9ad587b8a 100644 --- a/app/src/ai/facts/view/rule.rs +++ b/app/src/ai/facts/view/rule.rs @@ -39,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; @@ -707,8 +708,9 @@ impl RuleView { &self, project_row: FileBackedRow, appearance: &Appearance, + app: &AppContext, ) -> Option> { - let row_name = project_row.file_path.display_path(); + 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) @@ -871,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 { From 34f37c3713f922ec4fc3e19f365c053fe1b2f8ab Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Thu, 4 Jun 2026 15:03:11 -0700 Subject: [PATCH 3/6] Simplify project rule hydration Co-Authored-By: Oz --- app/src/ai/blocklist/context_model.rs | 22 +- .../ai/blocklist/controller/input_context.rs | 2 +- app/src/ai/facts/view/rule.rs | 4 +- app/src/ai/metadata_project_rules.rs | 254 +++---------- app/src/ai/metadata_project_rules_tests.rs | 50 +-- app/src/ai/persisted_workspace.rs | 26 +- app/src/code_review/code_review_view.rs | 3 +- app/src/lib.rs | 9 +- app/src/terminal/input.rs | 2 +- app/src/terminal/view.rs | 11 +- app/src/terminal/view/init_project/model.rs | 3 +- crates/ai/src/project_context/global_rules.rs | 8 +- crates/ai/src/project_context/model.rs | 343 +++++++++--------- crates/ai/src/project_context/model_tests.rs | 97 +++-- 14 files changed, 340 insertions(+), 494 deletions(-) diff --git a/app/src/ai/blocklist/context_model.rs b/app/src/ai/blocklist/context_model.rs index b299fc10d6..58beb0ae9f 100644 --- a/app/src/ai/blocklist/context_model.rs +++ b/app/src/ai/blocklist/context_model.rs @@ -3,7 +3,6 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::str::FromStr; use std::sync::Arc; use ai::project_context::model::ProjectContextModel; @@ -394,21 +393,7 @@ 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 { - let current_working_directory_location = self.current_pwd().and_then(|path| { - PathBuf::from_str(&path) - .ok() - .and_then(|path| path.canonicalize().ok()) - .map(LocalOrRemotePath::Local) - }); - self.pending_context_for_location( - app, - is_user_query, - current_working_directory_location.as_ref(), - ) - } - - pub fn pending_context_for_location( + pub fn pending_context( &self, app: &AppContext, is_user_query: bool, @@ -426,9 +411,8 @@ impl BlocklistAIContextModel { }) }; - let project_rules = current_working_directory_location.and_then(|pwd| { - ProjectContextModel::as_ref(app).find_applicable_rules_at_location(pwd) - }); + let project_rules = current_working_directory_location + .and_then(|pwd| ProjectContextModel::as_ref(app).find_applicable_rules(pwd)); let mut context = Vec::new(); diff --git a/app/src/ai/blocklist/controller/input_context.rs b/app/src/ai/blocklist/controller/input_context.rs index 76d6f7b855..b58a244bd3 100644 --- a/app/src/ai/blocklist/controller/input_context.rs +++ b/app/src/ai/blocklist/controller/input_context.rs @@ -53,7 +53,7 @@ pub(super) fn input_context_for_request( app: &AppContext, ) -> Arc<[AIAgentContext]> { let current_working_directory_location = active_session.current_working_directory_location(app); - let mut context = context_model.pending_context_for_location( + let mut context = context_model.pending_context( app, is_user_query, current_working_directory_location.as_ref(), diff --git a/app/src/ai/facts/view/rule.rs b/app/src/ai/facts/view/rule.rs index f9ad587b8a..880d384175 100644 --- a/app/src/ai/facts/view/rule.rs +++ b/app/src/ai/facts/view/rule.rs @@ -223,7 +223,7 @@ impl RuleView { .as_ref(ctx) .global_rule_paths() .map(|p| FileBackedRow { - file_path: LocalOrRemotePath::Local(p), + file_path: p, mouse_state: Default::default(), }) .collect(); @@ -249,7 +249,7 @@ impl RuleView { .as_ref(ctx) .global_rule_paths() .map(|p| FileBackedRow { - file_path: LocalOrRemotePath::Local(p), + file_path: p, mouse_state: Default::default(), }) .collect(); diff --git a/app/src/ai/metadata_project_rules.rs b/app/src/ai/metadata_project_rules.rs index 030ffdbed0..ad83bea69e 100644 --- a/app/src/ai/metadata_project_rules.rs +++ b/app/src/ai/metadata_project_rules.rs @@ -1,219 +1,64 @@ use std::collections::HashMap; -use ai::project_context::model::{ProjectContextModel, ProjectRule}; +use ai::project_context::model::ProjectRuleContents; use futures::future::{BoxFuture, FutureExt as _}; use remote_server::proto::{ file_context_proto, FileContextProto, ReadFileContextFile, ReadFileContextRequest, }; -use repo_metadata::{ - RepoMetadataEvent, RepoMetadataModel, RepositoryIdentifier, StandingQueryContent, -}; use warp_util::local_or_remote_path::LocalOrRemotePath; -use warp_util::remote_path::RemotePath; -use warpui::{AppContext, Entity, ModelContext, SingletonEntity}; +use warpui::{AppContext, SingletonEntity}; use crate::remote_server::manager::RemoteServerManager; -pub(crate) struct MetadataProjectRulesModel { - refresh_generations: HashMap, - next_refresh_generation: u64, -} - -type ProjectRuleContentsFuture = - BoxFuture<'static, anyhow::Result>>; - -impl MetadataProjectRulesModel { - pub(crate) fn new(ctx: &mut ModelContext) -> Self { - ctx.subscribe_to_model(&RepoMetadataModel::handle(ctx), |me, event, ctx| { - me.handle_repo_metadata_event(event, ctx); - }); - - let repo_ids = RepoMetadataModel::as_ref(ctx) - .remote_repository_ids(ctx) - .cloned() - .map(RepositoryIdentifier::Remote) - .collect::>(); - let mut model = Self { - refresh_generations: HashMap::new(), - next_refresh_generation: 0, - }; - for repo_id in repo_ids { - model.refresh_project_rules_for_repo(&repo_id, ctx); - } - model - } - - fn handle_repo_metadata_event( - &mut self, - event: &RepoMetadataEvent, - ctx: &mut ModelContext, - ) { - match event { - RepoMetadataEvent::RepositoryUpdated { - id: repo_id @ RepositoryIdentifier::Remote(_), - } => self.refresh_project_rules_for_repo(repo_id, ctx), - RepoMetadataEvent::StandingQueryResultsUpdated { - id: repo_id @ RepositoryIdentifier::Remote(_), - delta, - } => { - if delta.project_rules_changed() { - self.refresh_project_rules_for_repo(repo_id, ctx); +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() + ), } } - RepoMetadataEvent::RepositoryRemoved { - id: repo_id @ RepositoryIdentifier::Remote(_), - } => self.clear_project_rules_for_removed_repository(repo_id, ctx), - RepoMetadataEvent::RepositoryUpdated { - id: RepositoryIdentifier::Local(_), - } - | RepoMetadataEvent::RepositoryRemoved { - id: RepositoryIdentifier::Local(_), - } - | RepoMetadataEvent::StandingQueryResultsUpdated { - id: RepositoryIdentifier::Local(_), - .. - } - | RepoMetadataEvent::FileTreeUpdated { .. } - | RepoMetadataEvent::FileTreeEntryUpdated { .. } - | RepoMetadataEvent::UpdatingRepositoryFailed { .. } - | RepoMetadataEvent::IncrementalUpdateReady { .. } => {} - } - } - - fn refresh_project_rules_for_repo( - &mut self, - repo_id: &RepositoryIdentifier, - ctx: &mut ModelContext, - ) { - let RepositoryIdentifier::Remote(remote_root) = repo_id else { - return; - }; - let refresh_generation = self.advance_refresh_generation(repo_id); - let rule_paths = remote_project_rule_paths( - repo_id, - RepoMetadataModel::as_ref(ctx) - .standing_query_results(repo_id, ctx) - .into_iter() - .flat_map(|results| results.project_rules()), - ); - if rule_paths.is_empty() { - self.apply_project_rules_if_current( - repo_id, - refresh_generation, - remote_root.clone(), - Vec::new(), - ctx, - ); - return; + Ok(contents) } - let Some(read_rule_contents) = read_remote_project_rule_contents(rule_paths, ctx) else { - return; - }; - let repo_id_for_result = repo_id.clone(); - let remote_root_for_result = remote_root.clone(); - ctx.spawn( + .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 { - let rule_contents = read_rule_contents.await?; - Ok::, anyhow::Error>(build_project_rules(rule_contents)) - }, - move |me, hydrated_rules, ctx| match hydrated_rules { - Ok(rules) => me.apply_project_rules_if_current( - &repo_id_for_result, - refresh_generation, - remote_root_for_result, - rules, - ctx, - ), - Err(error) => log::warn!("Failed to read remote project rules: {error}"), - }, - ); - } - - fn clear_project_rules_for_removed_repository( - &mut self, - repo_id: &RepositoryIdentifier, - ctx: &mut ModelContext, - ) { - self.refresh_generations.remove(repo_id); - let RepositoryIdentifier::Remote(remote_root) = repo_id else { - return; - }; - ProjectContextModel::handle(ctx).update(ctx, |model, ctx| { - model.clear_remote_project_rules_for_removed_metadata_root(remote_root.clone(), ctx); - }); - } - - fn advance_refresh_generation(&mut self, repo_id: &RepositoryIdentifier) -> u64 { - self.next_refresh_generation += 1; - self.refresh_generations - .insert(repo_id.clone(), self.next_refresh_generation); - self.next_refresh_generation - } - - fn apply_project_rules_if_current( - &mut self, - repo_id: &RepositoryIdentifier, - refresh_generation: u64, - remote_root: RemotePath, - rules: Vec, - ctx: &mut ModelContext, - ) { - if self.refresh_generations.get(repo_id) != Some(&refresh_generation) { - return; + 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() } - ProjectContextModel::handle(ctx).update(ctx, |model, ctx| { - model.replace_remote_project_rules_from_metadata(remote_root, rules, ctx); - }); } } -impl Entity for MetadataProjectRulesModel { - type Event = (); -} - -impl SingletonEntity for MetadataProjectRulesModel {} - -fn remote_project_rule_paths<'a>( - repo_id: &RepositoryIdentifier, - contents: impl IntoIterator, -) -> Vec { - let RepositoryIdentifier::Remote(remote_root) = repo_id else { - return Vec::new(); - }; - contents - .into_iter() - .filter(|content| !content.is_directory) - .map(|content| { - LocalOrRemotePath::Remote(RemotePath::new( - remote_root.host_id.clone(), - content.path.clone(), - )) - }) - .collect() -} - -fn read_remote_project_rule_contents( - rule_paths: Vec, - ctx: &AppContext, -) -> Option { - let LocalOrRemotePath::Remote(remote) = rule_paths.first()? else { - return None; - }; - let handle = RemoteServerManager::as_ref(ctx).host_request_handle(&remote.host_id); - Some( - async move { - let response = handle - .read_file_context(remote_rule_read_request(&rule_paths)) - .await?; - Ok(match_remote_project_rule_contents( - rule_paths, - response.file_contexts, - )) - } - .boxed(), - ) -} - fn remote_rule_read_request(rule_paths: &[LocalOrRemotePath]) -> ReadFileContextRequest { ReadFileContextRequest { files: rule_paths @@ -231,7 +76,11 @@ fn remote_rule_read_request(rule_paths: &[LocalOrRemotePath]) -> ReadFileContext } } -fn match_remote_project_rule_contents( +/// 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)> { @@ -256,13 +105,6 @@ fn match_remote_project_rule_contents( .collect() } -fn build_project_rules(rule_contents: Vec<(LocalOrRemotePath, String)>) -> Vec { - rule_contents - .into_iter() - .map(|(path, content)| ProjectRule { 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 index b78dd5fc04..04466e1885 100644 --- a/app/src/ai/metadata_project_rules_tests.rs +++ b/app/src/ai/metadata_project_rules_tests.rs @@ -1,14 +1,10 @@ use remote_server::proto::{file_context_proto, FileContextProto}; -use repo_metadata::{RepositoryIdentifier, StandingQueryContent, StandingQueryResults}; 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::{ - build_project_rules, match_remote_project_rule_contents, remote_project_rule_paths, - remote_rule_read_request, -}; +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( @@ -40,19 +36,21 @@ fn remote_rule_contents_match_reordered_responses_by_path() { let first_path = remote_rule_path(&host, "WARP.md"); let second_path = remote_rule_path(&host, "nested/AGENTS.md"); - let rules = build_project_rules(match_remote_project_rule_contents( + 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!(rules.len(), 2); - assert_eq!(rules[0].path, first_path); - assert_eq!(rules[0].content, "first rules"); - assert_eq!(rules[1].path, second_path); - assert_eq!(rules[1].content, "second rules"); + assert_eq!( + contents, + vec![ + (first_path, "first rules".to_string()), + (second_path, "second rules".to_string()), + ] + ); } #[test] @@ -61,14 +59,12 @@ fn remote_rule_contents_keep_paths_aligned_after_missing_reads() { let missing_path = remote_rule_path(&host, "WARP.md"); let present_path = remote_rule_path(&host, "nested/AGENTS.md"); - let rules = build_project_rules(match_remote_project_rule_contents( + 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!(rules.len(), 1); - assert_eq!(rules[0].path, present_path); - assert_eq!(rules[0].content, "present rules"); + assert_eq!(contents, vec![(present_path, "present rules".to_string())]); } #[test] @@ -91,23 +87,3 @@ fn remote_rule_read_request_preserves_discovered_paths() { assert_eq!(request.files[0].path, first_remote.path.as_str()); assert_eq!(request.files[1].path, second_remote.path.as_str()); } - -#[test] -fn 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 mut results = StandingQueryResults::default(); - results.insert_project_rule(StandingQueryContent::file(rule_path.clone())); - results.insert_project_rule(StandingQueryContent::directory( - StandardizedPath::try_new("/repo/nested").unwrap(), - )); - - assert_eq!( - remote_project_rule_paths(&repo_id, results.project_rules()), - vec![LocalOrRemotePath::Remote(RemotePath::new(host, rule_path))] - ); -} diff --git a/app/src/ai/persisted_workspace.rs b/app/src/ai/persisted_workspace.rs index a89b5fc29d..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; @@ -314,7 +315,7 @@ impl PersistedWorkspace { let DetectedRepositoriesEvent::DetectedGitRepo { repository, .. } = event; let repo_path = repository.as_ref(ctx).root_dir().to_local_path_lossy(); - me.index_repo(repo_path, false, ctx); + me.index_repo(repo_path, ctx); }); } @@ -668,17 +669,14 @@ impl PersistedWorkspace { } #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] - fn index_repo( - &self, - directory_path: PathBuf, - index_project_rules: bool, - ctx: &mut ModelContext, - ) { - if index_project_rules { - ProjectContextModel::handle(ctx).update(ctx, |model, ctx| { - let _ = model.index_and_store_rules(directory_path.clone(), ctx); - }); - } + 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(), + read_project_rule_contents, + ctx, + ); + }); if FeatureFlag::FullSourceCodeEmbedding.is_enabled() && UserWorkspaces::as_ref(ctx).is_codebase_context_enabled(ctx) && *CodeSettings::as_ref(ctx).auto_indexing_enabled @@ -718,9 +716,7 @@ impl PersistedWorkspace { } self.persist_metadata_for_index(&path); - // Explicitly added folders may not be metadata-backed repositories, so retain direct - // project-rule scanning for this manual-workspace path. - self.index_repo(path.clone(), true, ctx); + self.index_repo(path.clone(), ctx); ctx.emit(PersistedWorkspaceEvent::WorkspaceAdded { path }); } diff --git a/app/src/code_review/code_review_view.rs b/app/src/code_review/code_review_view.rs index 158441723a..afc8ca6f12 100644 --- a/app/src/code_review/code_review_view.rs +++ b/app/src/code_review/code_review_view.rs @@ -4092,8 +4092,7 @@ 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) diff --git a/app/src/lib.rs b/app/src/lib.rs index 6be7080fc2..337ddc1aa3 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -144,7 +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::MetadataProjectRulesModel; +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}; @@ -2018,9 +2018,12 @@ 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, + ) }); - ctx.add_singleton_model(MetadataProjectRulesModel::new); // Index global rules (e.g. ~/.agents/AGENTS.md) on a background task so // they are available to subsequent agent queries. 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/crates/ai/src/project_context/global_rules.rs b/crates/ai/src/project_context/global_rules.rs index 74e7a7a0a3..e5f1202ffd 100644 --- a/crates/ai/src/project_context/global_rules.rs +++ b/crates/ai/src/project_context/global_rules.rs @@ -78,16 +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()) - .and_then(|parent| parent.to_local_path().map(Path::to_path_buf)) } /// Index all configured global rule sources (see [`GlobalRuleSource`]). @@ -161,6 +160,7 @@ impl GlobalRules { 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, }, diff --git a/crates/ai/src/project_context/model.rs b/crates/ai/src/project_context/model.rs index 32976ea513..d5720f5c26 100644 --- a/crates/ai/src/project_context/model.rs +++ b/crates/ai/src/project_context/model.rs @@ -1,22 +1,51 @@ -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 futures::future::BoxFuture; use warp_util::local_or_remote_path::LocalOrRemotePath; -use warp_util::remote_path::RemotePath; -use warpui_core::{Entity, ModelContext, SingletonEntity}; +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; } } +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: LocalOrRemotePath, @@ -63,32 +92,32 @@ struct ProjectRules { } impl ProjectRules { - #[cfg(feature = "local_fs")] - fn local_rule_paths(&self) -> impl Iterator + '_ { + fn rule_paths(&self) -> impl Iterator { self.rules.iter().flat_map(|rule| { rule.warp_md .iter() .chain(rule.agents_md.iter()) - .filter_map(|rule| rule.path.to_local_path().map(Path::to_path_buf)) + .map(|rule| &rule.path) }) } + fn local_rule_paths(&self) -> impl Iterator + '_ { + self.rule_paths() + .filter_map(|path| path.to_local_path().map(Path::to_path_buf)) + } - #[cfg(feature = "local_fs")] - fn retain_rule_paths(&mut self, retained_paths: &HashSet) { + fn retain_rule_paths(&mut self, retained_paths: &HashSet) { self.rules.retain_mut(|rule| { if rule .warp_md .as_ref() - .and_then(|rule| rule.path.to_local_path()) - .is_some_and(|path| !retained_paths.contains(path)) + .is_some_and(|rule| !retained_paths.contains(&rule.path)) { rule.warp_md = None; } if rule .agents_md .as_ref() - .and_then(|rule| rule.path.to_local_path()) - .is_some_and(|path| !retained_paths.contains(path)) + .is_some_and(|rule| !retained_paths.contains(&rule.path)) { rule.agents_md = None; } @@ -167,13 +196,13 @@ 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. + /// Latest metadata-backed async refresh per repository. #[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 @@ -237,43 +266,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: RepositoryIdentifier::Remote(_), + RepoMetadataEvent::RepositoryRemoved { id } => { + me.remove_project_rules_for_repo(id, ctx); } - | 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 }, @@ -285,9 +311,18 @@ impl ProjectContextModel { ctx.emit(ProjectContextModelEvent::PathIndexed); }, ); + + 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. @@ -295,6 +330,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")] @@ -309,7 +345,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(()) } @@ -317,86 +353,58 @@ 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 project_root_location = LocalOrRemotePath::Local(project_root.clone()); - let existing_rules = self - .path_to_rules - .get(&project_root_location) - .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.local_rule_paths().collect::>(); - let previous = me - .path_to_rules - .insert( - LocalOrRemotePath::Local(project_root_for_read.clone()), - 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: 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, + 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(&LocalOrRemotePath::Local(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 } @@ -404,24 +412,63 @@ 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(&LocalOrRemotePath::Local(project_root)) - { - let deleted_rules = rules.local_rule_paths().collect(); + if let Some(rules) = self.path_to_rules.remove(&project_root) { + 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. @@ -439,13 +486,7 @@ 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 { - self.find_applicable_project_rules_at_location(&LocalOrRemotePath::Local( - path.to_path_buf(), - )) - } - - pub fn find_applicable_project_rules_at_location( + pub fn find_applicable_project_rules( &self, path: &LocalOrRemotePath, ) -> Option { @@ -485,15 +526,8 @@ 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 { - self.find_applicable_rules_at_location(&LocalOrRemotePath::Local(path.to_path_buf())) - } - - pub fn find_applicable_rules_at_location( - &self, - path: &LocalOrRemotePath, - ) -> Option { - let project_result = self.find_applicable_project_rules_at_location(path); + 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 // project rules. `global_rules` is a `BTreeMap`, so iteration is @@ -513,10 +547,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(|| { - LocalOrRemotePath::Local(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, @@ -563,9 +595,9 @@ 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() } @@ -583,31 +615,6 @@ impl ProjectContextModel { }) .collect() } - - pub fn replace_remote_project_rules_from_metadata( - &mut self, - remote_root: RemotePath, - rules: Vec, - ctx: &mut ModelContext, - ) { - let mut project_rules = ProjectRules::default(); - for rule in rules { - project_rules.upsert_rule(&rule.path, rule.content); - } - self.path_to_rules - .insert(LocalOrRemotePath::Remote(remote_root), project_rules); - ctx.emit(ProjectContextModelEvent::PathIndexed); - } - - pub fn clear_remote_project_rules_for_removed_metadata_root( - &mut self, - remote_root: RemotePath, - ctx: &mut ModelContext, - ) { - self.path_to_rules - .remove(&LocalOrRemotePath::Remote(remote_root)); - ctx.emit(ProjectContextModelEvent::PathIndexed); - } } impl Entity for ProjectContextModel { diff --git a/crates/ai/src/project_context/model_tests.rs b/crates/ai/src/project_context/model_tests.rs index 99f6d6cdfd..062be54f59 100644 --- a/crates/ai/src/project_context/model_tests.rs +++ b/crates/ai/src/project_context/model_tests.rs @@ -325,45 +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( - &LocalOrRemotePath::Local(rule_path.clone()), - "cached content".to_string(), - ); + 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(&local_path("/unavailable/project/main.rs")); assert_eq!(result.active_rules.len(), 1); - assert_eq!( - result.active_rules[0].path, - LocalOrRemotePath::Local(rule_path) - ); + 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( - &LocalOrRemotePath::Local(rule_path), - "cached content".to_string(), + 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 rules = futures::executor::block_on(ProjectContextModel::read_standing_project_rules( - Vec::new(), - existing_rules, + 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(), )); - assert!(rules.local_rule_paths().next().is_none()); + 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 @@ -407,14 +442,14 @@ fn test_remote_project_rules_require_matching_host() { ); let same_host = model - .find_applicable_project_rules_at_location(&remote_path("host-a", "/repo/src/main.rs")) + .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_at_location(&remote_path("host-b", "/repo/src/main.rs")); + let other_host = + model.find_applicable_project_rules(&remote_path("host-b", "/repo/src/main.rs")); assert!(other_host.is_none()); } @@ -428,7 +463,7 @@ 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); @@ -452,7 +487,7 @@ 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. @@ -482,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. @@ -494,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()); } @@ -504,7 +539,7 @@ 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. @@ -526,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); From df81fb4bd2e8a239924ea0db1c05dc478ee9e32c Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Thu, 4 Jun 2026 16:18:12 -0700 Subject: [PATCH 4/6] wasm --- crates/ai/src/project_context/dummy_global_rules.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 } } From 1d7b89bf169f4d5a4c72fabc1f43309b746dde11 Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Thu, 4 Jun 2026 22:32:52 -0700 Subject: [PATCH 5/6] clarifying comments --- app/src/ai/blocklist/context_model.rs | 2 ++ crates/ai/src/project_context/model.rs | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/ai/blocklist/context_model.rs b/app/src/ai/blocklist/context_model.rs index 58beb0ae9f..2e7838cf28 100644 --- a/app/src/ai/blocklist/context_model.rs +++ b/app/src/ai/blocklist/context_model.rs @@ -399,6 +399,8 @@ impl BlocklistAIContextModel { 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 diff --git a/crates/ai/src/project_context/model.rs b/crates/ai/src/project_context/model.rs index d5720f5c26..0d1c71d76c 100644 --- a/crates/ai/src/project_context/model.rs +++ b/crates/ai/src/project_context/model.rs @@ -200,7 +200,8 @@ impl ProjectRules { 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 repository. + /// 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, #[cfg(feature = "local_fs")] @@ -312,6 +313,8 @@ impl ProjectContextModel { }, ); + // 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() @@ -420,6 +423,8 @@ impl ProjectContextModel { return; }; if let Some(rules) = self.path_to_rules.remove(&project_root) { + // 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 { From a952f94ca60d3405bf686e31c74871914d410baf Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Thu, 4 Jun 2026 22:41:58 -0700 Subject: [PATCH 6/6] clippy wasm --- crates/ai/src/project_context/model.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ai/src/project_context/model.rs b/crates/ai/src/project_context/model.rs index 0d1c71d76c..75f94b7148 100644 --- a/crates/ai/src/project_context/model.rs +++ b/crates/ai/src/project_context/model.rs @@ -92,6 +92,7 @@ struct ProjectRules { } impl ProjectRules { + #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] fn rule_paths(&self) -> impl Iterator { self.rules.iter().flat_map(|rule| { rule.warp_md @@ -100,11 +101,12 @@ impl ProjectRules { .map(|rule| &rule.path) }) } + #[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 @@ -399,6 +401,7 @@ impl ProjectContextModel { }); } + #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] fn reconcile_project_rules( rule_paths: Vec, rule_contents: ProjectRuleContents,