use parking_lot::RwLock;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InterruptMode {
Never,
#[default]
ProseOnly,
ToolOnly,
Always,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScopeToken {
Text,
Thinking,
Tool {
name: String,
globs: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleSource {
BuiltinDefaults,
Project,
User,
}
#[derive(Debug, Clone)]
pub struct Rule {
pub name: String,
pub content: String,
pub description: Option<String>,
pub condition: Vec<regex::Regex>,
pub scope: Vec<ScopeToken>,
pub interrupt_mode: InterruptMode,
pub globs: Vec<String>,
pub always_apply: bool,
pub source: RuleSource,
}
pub trait RuleRegistry: Send + Sync + 'static {
fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>>;
fn mark_injected(&self, _name: &str, _turn: u64) {}
fn injected_records(&self) -> Vec<(String, u64)> {
vec![]
}
fn restore(&self, _records: Vec<(String, u64)>) {}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MatchSource {
Text,
Thinking,
Tool,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
struct BufferKey {
source: MatchSource,
tool_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TtsrMatchContext {
pub source: MatchSource,
pub file_paths: Vec<String>,
pub tool_name: Option<String>,
}
pub struct TtsrEngine {
rules: Arc<dyn RuleRegistry>,
buffers: RwLock<HashMap<BufferKey, Vec<String>>>,
settings: TtsrSettings,
}
impl std::fmt::Debug for TtsrEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TtsrEngine")
.field("settings", &self.settings)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct TtsrSettings {
pub enabled: bool,
pub interrupt_mode: InterruptMode,
pub builtin_rules: bool,
pub max_retries_per_turn: u32,
}
impl Default for TtsrSettings {
fn default() -> Self {
Self {
enabled: false,
interrupt_mode: InterruptMode::ProseOnly,
builtin_rules: true,
max_retries_per_turn: 3,
}
}
}
impl TtsrEngine {
pub fn new(rules: Arc<dyn RuleRegistry>, settings: TtsrSettings) -> Self {
Self {
rules,
buffers: RwLock::new(HashMap::new()),
settings,
}
}
pub fn reset_buffers(&self) {
self.buffers.write().clear();
}
pub fn check_delta(&self, delta: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
if !self.settings.enabled {
return vec![];
}
let key = self.buffer_key(ctx);
let mut buffers = self.buffers.write();
let buf = buffers.entry(key).or_default();
buf.push(delta.to_string());
let full: String = buf.concat();
self.match_buffer(&full, ctx).into_iter().collect()
}
pub fn check_snapshot(&self, snapshot: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
if !self.settings.enabled {
return vec![];
}
let key = self.buffer_key(ctx);
let mut buffers = self.buffers.write();
buffers.insert(key, vec![snapshot.to_string()]);
self.match_buffer(snapshot, ctx).into_iter().collect()
}
pub fn injected_records(&self) -> Vec<(String, u64)> {
self.rules.injected_records()
}
fn buffer_key(&self, ctx: &TtsrMatchContext) -> BufferKey {
BufferKey {
source: ctx.source,
tool_name: if matches!(ctx.source, MatchSource::Tool) {
ctx.tool_name.clone()
} else {
None
},
}
}
fn match_buffer(&self, buf: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
let mut matched = Vec::new();
let rules: Vec<Rule> = futures::executor::block_on(self.rules.rules());
for rule in rules {
if !self.scope_matches(&rule, ctx) {
continue;
}
let mode = if matches!(rule.interrupt_mode, InterruptMode::Never) {
self.settings.interrupt_mode
} else {
rule.interrupt_mode
};
if !self.mode_allows(mode, ctx.source) {
continue;
}
if !rule.condition.iter().any(|re| re.is_match(buf)) {
continue;
}
matched.push(rule);
}
matched
}
fn scope_matches(&self, rule: &Rule, ctx: &TtsrMatchContext) -> bool {
if rule.scope.is_empty() {
return true;
}
for token in &rule.scope {
match token {
ScopeToken::Text => {
if matches!(ctx.source, MatchSource::Text) {
return true;
}
}
ScopeToken::Thinking => {
if matches!(ctx.source, MatchSource::Thinking) {
return true;
}
}
ScopeToken::Tool { name, globs } => {
if !matches!(ctx.source, MatchSource::Tool) {
continue;
}
if matches!(ctx.tool_name.as_ref(), Some(tool_name) if tool_name != name) {
continue;
}
if !globs.is_empty() {
let any_match = ctx.file_paths.iter().any(|fp| {
globs.iter().any(|g| {
g.strip_suffix("/*")
.map(|prefix| fp.starts_with(prefix))
.unwrap_or_else(|| g == fp)
})
});
if !any_match {
continue;
}
}
return true;
}
}
}
false
}
fn mode_allows(&self, mode: InterruptMode, source: MatchSource) -> bool {
match mode {
InterruptMode::Never => false,
InterruptMode::ProseOnly => matches!(source, MatchSource::Text),
InterruptMode::ToolOnly => matches!(source, MatchSource::Tool),
InterruptMode::Always => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use regex::Regex;
use std::pin::Pin;
struct StaticRegistry {
rules: Vec<Rule>,
injections: RwLock<Vec<(String, u64)>>,
}
impl RuleRegistry for StaticRegistry {
fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>> {
Box::pin(std::future::ready(self.rules.clone()))
}
fn mark_injected(&self, name: &str, turn: u64) {
self.injections.write().push((name.to_string(), turn));
}
fn injected_records(&self) -> Vec<(String, u64)> {
self.injections.read().clone()
}
fn restore(&self, records: Vec<(String, u64)>) {
*self.injections.write() = records;
}
}
fn make_rule(name: &str, pattern: &str) -> Rule {
Rule {
name: name.to_string(),
content: format!("Do not use {pattern}."),
description: Some(format!("Forbids {pattern}")),
condition: vec![Regex::new(pattern).unwrap()],
scope: vec![],
interrupt_mode: InterruptMode::ProseOnly,
globs: vec![],
always_apply: false,
source: RuleSource::BuiltinDefaults,
}
}
#[test]
fn test_check_delta_matches_simple_pattern() {
let rules = Arc::new(StaticRegistry {
rules: vec![make_rule("no-todo", r"TODO:")],
injections: RwLock::new(Vec::new()),
});
let engine = TtsrEngine::new(
rules,
TtsrSettings {
enabled: true,
..Default::default()
},
);
let ctx = TtsrMatchContext {
source: MatchSource::Text,
file_paths: vec![],
tool_name: None,
};
let results = engine.check_delta("This code is almost ", &ctx);
assert!(results.is_empty());
let results = engine.check_delta("TODO: fix later", &ctx);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "no-todo");
}
#[test]
fn test_check_delta_respects_disabled() {
let rules = Arc::new(StaticRegistry {
rules: vec![make_rule("no-todo", r"TODO:")],
injections: RwLock::new(Vec::new()),
});
let engine = TtsrEngine::new(
rules,
TtsrSettings {
enabled: false, ..Default::default()
},
);
let ctx = TtsrMatchContext {
source: MatchSource::Text,
file_paths: vec![],
tool_name: None,
};
let results = engine.check_delta("TODO: fix later", &ctx);
assert!(results.is_empty(), "disabled engine must return no matches");
}
#[test]
fn test_scope_filter_respects_tool_scope() {
let rules = Arc::new(StaticRegistry {
rules: vec![Rule {
name: "edit-only-rule".to_string(),
content: "Only for edit tool".to_string(),
description: None,
condition: vec![Regex::new("bad").unwrap()],
scope: vec![ScopeToken::Tool {
name: "edit".to_string(),
globs: vec![],
}],
interrupt_mode: InterruptMode::Always,
globs: vec![],
always_apply: false,
source: RuleSource::BuiltinDefaults,
}],
injections: RwLock::new(Vec::new()),
});
let engine = TtsrEngine::new(
rules,
TtsrSettings {
enabled: true,
..Default::default()
},
);
let text_ctx = TtsrMatchContext {
source: MatchSource::Text,
file_paths: vec![],
tool_name: None,
};
assert!(engine.check_delta("bad code", &text_ctx).is_empty());
let tool_ctx = TtsrMatchContext {
source: MatchSource::Tool,
file_paths: vec![],
tool_name: Some("edit".to_string()),
};
assert!(!engine.check_delta("bad code", &tool_ctx).is_empty());
let write_ctx = TtsrMatchContext {
source: MatchSource::Tool,
file_paths: vec![],
tool_name: Some("write".to_string()),
};
assert!(engine.check_delta("bad code", &write_ctx).is_empty());
}
#[test]
fn test_reset_buffers_clears_accumulation() {
let rules = Arc::new(StaticRegistry {
rules: vec![make_rule("no-todo", r"TODO:")],
injections: RwLock::new(Vec::new()),
});
let engine = TtsrEngine::new(
rules,
TtsrSettings {
enabled: true,
..Default::default()
},
);
let ctx = TtsrMatchContext {
source: MatchSource::Text,
file_paths: vec![],
tool_name: None,
};
engine.check_delta("TODO", &ctx);
engine.reset_buffers();
let results = engine.check_delta(":", &ctx);
assert!(results.is_empty(), "buffer was reset — TODO should be gone");
}
#[test]
fn test_prose_only_mode_ignores_tool_source() {
let rules = Arc::new(StaticRegistry {
rules: vec![make_rule("no-bad", r"bad")],
injections: RwLock::new(Vec::new()),
});
let engine = TtsrEngine::new(
rules,
TtsrSettings {
enabled: true,
interrupt_mode: InterruptMode::ProseOnly,
..Default::default()
},
);
let text_ctx = TtsrMatchContext {
source: MatchSource::Text,
file_paths: vec![],
tool_name: None,
};
assert!(!engine.check_delta("bad code", &text_ctx).is_empty());
let tool_ctx = TtsrMatchContext {
source: MatchSource::Tool,
file_paths: vec![],
tool_name: Some("edit".to_string()),
};
assert!(engine.check_delta("bad code", &tool_ctx).is_empty());
}
}