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            if let Ok(content) = std::fs::read_to_string(&fallback_path) {
54                if let Some(rule) =
55                    builtin_rules::parse_rule_file(&content, fallback, RuleSource::Project)
56                {
57                    rules.push(rule);
58                }
59            }
60        }
61    }
62
63    rules
64}
65
66/// Scan a directory for `.mdc` files and parse them into rules.
67fn scan_mdc_dir(dir: &Path, source: RuleSource) -> Vec<Rule> {
68    let mut rules = Vec::new();
69
70    let entries = match std::fs::read_dir(dir) {
71        Ok(entries) => entries,
72        Err(_) => return rules,
73    };
74
75    for entry in entries.flatten() {
76        let path = entry.path();
77        if path.extension().and_then(|e| e.to_str()) != Some("mdc") {
78            continue;
79        }
80
81        let name = match path.file_stem().and_then(|s| s.to_str()) {
82            Some(n) => n.to_string(),
83            None => continue,
84        };
85
86        let content = match std::fs::read_to_string(&path) {
87            Ok(c) => c,
88            Err(_) => continue,
89        };
90
91        if let Some(rule) = builtin_rules::parse_rule_file(&content, &name, source.clone()) {
92            rules.push(rule);
93        }
94    }
95
96    rules
97}
98
99/// A simple [`RuleRegistry`] that serves a static list of rules.
100///
101/// Used to feed discovered rules into the TTSR engine without
102/// requiring a full persisted registry.
103pub struct StaticRuleRegistry {
104    rules: std::sync::RwLock<Vec<Rule>>,
105    injections: std::sync::RwLock<Vec<(String, u64)>>,
106}
107
108impl StaticRuleRegistry {
109    pub fn new(rules: Vec<Rule>) -> Self {
110        Self {
111            rules: std::sync::RwLock::new(rules),
112            injections: std::sync::RwLock::new(Vec::new()),
113        }
114    }
115}
116
117impl oxi_agent::agent_loop::ttsr::RuleRegistry for StaticRuleRegistry {
118    fn rules<'a>(
119        &'a self,
120    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<Rule>> + Send + 'a>> {
121        let rules = self.rules.read().unwrap().clone();
122        Box::pin(std::future::ready(rules))
123    }
124
125    fn mark_injected(&self, name: &str, turn: u64) {
126        self.injections
127            .write()
128            .unwrap()
129            .push((name.to_string(), turn));
130    }
131
132    fn injected_records(&self) -> Vec<(String, u64)> {
133        self.injections.read().unwrap().clone()
134    }
135
136    fn restore(&self, records: Vec<(String, u64)>) {
137        *self.injections.write().unwrap() = records;
138    }
139}