642 lines
22 KiB
Rust
642 lines
22 KiB
Rust
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<ProjectRule>,
|
|
agents_md: Option<ProjectRule>,
|
|
}
|
|
|
|
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<ProjectRule>,
|
|
pub additional_rule_paths: Vec<String>,
|
|
}
|
|
|
|
#[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<ProjectRule>,
|
|
/// Rule paths that are currently not active but available to be applied if
|
|
/// a file under its directory is edited.
|
|
available_rule_paths: Vec<String>,
|
|
}
|
|
|
|
#[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<RuleAtPath>,
|
|
}
|
|
|
|
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<ProjectRule> {
|
|
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<PathBuf, ProjectRules>,
|
|
}
|
|
|
|
#[derive(Default, Debug)]
|
|
pub struct RulesDelta {
|
|
pub discovered_rules: Vec<ProjectRulePath>,
|
|
pub deleted_rules: Vec<PathBuf>,
|
|
}
|
|
|
|
/// 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<ProjectRulePath>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> 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<Self>,
|
|
) -> 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<ProjectRules>, 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<Self>) {
|
|
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<Self>) {
|
|
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<ProjectRulesResult> {
|
|
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<ProjectRules> {
|
|
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::<FileMetadata>::new();
|
|
let mut gitignores = Vec::<Gitignore>::new();
|
|
|
|
// Collect patterns that should not be ignored
|
|
let override_ignore_patterns: Vec<String> =
|
|
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<ProjectRulePath>,
|
|
) -> HashMap<PathBuf, ProjectRules> {
|
|
let mut rules: HashMap<PathBuf, ProjectRules> = 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<Item = PathBuf> + '_ {
|
|
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<PathBuf> {
|
|
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<RepositoryUpdate>,
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
impl RepositorySubscriber for ProjectContextRepositorySubscriber {
|
|
fn on_scan(
|
|
&mut self,
|
|
_repository: &Repository,
|
|
_ctx: &mut ModelContext<Repository>,
|
|
) -> std::pin::Pin<Box<dyn std::prelude::rust_2024::Future<Output = ()> + 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<Repository>,
|
|
) -> std::pin::Pin<Box<dyn std::prelude::rust_2024::Future<Output = ()> + 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;
|