use std::path::Path;
use serde::{Deserialize, Serialize};
use tracing::warn;
pub const RULE_SENTINEL: &str = "[stream rule ";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleScope {
Text,
Thinking,
ToolArg,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleMode {
Interrupt,
Remind,
}
#[derive(Debug)]
pub struct StreamRule {
pub name: String,
pub description: String,
pub matcher: regex::Regex,
pub scope: RuleScope,
pub mode: RuleMode,
pub body: String,
}
impl StreamRule {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
pattern: &str,
scope: RuleScope,
mode: RuleMode,
body: impl Into<String>,
) -> Result<Self, String> {
let matcher =
regex::Regex::new(pattern).map_err(|e| format!("invalid regex `{pattern}`: {e}"))?;
Ok(Self {
name: name.into(),
description: description.into(),
matcher,
scope,
mode,
body: body.into(),
})
}
}
#[derive(Deserialize)]
struct RuleFrontmatter {
description: String,
#[serde(rename = "match")]
pattern: String,
scope: RuleScope,
mode: RuleMode,
}
pub fn parse_rule(name: &str, content: &str) -> Result<StreamRule, String> {
let trimmed = content.trim_start_matches('\u{feff}').trim_start();
let rest = trimmed
.strip_prefix("---")
.ok_or_else(|| "missing YAML frontmatter (expected `---` delimiters)".to_string())?;
let rest = rest.trim_start_matches(['\r', '\n']);
let end_pos = rest
.find("\n---")
.ok_or_else(|| "unterminated YAML frontmatter".to_string())?;
let yaml_str = rest[..end_pos].trim_end_matches('\r');
let fm: RuleFrontmatter =
serde_yaml::from_str(yaml_str).map_err(|e| format!("YAML parse error: {e}"))?;
let body = rest[end_pos + "\n---".len()..]
.trim_start_matches(['\r', '\n'])
.trim()
.to_string();
StreamRule::new(name, fm.description, &fm.pattern, fm.scope, fm.mode, body)
}
#[must_use]
pub fn load_rules(dir: &Path) -> Vec<StreamRule> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut rules = Vec::new();
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
warn!(dir = %dir.display(), error = %e, "skipping unreadable dir entry");
continue;
}
};
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|content| parse_rule(&name, &content))
{
Ok(rule) => rules.push(rule),
Err(e) => warn!(path = %path.display(), error = %e, "skipping invalid stream rule"),
}
}
rules.sort_by(|a, b| a.name.cmp(&b.name));
rules
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
const VALID: &str = r#"---
description: Never use Box::leak in Rust code
match: "Box::leak"
scope: text
mode: interrupt
---
Box::leak permanently leaks memory. Return owned values instead.
"#;
#[test]
fn parses_valid_rule_file() {
let rule = parse_rule("no-box-leak", VALID).unwrap();
assert_eq!(rule.name, "no-box-leak");
assert_eq!(rule.description, "Never use Box::leak in Rust code");
assert_eq!(rule.scope, RuleScope::Text);
assert_eq!(rule.mode, RuleMode::Interrupt);
assert!(rule.matcher.is_match("let x = Box::leak(b);"));
assert!(!rule.matcher.is_match("let x = Box::new(b);"));
assert!(rule.body.contains("permanently leaks memory"));
}
#[test]
fn rejects_missing_frontmatter() {
assert!(parse_rule("bad", "no frontmatter here").is_err());
}
#[test]
fn rejects_invalid_regex() {
let content =
"---\ndescription: d\nmatch: \"[unclosed\"\nscope: text\nmode: remind\n---\nbody\n";
let err = parse_rule("bad-regex", content).unwrap_err();
assert!(err.contains("invalid regex"));
}
#[test]
fn rejects_unknown_scope() {
let content =
"---\ndescription: d\nmatch: \"x\"\nscope: everything\nmode: remind\n---\nbody\n";
assert!(parse_rule("bad-scope", content).is_err());
}
#[test]
fn load_rules_skips_invalid_and_sorts_by_name() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("b-rule.md"), VALID).unwrap();
std::fs::write(
tmp.path().join("a-rule.md"),
"---\ndescription: d\nmatch: \"secret\"\nscope: tool_arg\nmode: remind\n---\nnote body\n",
)
.unwrap();
std::fs::write(tmp.path().join("broken.md"), "no frontmatter").unwrap();
let rules = load_rules(tmp.path());
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].name, "a-rule");
assert_eq!(rules[1].name, "b-rule");
assert_eq!(rules[0].scope, RuleScope::ToolArg);
assert_eq!(rules[0].mode, RuleMode::Remind);
}
#[test]
fn load_rules_missing_dir_is_empty() {
let rules = load_rules(std::path::Path::new("/nonexistent/rules/dir"));
assert!(rules.is_empty());
}
#[test]
fn load_rules_ignores_non_markdown_files() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("notes.txt"), VALID).unwrap();
assert!(load_rules(tmp.path()).is_empty());
}
}