use anyhow::Result; #[cfg(feature = "local_fs")] use repo_metadata::repositories::RepoDetectionSource; use std::collections::HashMap; use std::path::{Path, PathBuf}; use warpui::{Entity, ModelContext, SingletonEntity}; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use repo_metadata::entry::{Entry, FileMetadata}; use repo_metadata::repository::RepositorySubscriber; use repo_metadata::{Repository, DirectoryWatcher, RepositoryUpdate}; use ignore::gitignore::Gitignore; use async_channel::Sender; const RULES_FILE_PATTERN: [&str; 2] = ["WARP.md", "AGENTS.md"]; const MAX_SCAN_DEPTH: usize = 3; const MAX_FILES_TO_SCAN: usize = 5000; } } #[derive(Debug, Default, Clone)] pub struct ProjectRule { pub path: PathBuf, pub content: String, } #[derive(Debug, Default)] struct RuleAtPath { parent_path: PathBuf, warp_md: Option, agents_md: Option, } impl RuleAtPath { fn respected_rule(&self) -> Option<&ProjectRule> { self.warp_md.as_ref().or(self.agents_md.as_ref()) } } #[derive(Debug, Default, Clone)] pub struct ProjectRulesResult { pub root_path: PathBuf, pub active_rules: Vec, pub additional_rule_paths: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectRulePath { pub path: PathBuf, pub project_root: PathBuf, } struct FindRulesResult { /// Rules that are active and should be eagerly applied. active_rules: Vec, /// Rule paths that are currently not active but available to be applied if /// a file under its directory is edited. available_rule_paths: Vec, } #[cfg(feature = "local_fs")] fn matches_rules_pattern(file_name_str: &str) -> bool { for pattern in RULES_FILE_PATTERN { if file_name_str.to_lowercase() == pattern.to_lowercase() { return true; } } false } #[derive(Debug, Default)] struct ProjectRules { rules: Vec, } 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 { let mut active_rules = Vec::new(); let mut available_rule_paths = Vec::new(); // Collect all applicable rules (rules in directories that are ancestors of the target path) for rule in &self.rules { if let Some(respected_rule) = rule.respected_rule() { // Check if the rule's directory is an ancestor of or equal to the target path 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()); } } } FindRulesResult { active_rules, available_rule_paths, } } /// Remove a rule from the set of project rules. This returns the removed rule. #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] fn remove_rule(&mut self, path: &Path) -> Option { let parent = path.parent()?; let file_name = path.file_name().and_then(|name| name.to_str())?; let rule = self .rules .iter_mut() .find(|rule| rule.parent_path == parent)?; if file_name.to_lowercase() == "warp.md" { rule.warp_md.take() } else if file_name.to_lowercase() == "agents.md" { rule.agents_md.take() } else { None } } /// Upsert a rule to the set of project rules. This will create a new RuleAtPath entry if none exists and update the existin one /// otherwise. #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] fn upsert_rule(&mut self, path: &Path, content: String) { let Some(parent) = path.parent() else { return; }; let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { return; }; let existing_rule = self .rules .iter_mut() .find(|rule| rule.parent_path == parent); let rule_file = Some(ProjectRule { path: path.to_path_buf(), content, }); match existing_rule { Some(rule) => { if file_name.to_lowercase() == "warp.md" { rule.warp_md = rule_file; } else if file_name.to_lowercase() == "agents.md" { rule.agents_md = rule_file; } } None => { let mut rule = RuleAtPath { parent_path: parent.to_path_buf(), ..Default::default() }; if file_name.to_lowercase() == "warp.md" { rule.warp_md = rule_file; } else if file_name.to_lowercase() == "agents.md" { rule.agents_md = rule_file; } self.rules.push(rule); } }; } } /// 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)] pub struct ProjectContextModel { /// Mapping from directory path to list of rule files found in that directory path_to_rules: HashMap, } #[derive(Default, Debug)] pub struct RulesDelta { pub discovered_rules: Vec, pub deleted_rules: Vec, } /// Events emitted by the ProjectContextModel pub enum ProjectContextModelEvent { /// Emitted when a path has been indexed PathIndexed, /// Emitted when the known set of rule files changed KnownRulesChanged(RulesDelta), } impl ProjectContextModel { #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] pub fn new_from_persisted( persisted_rules: Vec, ctx: &mut ModelContext, ) -> Self { #[cfg(feature = "local_fs")] ctx.spawn( async move { Self::read_persisted_rules(persisted_rules).await }, |me, mut res, ctx| { for root in res.keys() { me.try_initialize_and_register_watcher(root, ctx); } // If we have any rules detected before fully loading the persisted rules, we want to // keep the detected rules since it's more up to date. res.extend(me.path_to_rules.drain()); me.path_to_rules = res; ctx.emit(ProjectContextModelEvent::PathIndexed); }, ); Self::default() } /// Index a path and find all rule files from that path up to the root directory /// Returns a list of all rule files found #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] pub fn index_and_store_rules( &mut self, root_path: PathBuf, ctx: &mut ModelContext, ) -> Result<()> { if self.path_to_rules.contains_key(&root_path) { return Ok(()); } #[cfg(feature = "local_fs")] { let root_clone = root_path.clone(); ctx.spawn( async move { Self::scan_directory_for_rules(&root_path).await }, move |me, res: Result, ctx| match res { Ok(rule_files) => { me.register_watcher_for_path(&root_clone, ctx); // Persist the discovered rules. let delta = RulesDelta { discovered_rules: rule_files .rules .iter() .filter_map(|rule| { rule.warp_md.as_ref().map(|rule| ProjectRulePath { project_root: root_clone.clone(), path: rule.path.clone(), }) }) .chain(rule_files.rules.iter().filter_map(|rule| { rule.agents_md.as_ref().map(|rule| ProjectRulePath { project_root: root_clone.clone(), path: rule.path.clone(), }) })) .collect(), deleted_rules: Default::default(), }; ctx.emit(ProjectContextModelEvent::KnownRulesChanged(delta)); me.path_to_rules.insert(root_clone, rule_files); ctx.emit(ProjectContextModelEvent::PathIndexed); } Err(e) => log::warn!( "Couldn't index rules for path {}: {}", root_clone.display(), e ), }, ); } Ok(()) } /// This should be used when we are bootstrapping project rules from persisted rule paths. In this case, /// the actual repo watcher might not have been registered yet. We will attempt to register that repo watcher /// if it doesn't yet exists. #[cfg(feature = "local_fs")] fn try_initialize_and_register_watcher(&self, path: &Path, ctx: &mut ModelContext) { use repo_metadata::repositories::DetectedRepositories; let directory_watcher = DirectoryWatcher::handle(ctx); if directory_watcher .as_ref(ctx) .get_watched_directory_for_path(path) .is_some() { self.register_watcher_for_path(path, ctx); return; } let fut = DetectedRepositories::handle(ctx).update(ctx, |model, ctx| { model.detect_possible_git_repo( &path.to_string_lossy(), RepoDetectionSource::ProjectRulesIndexing, ctx, ) }); ctx.spawn(fut, move |me, repo_path_opt, ctx| { if let Some(path) = repo_path_opt { me.register_watcher_for_path(&path, ctx); } }); } #[cfg(feature = "local_fs")] fn register_watcher_for_path(&self, path: &Path, ctx: &mut ModelContext) { let Some(repository_model) = DirectoryWatcher::as_ref(ctx).get_watched_directory_for_path(path) else { return; }; let (repository_update_tx, repository_update_rx) = async_channel::unbounded(); let start = repository_model.update(ctx, |repo, ctx| { repo.start_watching( Box::new(ProjectContextRepositorySubscriber { repository_update_tx, }), ctx, ) }); let subscriber_id = start.subscriber_id; let repository_model_for_cleanup = repository_model.downgrade(); let path_clone = path.to_path_buf(); let path_for_log = path_clone.clone(); ctx.spawn(start.registration_future, move |_, res, ctx| { if let Err(err) = res { log::warn!( "Failed to start watching repository for rule updates at {}: {err}", path_for_log.display() ); if let Some(repository_model) = repository_model_for_cleanup.upgrade(ctx) { repository_model.update(ctx, |repo, ctx| { repo.stop_watching(subscriber_id, ctx); }); } } }); ctx.spawn_stream_local( repository_update_rx.clone(), move |me, update, ctx| { if update.is_empty() { return; } let existing_rules = me.path_to_rules.remove(&path_clone); let repo_path = path_clone.clone(); if let Some(rules) = existing_rules { let repo_path_for_closure = repo_path.clone(); ctx.spawn( async move { Self::process_repository_updates(update, rules, repo_path).await }, move |me, (rules, rule_delta), ctx| { ctx.emit(ProjectContextModelEvent::KnownRulesChanged(rule_delta)); me.path_to_rules.insert(repo_path_for_closure, rules); ctx.emit(ProjectContextModelEvent::PathIndexed); }, ); } }, |_, _| {}, ); } pub fn find_applicable_rules(&self, path: &Path) -> Option { let mut current_path = path.to_owned(); let mut active_rules = Vec::new(); let mut available_rule_paths = Vec::new(); // Find the root path with indexed rules and collect active rules let mut found_rules = false; loop { if let Some(rules) = self.path_to_rules.get(¤t_path) { let result = rules.find_active_or_applicable_rules(path); active_rules = result.active_rules; available_rule_paths = result.available_rule_paths; found_rules = true; break; } if !current_path.pop() { break; } } if !found_rules { return None; } if active_rules.is_empty() && available_rule_paths.is_empty() { return None; } Some(ProjectRulesResult { root_path: current_path, active_rules, additional_rule_paths: available_rule_paths, }) } #[cfg(feature = "local_fs")] async fn process_repository_updates( repository_update: RepositoryUpdate, mut existing_rules: ProjectRules, project_root: PathBuf, ) -> (ProjectRules, RulesDelta) { let mut rules_delta = RulesDelta::default(); // Handle deleted files - remove rules for deleted rule files for target_file in &repository_update.deleted { // Skip gitignored files if target_file.is_ignored { continue; } if let Some(file_name_str) = target_file.path.file_name().and_then(|name| name.to_str()) { if matches_rules_pattern(file_name_str) { // Remove the rule from existing rules existing_rules.remove_rule(&target_file.path); rules_delta.deleted_rules.push(target_file.path.clone()); log::debug!("Removed rule file: {}", target_file.path.display()); } } } // Handle moved files - update paths for moved rule files for (to_target, from_target) in &repository_update.moved { // Skip gitignored files if to_target.is_ignored || from_target.is_ignored { continue; } if let Some(file_name_str) = to_target.path.file_name().and_then(|name| name.to_str()) { if matches_rules_pattern(file_name_str) { // Find and update the rule with the old path if let Some(rule) = existing_rules.remove_rule(&from_target.path) { // Emit deletion event for old path rules_delta.deleted_rules.push(from_target.path.clone()); existing_rules.upsert_rule(&to_target.path, rule.content); // Emit upsert event for new path rules_delta.discovered_rules.push(ProjectRulePath { path: to_target.path.clone(), project_root: project_root.clone(), }); log::debug!( "Updated rule file path: {} -> {}", from_target.path.display(), to_target.path.display() ); } } } } // Handle added/updated files - upsert rules for rule files for target_file in repository_update.added_or_modified() { // Skip gitignored files if target_file.is_ignored { continue; } if let Some(file_name_str) = target_file.path.file_name().and_then(|name| name.to_str()) { if matches_rules_pattern(file_name_str) { // Read the content of the rule file match async_fs::read_to_string(&target_file.path).await { Ok(content) => { existing_rules.upsert_rule(&target_file.path, content); } Err(e) => { log::warn!( "Failed to read updated rule file {}: {}", target_file.path.display(), e ); } } } } } (existing_rules, rules_delta) } /// Scan a directory for rule files (currently WARP.md, extensible for future file types) /// Uses repo_metadata::entry::build_tree for efficient directory traversal #[cfg(feature = "local_fs")] async fn scan_directory_for_rules(dir_path: &Path) -> Result { use repo_metadata::entry::IgnoredPathStrategy; let mut rule_files = ProjectRules::default(); if !async_fs::metadata(dir_path).await?.is_dir() { return Ok(rule_files); } // Use build_tree to collect all files, then filter for rule files let mut files = Vec::::new(); let mut gitignores = Vec::::new(); // Collect patterns that should not be ignored let override_ignore_patterns: Vec = RULES_FILE_PATTERN.iter().map(|s| s.to_string()).collect(); let mut file_limit = MAX_FILES_TO_SCAN; // Build the file tree using repo_metadata's build_tree function let ignore_behavior = IgnoredPathStrategy::IncludeOnly(override_ignore_patterns.clone()); let _ = Entry::build_tree( dir_path, &mut files, &mut gitignores, Some(&mut file_limit), MAX_SCAN_DEPTH, 0, &ignore_behavior, )?; // Filter files to only include those matching RULES_FILE_PATTERN for file_metadata in files { let path = &file_metadata.path; let file_name = path.file_name(); if let Some(file_name_str) = file_name { if matches_rules_pattern(file_name_str) { // Read the content of the rule file let local_path = file_metadata.path.to_local_path_lossy(); let content = match async_fs::read_to_string(&local_path).await { Ok(content) => content, Err(e) => { log::warn!("Failed to read rule file {}: {e}", file_metadata.path,); break; } }; rule_files.upsert_rule(&local_path, content); } } } Ok(rule_files) } #[cfg(feature = "local_fs")] async fn read_persisted_rules( rule_paths: Vec, ) -> 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); } Err(e) => { log::debug!( "Failed to read rule file from persistence {}: {}", rule.path.display(), e ); // Continue processing other files even if one fails } } } rules } pub fn indexed_rules(&self) -> impl Iterator + '_ { self.path_to_rules.values().flat_map(|rules| { rules.rules.iter().filter_map(|rules| { rules .respected_rule() .map(|project_rule| project_rule.path.clone()) }) }) } /// 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) .into_iter() .flat_map(|rules| { rules.rules.iter().filter_map(|rule| { rule.respected_rule() .map(|project_rule| project_rule.path.clone()) }) }) .collect() } } impl Entity for ProjectContextModel { type Event = ProjectContextModelEvent; } impl SingletonEntity for ProjectContextModel {} #[cfg(feature = "local_fs")] struct ProjectContextRepositorySubscriber { repository_update_tx: Sender, } #[cfg(feature = "local_fs")] impl RepositorySubscriber for ProjectContextRepositorySubscriber { fn on_scan( &mut self, _repository: &Repository, _ctx: &mut ModelContext, ) -> std::pin::Pin + Send + 'static>> { // The model can safely ignore the initial scan because the model only subscribes // after the repository is already scanned. Box::pin(async {}) } fn on_files_updated( &mut self, _repository: &Repository, update: &repo_metadata::RepositoryUpdate, _ctx: &mut ModelContext, ) -> std::pin::Pin + Send + 'static>> { let tx = self.repository_update_tx.clone(); let update = update.clone(); Box::pin(async move { let _ = tx.send(update).await; }) } } #[cfg(test)] #[path = "model_tests.rs"] mod tests;