Files
galaxy/crates/repo_metadata/src/standing_queries.rs
T

258 lines
8.8 KiB
Rust

//! Standing repository queries maintained alongside the canonical file tree.
//!
//! These results contain project-derived context paths that must remain available
//! even when the visible file tree is intentionally lazy or shallow.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use galaxy_util::standardized_path::StandardizedPath;
/// Repository-scoped standing query configuration.
#[derive(Debug, Clone)]
pub struct StandingQueryDefinitions {
project_skill_provider_paths: Vec<PathBuf>,
project_rule_file_names: Vec<String>,
}
impl Default for StandingQueryDefinitions {
fn default() -> Self {
Self {
project_skill_provider_paths: Vec::new(),
project_rule_file_names: vec!["WARP.md".to_string(), "AGENTS.md".to_string()],
}
}
}
impl StandingQueryDefinitions {
pub fn set_project_skill_provider_paths(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
self.project_skill_provider_paths = paths.into_iter().collect();
}
pub fn project_skill_provider_paths(&self) -> &[PathBuf] {
&self.project_skill_provider_paths
}
fn is_project_skill_provider_directory(&self, path: &Path) -> bool {
self.project_skill_provider_paths
.iter()
.any(|provider_path| path.ends_with(provider_path))
}
fn project_skill_provider_ancestor<'a>(&self, path: &'a Path) -> Option<&'a Path> {
path.ancestors()
.find(|ancestor| self.is_project_skill_provider_directory(ancestor))
}
fn is_direct_project_skill_provider_child(&self, path: &Path) -> bool {
path.parent()
.is_some_and(|parent| self.is_project_skill_provider_directory(parent))
}
fn is_project_skill_file(&self, path: &Path) -> bool {
path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md")
&& path
.parent()
.and_then(Path::parent)
.is_some_and(|skills_root| self.is_project_skill_provider_directory(skills_root))
}
fn is_project_rule_file(&self, path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|file_name| {
self.project_rule_file_names
.iter()
.any(|rule_name| file_name.eq_ignore_ascii_case(rule_name))
})
}
}
/// A path retained by a standing query.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct StandingQueryContent {
pub path: StandardizedPath,
pub is_directory: bool,
}
impl StandingQueryContent {
pub fn file(path: StandardizedPath) -> Self {
Self {
path,
is_directory: false,
}
}
pub fn directory(path: StandardizedPath) -> Self {
Self {
path,
is_directory: true,
}
}
}
/// Current paths matching each standing repository query.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StandingQueryResults {
project_skills: HashSet<StandingQueryContent>,
project_rules: HashSet<StandingQueryContent>,
}
impl StandingQueryResults {
pub fn project_skills(&self) -> impl Iterator<Item = &StandingQueryContent> {
self.project_skills.iter()
}
pub fn project_rules(&self) -> impl Iterator<Item = &StandingQueryContent> {
self.project_rules.iter()
}
/// Records a path encountered while traversing the repository.
pub(crate) fn record_path(
&mut self,
path: &Path,
is_directory: bool,
definitions: &StandingQueryDefinitions,
) {
let standardized = StandardizedPath::from_local_absolute_unchecked(path);
if is_directory && definitions.is_project_skill_provider_directory(path) {
self.project_skills
.insert(StandingQueryContent::directory(standardized.clone()));
}
if !is_directory && definitions.is_project_skill_file(path) {
self.project_skills
.insert(StandingQueryContent::file(standardized.clone()));
}
if !is_directory && definitions.is_project_rule_file(path) {
self.project_rules
.insert(StandingQueryContent::file(standardized));
}
}
pub(crate) fn record_direct_project_skill_provider_child_change(
&mut self,
path: &Path,
definitions: &StandingQueryDefinitions,
) {
if definitions.is_direct_project_skill_provider_child(path) {
if let Some(provider_root) = definitions.project_skill_provider_ancestor(path) {
self.project_skills.insert(StandingQueryContent::directory(
StandardizedPath::from_local_absolute_unchecked(provider_root),
));
}
}
}
/// Records an eligible project skill reached through a directory symlink during standing
/// query evaluation. The lexical path is intentionally retained so consumers address the
/// skill through the provider entry rather than the symlink target.
pub(crate) fn record_followed_project_skill_directory(
&mut self,
path: &Path,
definitions: &StandingQueryDefinitions,
) {
if !definitions.is_direct_project_skill_provider_child(path) {
return;
}
let skill_file = path.join("SKILL.md");
if skill_file.is_file() {
self.record_path(&skill_file, false, definitions);
}
}
pub fn insert_project_skill(&mut self, content: StandingQueryContent) {
self.project_skills.insert(content);
}
pub fn insert_project_rule(&mut self, content: StandingQueryContent) {
self.project_rules.insert(content);
}
pub fn apply_delta(&mut self, delta: &StandingQueryResultsDelta) {
for removed in &delta.removed_project_skills {
self.project_skills.remove(removed);
}
for removed in &delta.removed_project_rules {
self.project_rules.remove(removed);
}
self.project_skills
.extend(delta.upserted_project_skills.iter().cloned());
self.project_rules
.extend(delta.upserted_project_rules.iter().cloned());
}
/// Replaces results beneath changed roots and returns the observable delta.
///
/// Upserts are emitted even when a matching path already exists so consumers
/// reread modified skill and rules file contents.
pub fn replace_subtrees(
&mut self,
removed_roots: &[StandardizedPath],
discovered: StandingQueryResults,
) -> StandingQueryResultsDelta {
let mut delta = StandingQueryResultsDelta::default();
for root in removed_roots {
let removed_skills = self
.project_skills
.iter()
.filter(|content| content.path.starts_with(root))
.cloned()
.collect::<Vec<_>>();
let removed_rules = self
.project_rules
.iter()
.filter(|content| content.path.starts_with(root))
.cloned()
.collect::<Vec<_>>();
delta.removed_project_skills.extend(removed_skills);
delta.removed_project_rules.extend(removed_rules);
}
delta
.upserted_project_skills
.extend(discovered.project_skills);
delta
.upserted_project_rules
.extend(discovered.project_rules);
self.apply_delta(&delta);
delta
}
pub fn as_snapshot_delta(&self) -> StandingQueryResultsDelta {
StandingQueryResultsDelta {
upserted_project_skills: self.project_skills.iter().cloned().collect(),
removed_project_skills: Vec::new(),
upserted_project_rules: self.project_rules.iter().cloned().collect(),
removed_project_rules: Vec::new(),
}
}
}
/// Changes to standing query results for one repository.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StandingQueryResultsDelta {
pub upserted_project_skills: Vec<StandingQueryContent>,
pub removed_project_skills: Vec<StandingQueryContent>,
pub upserted_project_rules: Vec<StandingQueryContent>,
pub removed_project_rules: Vec<StandingQueryContent>,
}
impl StandingQueryResultsDelta {
pub fn is_empty(&self) -> bool {
self.upserted_project_skills.is_empty()
&& self.removed_project_skills.is_empty()
&& self.upserted_project_rules.is_empty()
&& self.removed_project_rules.is_empty()
}
pub fn project_skills_changed(&self) -> bool {
!self.upserted_project_skills.is_empty() || !self.removed_project_skills.is_empty()
}
pub fn project_rules_changed(&self) -> bool {
!self.upserted_project_rules.is_empty() || !self.removed_project_rules.is_empty()
}
}
#[cfg(test)]
#[path = "standing_queries_tests.rs"]
mod tests;