Skip to main content

oxicode_agent/agent_loop/
ttsr.rs

1//! TTSR (Time-Traveling Stream Rules) engine.
2//!
3//! Monitors streaming model output against project rules. When a rule is
4//! violated, the stream is aborted and the rule is injected as a system
5//! reminder so the model can correct itself.
6//!
7//! Ported from omp `packages/coding-agent/src/export/ttsr.ts` (TtsrManager).
8//!
9//! Two matching modes are supported:
10//! - **Regex**: the original `condition` field, matched against streaming deltas.
11//! - **AST**: the optional `ast_condition` field, matched against file content
12//!   after tool writes via the `TtsrAstMatcher`. AST matching shells out to
13//!   the `sg` (ast-grep) CLI in the default build — same dependency surface
14//!   as [`crate::tools::ast_grep`].
15
16use parking_lot::RwLock;
17use std::collections::HashMap;
18use std::future::Future;
19use std::hash::{Hash, Hasher};
20use std::path::Path;
21use std::pin::Pin;
22use std::process::Stdio;
23use std::sync::Arc;
24
25// ── Local type definitions (mirrors oxicode_sdk::ports to avoid a dependency cycle) ─
26
27/// Interrupt mode controlling which stream sources TTSR inspects.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum InterruptMode {
30    /// Never interrupt — the rule is informational only.
31    Never,
32    /// Interrupt only on assistant prose output.
33    #[default]
34    ProseOnly,
35    /// Interrupt only on tool-call arguments.
36    ToolOnly,
37    /// Interrupt on any source (text, thinking, tools).
38    Always,
39}
40
41/// Which source produced a TTSR match.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ScopeToken {
44    /// Assistant prose (text deltas).
45    Text,
46    /// Model reasoning (thinking blocks).
47    Thinking,
48    /// Tool argument payload.
49    Tool {
50        /// Name of the tool whose arguments are being built.
51        name: String,
52        /// Glob patterns matching affected file paths.
53        globs: Vec<String>,
54    },
55}
56
57/// Where a rule originated.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum RuleSource {
60    /// Shipped with oxicode itself (e.g., rs-future-prelude).
61    BuiltinDefaults,
62    /// Project-local rule (`.oxicode/rules/*.mdc`).
63    Project,
64    /// User-level rule (`~/.oxicode/rules/*.mdc`).
65    User,
66}
67/// A single TTSR rule.
68#[derive(Debug, Clone)]
69pub struct Rule {
70    /// Human-readable name identifying this rule.
71    pub name: String,
72    /// The rule body — instructions injected as a system reminder on match.
73    pub content: String,
74    /// Optional short summary of what the rule governs.
75    pub description: Option<String>,
76    /// Regex patterns that, when found in the stream, trigger the rule.
77    pub condition: Vec<regex::Regex>,
78    /// Stream sources (and tools) this rule applies to.
79    pub scope: Vec<ScopeToken>,
80    /// When the rule is permitted to interrupt the stream.
81    pub interrupt_mode: InterruptMode,
82    /// Glob patterns restricting the rule to specific file paths.
83    pub globs: Vec<String>,
84    /// If `true`, the rule is always active regardless of conditions.
85    pub always_apply: bool,
86    /// Where the rule originated (builtin, project, or user).
87    pub source: RuleSource,
88    /// Optional ast-grep Smart pattern matched against file content after
89    /// tool writes. See `TtsrAstMatcher` for the runtime that consumes
90    /// this field. `None` (the common case) means the rule is purely
91    /// regex-driven.
92    pub ast_condition: Option<String>,
93}
94
95/// Registry of TTSR rules (supplied by the host).
96///
97/// This is a simplified version of `oxicode_sdk::ports::RuleRegistry` that lives
98/// in oxicode-agent to avoid a dependency cycle.
99pub trait RuleRegistry: Send + Sync + 'static {
100    /// Returns a future that resolves to the current set of registered rules.
101    fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>>;
102
103    /// Mark that a rule was injected at a given turn.
104    fn mark_injected(&self, _name: &str, _turn: u64) {}
105
106    /// Return all injection records for compaction survival.
107    fn injected_records(&self) -> Vec<(String, u64)> {
108        vec![]
109    }
110
111    /// Restore injection records after compaction.
112    fn restore(&self, _records: Vec<(String, u64)>) {}
113}
114/// Which stream source produced a delta.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub enum MatchSource {
117    /// Assistant prose (the main response text).
118    Text,
119    /// Model reasoning (CoT / thinking blocks).
120    Thinking,
121    /// Tool argument payloads.
122    Tool,
123}
124
125/// Per-source buffer key.
126#[derive(Debug, Clone, Hash, PartialEq, Eq)]
127struct BufferKey {
128    source: MatchSource,
129    /// Only meaningful for Tool source; otherwise None.
130    tool_name: Option<String>,
131}
132
133// ── Match context ───────────────────────────────────────────────────────────
134
135/// Context passed to [`TtsrEngine::check_delta`] describing what is being
136/// generated right now.
137#[derive(Debug, Clone)]
138pub struct TtsrMatchContext {
139    /// Source stream (text / thinking / tool).
140    pub source: MatchSource,
141    /// Active file paths this delta may affect (for glob-scoped rules).
142    pub file_paths: Vec<String>,
143    /// Tool name when `source` is [`MatchSource::Tool`].
144    pub tool_name: Option<String>,
145    /// File contents that have just been written by a tool. Each entry is
146    /// `(path, full content)`. Populated by tool execution hooks so the AST
147    /// matcher can evaluate `ast_condition` against the new file body.
148    /// Empty when no file content is available (the common case during
149    /// pure text/thinking streaming).
150    pub file_contents: Vec<(String, String)>,
151}
152
153// ── AST matcher ─────────────────────────────────────────────────────────────
154
155/// One AST rule: the subset of a [`Rule`] that the `TtsrAstMatcher`
156/// cares about. Decoupled from `Rule` so AST evaluation is independent
157/// of regex compilation and so the matcher can be reused without a
158/// full rule registry.
159#[derive(Debug, Clone)]
160pub struct AstRule {
161    /// Name surfaced on match (mirrors `Rule::name`).
162    pub name: String,
163    /// ast-grep Smart pattern.
164    pub pattern: String,
165    /// Glob patterns restricting the rule to specific file paths.
166    /// Empty = matches every file path.
167    pub file_scope: Vec<String>,
168    /// Interrupt mode carried through to the caller.
169    pub interrupt_mode: InterruptMode,
170}
171
172/// Function signature for the underlying pattern matcher. Given an
173/// ast-grep pattern and the file content, return `true` if the pattern
174/// matches at least one node in the content.
175///
176/// The default implementation shells out to the `sg` (ast-grep) CLI —
177/// same approach as [`crate::tools::ast_grep`]. Tests inject a
178/// pure-Rust matcher so they don't require `sg` to be installed.
179pub type AstMatcherFn = dyn Fn(&str, &str) -> bool + Send + Sync;
180
181/// Default matcher: invoke the `sg` CLI. Returns `false` (no match) on
182/// any error, including `sg` not being installed. This is deliberately
183/// fail-safe — an unreachable binary should not silently break the
184/// stream or surface false positives.
185fn default_sg_matcher() -> Box<AstMatcherFn> {
186    Box::new(|pattern: &str, content: &str| {
187        // sg reads source from a path or stdin; we write to a temp
188        // file because stdio handling from a sync context is brittle
189        // and the `sg` CLI is happiest with a real path. We avoid
190        // pulling in the `tempfile` crate (which is only a
191        // dev-dependency of oxicode-agent) by hand-rolling a unique
192        // path in the system temp directory.
193        let mut tmp = std::env::temp_dir();
194        let unique = format!(
195            "ttsr-ast-{}-{}.snap",
196            std::process::id(),
197            content_digest(content)
198        );
199        tmp.push(unique);
200        if std::fs::write(&tmp, content).is_err() {
201            return false;
202        }
203        let matched = run_sg_match(pattern, &tmp).unwrap_or(false);
204        // Best-effort cleanup; failure to delete the temp file is
205        // harmless (the OS reclaims /tmp on reboot).
206        let _ = std::fs::remove_file(&tmp);
207        matched
208    })
209}
210
211/// Synchronous wrapper around `sg run -p <pattern> --json <path>`.
212///
213/// Returns `Ok(true)` if at least one match is produced, `Ok(false)` on
214/// no matches or any failure that's not a hard parse error. This mirrors
215/// [`crate::tools::ast_grep::run_sg`] but is sync (we run it from the
216/// TTSR call site, not from a tokio task).
217fn run_sg_match(pattern: &str, target: &Path) -> std::io::Result<bool> {
218    let output = std::process::Command::new("sg")
219        .arg("run")
220        .arg("-p")
221        .arg(pattern)
222        .arg("--json")
223        .arg(target)
224        .stdin(Stdio::null())
225        .stdout(Stdio::piped())
226        .stderr(Stdio::piped())
227        .output();
228
229    let output = match output {
230        Ok(o) => o,
231        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
232        Err(_) => return Ok(false),
233    };
234
235    // sg exits 1 on no matches with empty stdout. We treat any exit code
236    // with non-empty stdout as "matches present" and any exit code with
237    // empty stdout as "no matches".
238    Ok(!output.stdout.is_empty())
239}
240
241/// AST condition matcher: holds a list of [`AstRule`]s, dedups by file
242/// digest, and delegates actual pattern matching to an injectable
243/// [`AstMatcherFn`].
244pub struct TtsrAstMatcher {
245    rules: Vec<AstRule>,
246    /// Per-file content digest for dedup: skip when `content` for
247    /// `file_path` hasn't changed since the last successful check.
248    /// Key is the file path as supplied by the caller; value is a
249    /// `DefaultHasher` digest of the file content at the time of the
250    /// last successful check.
251    seen_digests: HashMap<String, u64>,
252    matcher: Box<AstMatcherFn>,
253}
254
255impl std::fmt::Debug for TtsrAstMatcher {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        f.debug_struct("TtsrAstMatcher")
258            .field("rules_count", &self.rules.len())
259            .field("seen_digests_count", &self.seen_digests.len())
260            .finish_non_exhaustive()
261    }
262}
263
264impl TtsrAstMatcher {
265    /// Build a matcher with the default `sg`-backed pattern matcher.
266    pub fn new(rules: Vec<AstRule>) -> Self {
267        Self {
268            rules,
269            seen_digests: HashMap::new(),
270            matcher: default_sg_matcher(),
271        }
272    }
273
274    /// Build a matcher with a custom pattern function. Used by tests to
275    /// avoid requiring `sg` on PATH.
276    pub fn with_matcher(rules: Vec<AstRule>, matcher: Box<AstMatcherFn>) -> Self {
277        Self {
278            rules,
279            seen_digests: HashMap::new(),
280            matcher,
281        }
282    }
283
284    /// Number of registered rules (handy for diagnostics).
285    pub fn rule_count(&self) -> usize {
286        self.rules.len()
287    }
288
289    /// Drop every cached digest. Call when a session restarts so old
290    /// file snapshots don't suppress fresh matches.
291    pub fn clear_dedup(&mut self) {
292        self.seen_digests.clear();
293    }
294
295    /// Check one file's content against every AST rule and return the
296    /// name of the first rule whose pattern matches.
297    ///
298    /// Steps:
299    /// 1. Filter rules by `file_scope` glob (empty scope = match-all).
300    /// 2. Hash the content; skip if this file's digest hasn't changed.
301    /// 3. For each surviving rule, run the injected matcher.
302    /// 4. Return the first matching rule's name, or `None`.
303    ///
304    /// The function is infallible by design: any matcher error collapses
305    /// to "no match" rather than aborting the stream.
306    pub fn check_tool_snapshot(&mut self, file_path: &str, content: &str) -> Option<String> {
307        if self.rules.is_empty() {
308            return None;
309        }
310
311        // ── 1. Glob filter ────────────────────────────────────────
312        let candidates: Vec<&AstRule> = self
313            .rules
314            .iter()
315            .filter(|r| file_scope_matches(&r.file_scope, file_path))
316            .collect();
317
318        if candidates.is_empty() {
319            return None;
320        }
321
322        // ── 2. Digest dedup ────────────────────────────────────────
323        let digest = content_digest(content);
324        if self.seen_digests.get(file_path) == Some(&digest) {
325            return None;
326        }
327
328        // ── 3. Pattern match (first-hit wins) ──────────────────────
329        for rule in candidates {
330            if (self.matcher)(&rule.pattern, content) {
331                // Record digest only after a successful match so a
332                // non-matching snapshot doesn't poison subsequent
333                // edits to the same file.
334                self.seen_digests.insert(file_path.to_string(), digest);
335                return Some(rule.name.clone());
336            }
337        }
338
339        // No match this round; remember the digest so identical
340        // snapshots in later turns are skipped.
341        self.seen_digests.insert(file_path.to_string(), digest);
342        None
343    }
344}
345
346/// `DefaultHasher` digest of a string. Stable across runs of the same
347/// binary (uses the standard random seed) — dedup is process-local, so
348/// cross-run stability is unnecessary.
349fn content_digest(content: &str) -> u64 {
350    let mut hasher = std::collections::hash_map::DefaultHasher::new();
351    content.hash(&mut hasher);
352    hasher.finish()
353}
354
355/// Returns `true` if `file_path` matches any glob in `scope`. Empty
356/// scope means "match everything". Each glob is compiled once per call;
357/// invalid patterns are silently skipped (consistent with the existing
358/// `Rule::globs` handling in `scope_matches`).
359fn file_scope_matches(scope: &[String], file_path: &str) -> bool {
360    if scope.is_empty() {
361        return true;
362    }
363    scope.iter().any(|g| {
364        glob::Pattern::new(g)
365            .map(|p| p.matches(file_path))
366            .unwrap_or(false)
367    })
368}
369
370// ── Engine ──────────────────────────────────────────────────────────────────
371
372/// TTSR engine that buffers streaming deltas and checks them against
373/// registered rules.
374pub struct TtsrEngine {
375    rules: Arc<dyn RuleRegistry>,
376    /// Per-source accumulation buffers. Keys are source + optional tool name.
377    buffers: RwLock<HashMap<BufferKey, Vec<String>>>,
378    settings: TtsrSettings,
379    /// Optional AST matcher — when `Some`, tool deltas also evaluate
380    /// `ast_condition` against the supplied file contents.
381    ast_matcher: RwLock<Option<TtsrAstMatcher>>,
382}
383
384impl std::fmt::Debug for TtsrEngine {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        f.debug_struct("TtsrEngine")
387            .field("settings", &self.settings)
388            .finish_non_exhaustive()
389    }
390}
391
392/// Knobs for the TTSR engine.
393#[derive(Debug, Clone)]
394pub struct TtsrSettings {
395    /// Master on/off switch. When `false`, all checks are no-ops.
396    pub enabled: bool,
397    /// Default interrupt mode (overridden per-rule).
398    pub interrupt_mode: InterruptMode,
399    /// Whether the bundled builtin rules are activated.
400    pub builtin_rules: bool,
401    /// Safety cap: how many times a single turn can be interrupted.
402    pub max_retries_per_turn: u32,
403}
404
405impl Default for TtsrSettings {
406    fn default() -> Self {
407        Self {
408            enabled: false,
409            interrupt_mode: InterruptMode::ProseOnly,
410            builtin_rules: true,
411            max_retries_per_turn: 3,
412        }
413    }
414}
415
416impl TtsrEngine {
417    /// Create an engine backed by `rules`.
418    pub fn new(rules: Arc<dyn RuleRegistry>, settings: TtsrSettings) -> Self {
419        Self {
420            rules,
421            buffers: RwLock::new(HashMap::new()),
422            settings,
423            ast_matcher: RwLock::new(None),
424        }
425    }
426
427    /// Create an engine with an AST matcher pre-installed. The matcher
428    /// is queried on tool deltas with `file_contents` present.
429    pub fn with_ast_matcher(
430        rules: Arc<dyn RuleRegistry>,
431        settings: TtsrSettings,
432        ast_matcher: TtsrAstMatcher,
433    ) -> Self {
434        Self {
435            rules,
436            buffers: RwLock::new(HashMap::new()),
437            settings,
438            ast_matcher: RwLock::new(Some(ast_matcher)),
439        }
440    }
441
442    /// Install (or replace) the AST matcher after construction.
443    pub fn set_ast_matcher(&self, matcher: TtsrAstMatcher) {
444        *self.ast_matcher.write() = Some(matcher);
445    }
446
447    /// Drop any installed AST matcher. Subsequent tool deltas skip
448    /// AST evaluation.
449    pub fn clear_ast_matcher(&self) {
450        *self.ast_matcher.write() = None;
451    }
452
453    /// Clear all source buffers. Call at the start of each turn.
454    pub fn reset_buffers(&self) {
455        self.buffers.write().clear();
456    }
457
458    /// Append a streaming delta to the appropriate buffer and return any
459    /// rules whose conditions now match the accumulated text.
460    ///
461    /// This is called on every `ProviderEvent::Delta` while streaming.
462    pub fn check_delta(&self, delta: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
463        if !self.settings.enabled {
464            return vec![];
465        }
466
467        let key = self.buffer_key(ctx);
468        let mut buffers = self.buffers.write();
469        let buf = buffers.entry(key).or_default();
470        buf.push(delta.to_string());
471
472        // Join accumulated deltas into one string for matching.
473        let full: String = buf.concat();
474        let mut matched = self.match_buffer(&full, ctx);
475
476        // ── AST path (tool only) ─────────────────────────────────────
477        // AST conditions need file *content*, not streaming deltas. When
478        // the caller supplies file_contents, evaluate every (path, content)
479        // pair through the installed AST matcher and promote any matching
480        // rule to the returned set.
481        if matches!(ctx.source, MatchSource::Tool) && !ctx.file_contents.is_empty() {
482            let ast_matches = self.check_ast_against_contents(ctx);
483            for ast_match in ast_matches {
484                if !matched.iter().any(|r| r.name == ast_match.name) {
485                    matched.push(ast_match);
486                }
487            }
488        }
489
490        matched
491    }
492
493    /// Replace the buffer for `ctx` with a normalized snapshot (used when
494    /// tool output is available in a pre-parsed form).
495    pub fn check_snapshot(&self, snapshot: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
496        if !self.settings.enabled {
497            return vec![];
498        }
499
500        let key = self.buffer_key(ctx);
501        let mut buffers = self.buffers.write();
502        buffers.insert(key, vec![snapshot.to_string()]);
503
504        let mut matched = self.match_buffer(snapshot, ctx);
505
506        // ── AST path ────────────────────────────────────────────────
507        if !ctx.file_contents.is_empty() {
508            let ast_matches = self.check_ast_against_contents(ctx);
509            for ast_match in ast_matches {
510                if !matched.iter().any(|r| r.name == ast_match.name) {
511                    matched.push(ast_match);
512                }
513            }
514        }
515
516        matched
517    }
518
519    /// Return all injected rule records for compaction survival.
520    pub fn injected_records(&self) -> Vec<(String, u64)> {
521        self.rules.injected_records()
522    }
523
524    // ── Private ─────────────────────────────────────────────────────────
525
526    fn buffer_key(&self, ctx: &TtsrMatchContext) -> BufferKey {
527        BufferKey {
528            source: ctx.source,
529            tool_name: if matches!(ctx.source, MatchSource::Tool) {
530                ctx.tool_name.clone()
531            } else {
532                None
533            },
534        }
535    }
536
537    /// Evaluate AST conditions against every (file_path, content) pair
538    /// supplied by the caller. Resolves the rule body from the registry
539    /// so we have the rule's `content` and `interrupt_mode` for the
540    /// returned [`Rule`].
541    fn check_ast_against_contents(&self, ctx: &TtsrMatchContext) -> Vec<Rule> {
542        let mut guard = self.ast_matcher.write();
543        let matcher = match guard.as_mut() {
544            Some(m) => m,
545            None => return Vec::new(),
546        };
547
548        let mut matched = Vec::new();
549        for (path, content) in &ctx.file_contents {
550            if let Some(rule_name) = matcher.check_tool_snapshot(path, content)
551                && let Some(rule) = self.lookup_rule(&rule_name)
552            {
553                matched.push(rule);
554            }
555        }
556        matched
557    }
558
559    /// Look up a rule by name in the registry.
560    fn lookup_rule(&self, name: &str) -> Option<Rule> {
561        let rules: Vec<Rule> = futures::executor::block_on(self.rules.rules());
562        rules.into_iter().find(|r| r.name == name)
563    }
564
565    /// Walk every rule and return matching ones (the first match per rule
566    /// is sufficient to trigger an interrupt).
567    fn match_buffer(&self, buf: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
568        // Rules are cheap to clone (owned strings), but we collect all matches
569        // eagerly so the caller can inspect them without holding the lock.
570        let mut matched = Vec::new();
571
572        // Collect rules from the registry. We re-fetch each time because
573        // rules can be hot-reloaded at runtime.
574        let rules: Vec<Rule> = futures::executor::block_on(self.rules.rules());
575
576        for rule in rules {
577            // ── Scope filter ──
578            if !self.scope_matches(&rule, ctx) {
579                continue;
580            }
581
582            // ── Interrupt mode filter ──
583            let mode = if matches!(rule.interrupt_mode, InterruptMode::Never) {
584                self.settings.interrupt_mode
585            } else {
586                rule.interrupt_mode
587            };
588            if !self.mode_allows(mode, ctx.source) {
589                continue;
590            }
591
592            // ── Condition matching ──
593            if !rule.condition.iter().any(|re| re.is_match(buf)) {
594                continue;
595            }
596
597            matched.push(rule);
598        }
599
600        matched
601    }
602
603    /// Check whether the rule's scope tokens include the current context.
604    fn scope_matches(&self, rule: &Rule, ctx: &TtsrMatchContext) -> bool {
605        if rule.scope.is_empty() {
606            // No scope = applies everywhere.
607            return true;
608        }
609
610        for token in &rule.scope {
611            match token {
612                ScopeToken::Text => {
613                    if matches!(ctx.source, MatchSource::Text) {
614                        return true;
615                    }
616                }
617                ScopeToken::Thinking => {
618                    if matches!(ctx.source, MatchSource::Thinking) {
619                        return true;
620                    }
621                }
622                ScopeToken::Tool { name, globs } => {
623                    if !matches!(ctx.source, MatchSource::Tool) {
624                        continue;
625                    }
626                    if matches!(ctx.tool_name.as_ref(), Some(tool_name) if tool_name != name) {
627                        continue;
628                    }
629                    // If globs are specified, at least one must match a file path.
630                    if !globs.is_empty() {
631                        let any_match = ctx.file_paths.iter().any(|fp| {
632                            globs.iter().any(|g| {
633                                // Simple glob: suffix match.
634                                g.strip_suffix("/*")
635                                    .map(|prefix| fp.starts_with(prefix))
636                                    .unwrap_or_else(|| g == fp)
637                            })
638                        });
639                        if !any_match {
640                            continue;
641                        }
642                    }
643                    return true;
644                }
645            }
646        }
647
648        false
649    }
650
651    /// Check whether the interrupt mode permits firing on this source.
652    fn mode_allows(&self, mode: InterruptMode, source: MatchSource) -> bool {
653        match mode {
654            InterruptMode::Never => false,
655            InterruptMode::ProseOnly => matches!(source, MatchSource::Text),
656            InterruptMode::ToolOnly => matches!(source, MatchSource::Tool),
657            InterruptMode::Always => true,
658        }
659    }
660}
661
662// ── Tests ───────────────────────────────────────────────────────────────────
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use regex::Regex;
668    use std::pin::Pin;
669
670    /// Minimal RuleRegistry that returns a static set of rules.
671    struct StaticRegistry {
672        rules: Vec<Rule>,
673        injections: RwLock<Vec<(String, u64)>>,
674    }
675
676    impl RuleRegistry for StaticRegistry {
677        fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>> {
678            Box::pin(std::future::ready(self.rules.clone()))
679        }
680
681        fn mark_injected(&self, name: &str, turn: u64) {
682            self.injections.write().push((name.to_string(), turn));
683        }
684
685        fn injected_records(&self) -> Vec<(String, u64)> {
686            self.injections.read().clone()
687        }
688
689        fn restore(&self, records: Vec<(String, u64)>) {
690            *self.injections.write() = records;
691        }
692    }
693
694    fn make_rule(name: &str, pattern: &str) -> Rule {
695        Rule {
696            name: name.to_string(),
697            content: format!("Do not use {pattern}."),
698            description: Some(format!("Forbids {pattern}")),
699            condition: vec![Regex::new(pattern).unwrap()],
700            scope: vec![],
701            interrupt_mode: InterruptMode::ProseOnly,
702            globs: vec![],
703            always_apply: false,
704            source: RuleSource::BuiltinDefaults,
705            ast_condition: None,
706        }
707    }
708
709    /// Simple matcher for tests: substring match against the pattern
710    /// (with metacharacter-safe semantics). The pattern is treated as a
711    /// literal — tests only need to verify orchestration, not actual
712    /// ast-grep behavior.
713    fn substring_matcher(pattern: &str, content: &str) -> bool {
714        content.contains(pattern)
715    }
716
717    fn make_ast_rule(name: &str, pattern: &str, scope: Vec<String>) -> AstRule {
718        AstRule {
719            name: name.to_string(),
720            pattern: pattern.to_string(),
721            file_scope: scope,
722            interrupt_mode: InterruptMode::Always,
723        }
724    }
725
726    #[test]
727    fn test_check_delta_matches_simple_pattern() {
728        let rules = Arc::new(StaticRegistry {
729            rules: vec![make_rule("no-todo", r"TODO:")],
730            injections: RwLock::new(Vec::new()),
731        });
732
733        let engine = TtsrEngine::new(
734            rules,
735            TtsrSettings {
736                enabled: true,
737                ..Default::default()
738            },
739        );
740
741        let ctx = TtsrMatchContext {
742            source: MatchSource::Text,
743            file_paths: vec![],
744            tool_name: None,
745            file_contents: vec![],
746        };
747
748        // First delta — no match yet.
749        let results = engine.check_delta("This code is almost ", &ctx);
750        assert!(results.is_empty());
751
752        // Second delta triggers the rule.
753        let results = engine.check_delta("TODO: fix later", &ctx);
754        assert_eq!(results.len(), 1);
755        assert_eq!(results[0].name, "no-todo");
756    }
757
758    #[test]
759    fn test_check_delta_respects_disabled() {
760        let rules = Arc::new(StaticRegistry {
761            rules: vec![make_rule("no-todo", r"TODO:")],
762            injections: RwLock::new(Vec::new()),
763        });
764
765        let engine = TtsrEngine::new(
766            rules,
767            TtsrSettings {
768                enabled: false, // DISABLED
769                ..Default::default()
770            },
771        );
772
773        let ctx = TtsrMatchContext {
774            source: MatchSource::Text,
775            file_paths: vec![],
776            tool_name: None,
777            file_contents: vec![],
778        };
779
780        let results = engine.check_delta("TODO: fix later", &ctx);
781        assert!(results.is_empty(), "disabled engine must return no matches");
782    }
783
784    #[test]
785    fn test_scope_filter_respects_tool_scope() {
786        let rules = Arc::new(StaticRegistry {
787            rules: vec![Rule {
788                name: "edit-only-rule".to_string(),
789                content: "Only for edit tool".to_string(),
790                description: None,
791                condition: vec![Regex::new("bad").unwrap()],
792                scope: vec![ScopeToken::Tool {
793                    name: "edit".to_string(),
794                    globs: vec![],
795                }],
796                interrupt_mode: InterruptMode::Always,
797                globs: vec![],
798                always_apply: false,
799                source: RuleSource::BuiltinDefaults,
800                ast_condition: None,
801            }],
802            injections: RwLock::new(Vec::new()),
803        });
804
805        let engine = TtsrEngine::new(
806            rules,
807            TtsrSettings {
808                enabled: true,
809                ..Default::default()
810            },
811        );
812
813        // Text source — scope doesn't match.
814        let text_ctx = TtsrMatchContext {
815            source: MatchSource::Text,
816            file_paths: vec![],
817            tool_name: None,
818            file_contents: vec![],
819        };
820        assert!(engine.check_delta("bad code", &text_ctx).is_empty());
821
822        // Tool source matching "edit" — matches.
823        let tool_ctx = TtsrMatchContext {
824            source: MatchSource::Tool,
825            file_paths: vec![],
826            tool_name: Some("edit".to_string()),
827            file_contents: vec![],
828        };
829        assert!(!engine.check_delta("bad code", &tool_ctx).is_empty());
830
831        // Tool source but wrong tool name — no match.
832        let write_ctx = TtsrMatchContext {
833            source: MatchSource::Tool,
834            file_paths: vec![],
835            tool_name: Some("write".to_string()),
836            file_contents: vec![],
837        };
838        assert!(engine.check_delta("bad code", &write_ctx).is_empty());
839    }
840
841    #[test]
842    fn test_reset_buffers_clears_accumulation() {
843        let rules = Arc::new(StaticRegistry {
844            rules: vec![make_rule("no-todo", r"TODO:")],
845            injections: RwLock::new(Vec::new()),
846        });
847
848        let engine = TtsrEngine::new(
849            rules,
850            TtsrSettings {
851                enabled: true,
852                ..Default::default()
853            },
854        );
855
856        let ctx = TtsrMatchContext {
857            source: MatchSource::Text,
858            file_paths: vec![],
859            tool_name: None,
860            file_contents: vec![],
861        };
862
863        // Accumulate "TODO" in buffer.
864        engine.check_delta("TODO", &ctx);
865        // Reset should clear it.
866        engine.reset_buffers();
867
868        // Now ":" alone shouldn't match because "TODO" was cleared.
869        let results = engine.check_delta(":", &ctx);
870        assert!(results.is_empty(), "buffer was reset — TODO should be gone");
871    }
872
873    #[test]
874    fn test_prose_only_mode_ignores_tool_source() {
875        let rules = Arc::new(StaticRegistry {
876            rules: vec![make_rule("no-bad", r"bad")],
877            injections: RwLock::new(Vec::new()),
878        });
879
880        let engine = TtsrEngine::new(
881            rules,
882            TtsrSettings {
883                enabled: true,
884                interrupt_mode: InterruptMode::ProseOnly,
885                ..Default::default()
886            },
887        );
888
889        // Text source — allowed.
890        let text_ctx = TtsrMatchContext {
891            source: MatchSource::Text,
892            file_paths: vec![],
893            tool_name: None,
894            file_contents: vec![],
895        };
896        assert!(!engine.check_delta("bad code", &text_ctx).is_empty());
897
898        // Tool source — blocked by ProseOnly mode.
899        let tool_ctx = TtsrMatchContext {
900            source: MatchSource::Tool,
901            file_paths: vec![],
902            tool_name: Some("edit".to_string()),
903            file_contents: vec![],
904        };
905        assert!(engine.check_delta("bad code", &tool_ctx).is_empty());
906    }
907
908    // ── AST matcher tests ──────────────────────────────────────────────
909
910    #[test]
911    fn test_ast_match_detects_pattern() {
912        // Register a single AST rule: forbid `Box::leak` in any `.rs` file.
913        let ast_rules = vec![make_ast_rule(
914            "no-box-leak",
915            "Box::leak",
916            vec!["*.rs".to_string()],
917        )];
918
919        let mut matcher = TtsrAstMatcher::with_matcher(ast_rules, Box::new(substring_matcher));
920
921        let content = "fn main() {\n    let _ = Box::leak(Box::new(0));\n}\n";
922        let result = matcher.check_tool_snapshot("src/main.rs", content);
923        assert_eq!(result.as_deref(), Some("no-box-leak"));
924    }
925
926    #[test]
927    fn test_ast_match_no_false_positive() {
928        // The pattern is NOT present — matcher must return None.
929        let ast_rules = vec![make_ast_rule(
930            "no-box-leak",
931            "Box::leak",
932            vec!["*.rs".to_string()],
933        )];
934
935        let mut matcher = TtsrAstMatcher::with_matcher(ast_rules, Box::new(substring_matcher));
936
937        let content = "fn main() {\n    println!(\"clean code\");\n}\n";
938        let result = matcher.check_tool_snapshot("src/main.rs", content);
939        assert!(result.is_none(), "pattern absent — must not match");
940
941        // Dedup: identical content on a re-check still returns None
942        // (digest unchanged) — proves dedup doesn't suppress the first
943        // non-match either, and is stable on repeat.
944        let result = matcher.check_tool_snapshot("src/main.rs", content);
945        assert!(result.is_none());
946
947        // Edited content with no pattern still returns None.
948        let edited = "fn main() {\n    println!(\"clean code v2\");\n}\n";
949        let result = matcher.check_tool_snapshot("src/main.rs", edited);
950        assert!(result.is_none());
951    }
952
953    #[test]
954    fn test_ast_match_respects_file_scope() {
955        // Two rules with disjoint scopes; only the matching-scope one fires.
956        let ast_rules = vec![
957            make_ast_rule("no-rs-leak", "Box::leak", vec!["*.rs".to_string()]),
958            make_ast_rule("no-ts-leak", "Box::leak", vec!["*.ts".to_string()]),
959        ];
960
961        let mut matcher = TtsrAstMatcher::with_matcher(ast_rules, Box::new(substring_matcher));
962
963        // `.ts` file containing the pattern — `no-ts-leak` must fire,
964        // `no-rs-leak` must not (because the `.rs` scope excludes it).
965        let ts_content = "export const x = Box::leak(new Object());\n";
966        let result = matcher.check_tool_snapshot("app/index.ts", ts_content);
967        assert_eq!(result.as_deref(), Some("no-ts-leak"));
968
969        // A `.md` file — neither rule's scope matches, so nothing fires.
970        let md_content = "Documentation note: Box::leak is forbidden.\n";
971        let result = matcher.check_tool_snapshot("docs/notes.md", md_content);
972        assert!(
973            result.is_none(),
974            "scope filter must exclude out-of-scope files"
975        );
976
977        // Empty-scope rule = match-all. Confirms the "empty scope = all"
978        // semantic that `Rule::globs` already provides for regex.
979        let mut permissive = TtsrAstMatcher::with_matcher(
980            vec![make_ast_rule("global", "forbidden-token", vec![])],
981            Box::new(substring_matcher),
982        );
983        let result = permissive.check_tool_snapshot("any/path.xyz", "has forbidden-token here");
984        assert_eq!(result.as_deref(), Some("global"));
985    }
986
987    #[test]
988    fn test_engine_ast_integration_via_tool_delta() {
989        // End-to-end: AST match found via `check_delta` on a Tool source
990        // with `file_contents` populated. Uses a registry that returns a
991        // rule by name so the engine can resolve the matched AST rule
992        // back to a full `Rule` for the caller.
993        let registry_rules = vec![Rule {
994            name: "no-box-leak".to_string(),
995            content: "Do not call Box::leak.".to_string(),
996            description: None,
997            condition: vec![],
998            scope: vec![ScopeToken::Tool {
999                name: "write".to_string(),
1000                globs: vec![],
1001            }],
1002            interrupt_mode: InterruptMode::Always,
1003            globs: vec![],
1004            always_apply: false,
1005            source: RuleSource::BuiltinDefaults,
1006            ast_condition: Some("Box::leak".to_string()),
1007        }];
1008        let registry: Arc<dyn RuleRegistry> = Arc::new(StaticRegistry {
1009            rules: registry_rules,
1010            injections: RwLock::new(Vec::new()),
1011        });
1012
1013        let ast_matcher = TtsrAstMatcher::with_matcher(
1014            vec![make_ast_rule(
1015                "no-box-leak",
1016                "Box::leak",
1017                vec!["*.rs".to_string()],
1018            )],
1019            Box::new(substring_matcher),
1020        );
1021
1022        let engine = TtsrEngine::with_ast_matcher(
1023            registry,
1024            TtsrSettings {
1025                enabled: true,
1026                ..Default::default()
1027            },
1028            ast_matcher,
1029        );
1030
1031        let ctx = TtsrMatchContext {
1032            source: MatchSource::Tool,
1033            file_paths: vec!["src/main.rs".to_string()],
1034            tool_name: Some("write".to_string()),
1035            file_contents: vec![(
1036                "src/main.rs".to_string(),
1037                "fn main() { let _ = Box::leak(Box::new(1)); }\n".to_string(),
1038            )],
1039        };
1040
1041        let matched = engine.check_delta("editing src/main.rs", &ctx);
1042        assert_eq!(matched.len(), 1);
1043        assert_eq!(matched[0].name, "no-box-leak");
1044    }
1045}