Skip to main content

oxi/discovery/
rules.rs

1//! Rule discovery for the TTSR engine.
2//!
3//! Scans a project directory for rule files and returns compiled
4//! [`Rule`] values ready for the TTSR engine. Rules are discovered
5//! from several sources:
6//!
7//! 1. **Bundled builtin rules** — always loaded (see [`builtin_rules`]).
8//! 2. **`.oxi/rules/*.mdc`** — project-level rule files.
9//! 3. **`.cursorrules/` / `.clinerules/`** — fallback directories
10//!    when `.oxi/rules/` does not exist.
11//!
12//! Each `.mdc` file uses YAML frontmatter followed by a markdown body:
13//!
14//! ```text
15//! ---
16//! description: Rule description
17//! condition: "regex"
18//! scope: "text"
19//! interruptMode: prose-only
20//! ---
21//! Rule body in markdown.
22//! ```
23
24use super::builtin_rules;
25use oxi_agent::agent_loop::ttsr::Rule;
26use oxi_agent::agent_loop::ttsr::RuleSource;
27use std::path::Path;
28
29/// Discover all TTSR rules for a project.
30///
31/// Always includes the bundled default rules. Additionally scans
32/// `.oxi/rules/*.mdc` in `project_dir`, falling back to
33/// `.cursorrules/` or `.clinerules/` (as directories or single files)
34/// when `.oxi/rules/` does not exist.
35pub fn discover_rules(project_dir: &Path) -> Vec<Rule> {
36    let mut rules = Vec::new();
37
38    // Always include the builtin default rules.
39    rules.extend(builtin_rules::load_all());
40
41    let oxi_rules_dir = project_dir.join(".oxi").join("rules");
42    if oxi_rules_dir.is_dir() {
43        rules.extend(scan_mdc_dir(&oxi_rules_dir, RuleSource::Project));
44        return rules;
45    }
46
47    // Fallback directories / files.
48    for fallback in &[".cursorrules", ".clinerules"] {
49        let fallback_path = project_dir.join(fallback);
50        if fallback_path.is_dir() {
51            rules.extend(scan_mdc_dir(&fallback_path, RuleSource::Project));
52        } else if fallback_path.is_file()
53            && let Ok(content) = std::fs::read_to_string(&fallback_path)
54            && let Some(rule) =
55                builtin_rules::parse_rule_file(&content, fallback, RuleSource::Project)
56        {
57            rules.push(rule);
58        }
59    }
60
61    rules
62}
63
64/// Scan a directory for `.mdc` files and parse them into rules.
65fn scan_mdc_dir(dir: &Path, source: RuleSource) -> Vec<Rule> {
66    let mut rules = Vec::new();
67
68    let entries = match std::fs::read_dir(dir) {
69        Ok(entries) => entries,
70        Err(_) => return rules,
71    };
72
73    for entry in entries.flatten() {
74        let path = entry.path();
75        if path.extension().and_then(|e| e.to_str()) != Some("mdc") {
76            continue;
77        }
78
79        let name = match path.file_stem().and_then(|s| s.to_str()) {
80            Some(n) => n.to_string(),
81            None => continue,
82        };
83
84        let content = match std::fs::read_to_string(&path) {
85            Ok(c) => c,
86            Err(_) => continue,
87        };
88
89        if let Some(rule) = builtin_rules::parse_rule_file(&content, &name, source.clone()) {
90            rules.push(rule);
91        }
92    }
93
94    rules
95}
96
97/// A simple [`RuleRegistry`] that serves a static list of rules.
98///
99/// Used to feed discovered rules into the TTSR engine without
100/// requiring a full persisted registry.
101pub struct StaticRuleRegistry {
102    rules: std::sync::RwLock<Vec<Rule>>,
103    injections: std::sync::RwLock<Vec<(String, u64)>>,
104}
105
106impl StaticRuleRegistry {
107    /// Create a new registry holding the given static list of `rules`.
108    pub fn new(rules: Vec<Rule>) -> Self {
109        Self {
110            rules: std::sync::RwLock::new(rules),
111            injections: std::sync::RwLock::new(Vec::new()),
112        }
113    }
114}
115
116impl oxi_agent::agent_loop::ttsr::RuleRegistry for StaticRuleRegistry {
117    fn rules<'a>(
118        &'a self,
119    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<Rule>> + Send + 'a>> {
120        let rules = self
121            .rules
122            .read()
123            .unwrap_or_else(std::sync::PoisonError::into_inner)
124            .clone();
125        Box::pin(std::future::ready(rules))
126    }
127
128    fn mark_injected(&self, name: &str, turn: u64) {
129        self.injections
130            .write()
131            .unwrap_or_else(std::sync::PoisonError::into_inner)
132            .push((name.to_string(), turn));
133    }
134
135    fn injected_records(&self) -> Vec<(String, u64)> {
136        self.injections
137            .read()
138            .unwrap_or_else(std::sync::PoisonError::into_inner)
139            .clone()
140    }
141
142    fn restore(&self, records: Vec<(String, u64)>) {
143        *self
144            .injections
145            .write()
146            .unwrap_or_else(std::sync::PoisonError::into_inner) = records;
147    }
148}