1use parking_lot::RwLock;
14use std::collections::HashMap;
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum InterruptMode {
24 Never,
26 #[default]
28 ProseOnly,
29 ToolOnly,
31 Always,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ScopeToken {
38 Text,
40 Thinking,
42 Tool {
44 name: String,
46 globs: Vec<String>,
48 },
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum RuleSource {
54 BuiltinDefaults,
56 Project,
58 User,
60}
61#[derive(Debug, Clone)]
63pub struct Rule {
64 pub name: String,
66 pub content: String,
68 pub description: Option<String>,
70 pub condition: Vec<regex::Regex>,
72 pub scope: Vec<ScopeToken>,
74 pub interrupt_mode: InterruptMode,
76 pub globs: Vec<String>,
78 pub always_apply: bool,
80 pub source: RuleSource,
82}
83
84pub trait RuleRegistry: Send + Sync + 'static {
89 fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>>;
91
92 fn mark_injected(&self, _name: &str, _turn: u64) {}
94
95 fn injected_records(&self) -> Vec<(String, u64)> {
97 vec![]
98 }
99
100 fn restore(&self, _records: Vec<(String, u64)>) {}
102}
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub enum MatchSource {
106 Text,
108 Thinking,
110 Tool,
112}
113
114#[derive(Debug, Clone, Hash, PartialEq, Eq)]
116struct BufferKey {
117 source: MatchSource,
118 tool_name: Option<String>,
120}
121
122#[derive(Debug, Clone)]
127pub struct TtsrMatchContext {
128 pub source: MatchSource,
130 pub file_paths: Vec<String>,
132 pub tool_name: Option<String>,
134}
135
136pub struct TtsrEngine {
141 rules: Arc<dyn RuleRegistry>,
142 buffers: RwLock<HashMap<BufferKey, Vec<String>>>,
144 settings: TtsrSettings,
145}
146
147impl std::fmt::Debug for TtsrEngine {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.debug_struct("TtsrEngine")
150 .field("settings", &self.settings)
151 .finish_non_exhaustive()
152 }
153}
154
155#[derive(Debug, Clone)]
157pub struct TtsrSettings {
158 pub enabled: bool,
160 pub interrupt_mode: InterruptMode,
162 pub builtin_rules: bool,
164 pub max_retries_per_turn: u32,
166}
167
168impl Default for TtsrSettings {
169 fn default() -> Self {
170 Self {
171 enabled: false,
172 interrupt_mode: InterruptMode::ProseOnly,
173 builtin_rules: true,
174 max_retries_per_turn: 3,
175 }
176 }
177}
178
179impl TtsrEngine {
180 pub fn new(rules: Arc<dyn RuleRegistry>, settings: TtsrSettings) -> Self {
182 Self {
183 rules,
184 buffers: RwLock::new(HashMap::new()),
185 settings,
186 }
187 }
188
189 pub fn reset_buffers(&self) {
191 self.buffers.write().clear();
192 }
193
194 pub fn check_delta(&self, delta: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
199 if !self.settings.enabled {
200 return vec![];
201 }
202
203 let key = self.buffer_key(ctx);
204 let mut buffers = self.buffers.write();
205 let buf = buffers.entry(key).or_default();
206 buf.push(delta.to_string());
207
208 let full: String = buf.concat();
210 self.match_buffer(&full, ctx).into_iter().collect()
211 }
212
213 pub fn check_snapshot(&self, snapshot: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
216 if !self.settings.enabled {
217 return vec![];
218 }
219
220 let key = self.buffer_key(ctx);
221 let mut buffers = self.buffers.write();
222 buffers.insert(key, vec![snapshot.to_string()]);
223
224 self.match_buffer(snapshot, ctx).into_iter().collect()
225 }
226
227 pub fn injected_records(&self) -> Vec<(String, u64)> {
229 self.rules.injected_records()
230 }
231
232 fn buffer_key(&self, ctx: &TtsrMatchContext) -> BufferKey {
235 BufferKey {
236 source: ctx.source,
237 tool_name: if matches!(ctx.source, MatchSource::Tool) {
238 ctx.tool_name.clone()
239 } else {
240 None
241 },
242 }
243 }
244
245 fn match_buffer(&self, buf: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
248 let mut matched = Vec::new();
251
252 let rules: Vec<Rule> = futures::executor::block_on(self.rules.rules());
255
256 for rule in rules {
257 if !self.scope_matches(&rule, ctx) {
259 continue;
260 }
261
262 let mode = if matches!(rule.interrupt_mode, InterruptMode::Never) {
264 self.settings.interrupt_mode
265 } else {
266 rule.interrupt_mode
267 };
268 if !self.mode_allows(mode, ctx.source) {
269 continue;
270 }
271
272 if !rule.condition.iter().any(|re| re.is_match(buf)) {
274 continue;
275 }
276
277 matched.push(rule);
278 }
279
280 matched
281 }
282
283 fn scope_matches(&self, rule: &Rule, ctx: &TtsrMatchContext) -> bool {
285 if rule.scope.is_empty() {
286 return true;
288 }
289
290 for token in &rule.scope {
291 match token {
292 ScopeToken::Text => {
293 if matches!(ctx.source, MatchSource::Text) {
294 return true;
295 }
296 }
297 ScopeToken::Thinking => {
298 if matches!(ctx.source, MatchSource::Thinking) {
299 return true;
300 }
301 }
302 ScopeToken::Tool { name, globs } => {
303 if !matches!(ctx.source, MatchSource::Tool) {
304 continue;
305 }
306 if matches!(ctx.tool_name.as_ref(), Some(tool_name) if tool_name != name) {
307 continue;
308 }
309 if !globs.is_empty() {
311 let any_match = ctx.file_paths.iter().any(|fp| {
312 globs.iter().any(|g| {
313 g.strip_suffix("/*")
315 .map(|prefix| fp.starts_with(prefix))
316 .unwrap_or_else(|| g == fp)
317 })
318 });
319 if !any_match {
320 continue;
321 }
322 }
323 return true;
324 }
325 }
326 }
327
328 false
329 }
330
331 fn mode_allows(&self, mode: InterruptMode, source: MatchSource) -> bool {
333 match mode {
334 InterruptMode::Never => false,
335 InterruptMode::ProseOnly => matches!(source, MatchSource::Text),
336 InterruptMode::ToolOnly => matches!(source, MatchSource::Tool),
337 InterruptMode::Always => true,
338 }
339 }
340}
341
342#[cfg(test)]
345mod tests {
346 use super::*;
347 use regex::Regex;
348 use std::pin::Pin;
349
350 struct StaticRegistry {
352 rules: Vec<Rule>,
353 injections: RwLock<Vec<(String, u64)>>,
354 }
355
356 impl RuleRegistry for StaticRegistry {
357 fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>> {
358 Box::pin(std::future::ready(self.rules.clone()))
359 }
360
361 fn mark_injected(&self, name: &str, turn: u64) {
362 self.injections.write().push((name.to_string(), turn));
363 }
364
365 fn injected_records(&self) -> Vec<(String, u64)> {
366 self.injections.read().clone()
367 }
368
369 fn restore(&self, records: Vec<(String, u64)>) {
370 *self.injections.write() = records;
371 }
372 }
373
374 fn make_rule(name: &str, pattern: &str) -> Rule {
375 Rule {
376 name: name.to_string(),
377 content: format!("Do not use {pattern}."),
378 description: Some(format!("Forbids {pattern}")),
379 condition: vec![Regex::new(pattern).unwrap()],
380 scope: vec![],
381 interrupt_mode: InterruptMode::ProseOnly,
382 globs: vec![],
383 always_apply: false,
384 source: RuleSource::BuiltinDefaults,
385 }
386 }
387
388 #[test]
389 fn test_check_delta_matches_simple_pattern() {
390 let rules = Arc::new(StaticRegistry {
391 rules: vec![make_rule("no-todo", r"TODO:")],
392 injections: RwLock::new(Vec::new()),
393 });
394
395 let engine = TtsrEngine::new(
396 rules,
397 TtsrSettings {
398 enabled: true,
399 ..Default::default()
400 },
401 );
402
403 let ctx = TtsrMatchContext {
404 source: MatchSource::Text,
405 file_paths: vec![],
406 tool_name: None,
407 };
408
409 let results = engine.check_delta("This code is almost ", &ctx);
411 assert!(results.is_empty());
412
413 let results = engine.check_delta("TODO: fix later", &ctx);
415 assert_eq!(results.len(), 1);
416 assert_eq!(results[0].name, "no-todo");
417 }
418
419 #[test]
420 fn test_check_delta_respects_disabled() {
421 let rules = Arc::new(StaticRegistry {
422 rules: vec![make_rule("no-todo", r"TODO:")],
423 injections: RwLock::new(Vec::new()),
424 });
425
426 let engine = TtsrEngine::new(
427 rules,
428 TtsrSettings {
429 enabled: false, ..Default::default()
431 },
432 );
433
434 let ctx = TtsrMatchContext {
435 source: MatchSource::Text,
436 file_paths: vec![],
437 tool_name: None,
438 };
439
440 let results = engine.check_delta("TODO: fix later", &ctx);
441 assert!(results.is_empty(), "disabled engine must return no matches");
442 }
443
444 #[test]
445 fn test_scope_filter_respects_tool_scope() {
446 let rules = Arc::new(StaticRegistry {
447 rules: vec![Rule {
448 name: "edit-only-rule".to_string(),
449 content: "Only for edit tool".to_string(),
450 description: None,
451 condition: vec![Regex::new("bad").unwrap()],
452 scope: vec![ScopeToken::Tool {
453 name: "edit".to_string(),
454 globs: vec![],
455 }],
456 interrupt_mode: InterruptMode::Always,
457 globs: vec![],
458 always_apply: false,
459 source: RuleSource::BuiltinDefaults,
460 }],
461 injections: RwLock::new(Vec::new()),
462 });
463
464 let engine = TtsrEngine::new(
465 rules,
466 TtsrSettings {
467 enabled: true,
468 ..Default::default()
469 },
470 );
471
472 let text_ctx = TtsrMatchContext {
474 source: MatchSource::Text,
475 file_paths: vec![],
476 tool_name: None,
477 };
478 assert!(engine.check_delta("bad code", &text_ctx).is_empty());
479
480 let tool_ctx = TtsrMatchContext {
482 source: MatchSource::Tool,
483 file_paths: vec![],
484 tool_name: Some("edit".to_string()),
485 };
486 assert!(!engine.check_delta("bad code", &tool_ctx).is_empty());
487
488 let write_ctx = TtsrMatchContext {
490 source: MatchSource::Tool,
491 file_paths: vec![],
492 tool_name: Some("write".to_string()),
493 };
494 assert!(engine.check_delta("bad code", &write_ctx).is_empty());
495 }
496
497 #[test]
498 fn test_reset_buffers_clears_accumulation() {
499 let rules = Arc::new(StaticRegistry {
500 rules: vec![make_rule("no-todo", r"TODO:")],
501 injections: RwLock::new(Vec::new()),
502 });
503
504 let engine = TtsrEngine::new(
505 rules,
506 TtsrSettings {
507 enabled: true,
508 ..Default::default()
509 },
510 );
511
512 let ctx = TtsrMatchContext {
513 source: MatchSource::Text,
514 file_paths: vec![],
515 tool_name: None,
516 };
517
518 engine.check_delta("TODO", &ctx);
520 engine.reset_buffers();
522
523 let results = engine.check_delta(":", &ctx);
525 assert!(results.is_empty(), "buffer was reset — TODO should be gone");
526 }
527
528 #[test]
529 fn test_prose_only_mode_ignores_tool_source() {
530 let rules = Arc::new(StaticRegistry {
531 rules: vec![make_rule("no-bad", r"bad")],
532 injections: RwLock::new(Vec::new()),
533 });
534
535 let engine = TtsrEngine::new(
536 rules,
537 TtsrSettings {
538 enabled: true,
539 interrupt_mode: InterruptMode::ProseOnly,
540 ..Default::default()
541 },
542 );
543
544 let text_ctx = TtsrMatchContext {
546 source: MatchSource::Text,
547 file_paths: vec![],
548 tool_name: None,
549 };
550 assert!(!engine.check_delta("bad code", &text_ctx).is_empty());
551
552 let tool_ctx = TtsrMatchContext {
554 source: MatchSource::Tool,
555 file_paths: vec![],
556 tool_name: Some("edit".to_string()),
557 };
558 assert!(engine.check_delta("bad code", &tool_ctx).is_empty());
559 }
560}