1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum InterruptMode {
30 Never,
32 #[default]
34 ProseOnly,
35 ToolOnly,
37 Always,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ScopeToken {
44 Text,
46 Thinking,
48 Tool {
50 name: String,
52 globs: Vec<String>,
54 },
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum RuleSource {
60 BuiltinDefaults,
62 Project,
64 User,
66}
67#[derive(Debug, Clone)]
69pub struct Rule {
70 pub name: String,
72 pub content: String,
74 pub description: Option<String>,
76 pub condition: Vec<regex::Regex>,
78 pub scope: Vec<ScopeToken>,
80 pub interrupt_mode: InterruptMode,
82 pub globs: Vec<String>,
84 pub always_apply: bool,
86 pub source: RuleSource,
88 pub ast_condition: Option<String>,
93}
94
95pub trait RuleRegistry: Send + Sync + 'static {
100 fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>>;
102
103 fn mark_injected(&self, _name: &str, _turn: u64) {}
105
106 fn injected_records(&self) -> Vec<(String, u64)> {
108 vec![]
109 }
110
111 fn restore(&self, _records: Vec<(String, u64)>) {}
113}
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub enum MatchSource {
117 Text,
119 Thinking,
121 Tool,
123}
124
125#[derive(Debug, Clone, Hash, PartialEq, Eq)]
127struct BufferKey {
128 source: MatchSource,
129 tool_name: Option<String>,
131}
132
133#[derive(Debug, Clone)]
138pub struct TtsrMatchContext {
139 pub source: MatchSource,
141 pub file_paths: Vec<String>,
143 pub tool_name: Option<String>,
145 pub file_contents: Vec<(String, String)>,
151}
152
153#[derive(Debug, Clone)]
160pub struct AstRule {
161 pub name: String,
163 pub pattern: String,
165 pub file_scope: Vec<String>,
168 pub interrupt_mode: InterruptMode,
170}
171
172pub type AstMatcherFn = dyn Fn(&str, &str) -> bool + Send + Sync;
180
181fn default_sg_matcher() -> Box<AstMatcherFn> {
186 Box::new(|pattern: &str, content: &str| {
187 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 let _ = std::fs::remove_file(&tmp);
207 matched
208 })
209}
210
211fn 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 Ok(!output.stdout.is_empty())
239}
240
241pub struct TtsrAstMatcher {
245 rules: Vec<AstRule>,
246 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 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 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 pub fn rule_count(&self) -> usize {
286 self.rules.len()
287 }
288
289 pub fn clear_dedup(&mut self) {
292 self.seen_digests.clear();
293 }
294
295 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 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 let digest = content_digest(content);
324 if self.seen_digests.get(file_path) == Some(&digest) {
325 return None;
326 }
327
328 for rule in candidates {
330 if (self.matcher)(&rule.pattern, content) {
331 self.seen_digests.insert(file_path.to_string(), digest);
335 return Some(rule.name.clone());
336 }
337 }
338
339 self.seen_digests.insert(file_path.to_string(), digest);
342 None
343 }
344}
345
346fn 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
355fn 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
370pub struct TtsrEngine {
375 rules: Arc<dyn RuleRegistry>,
376 buffers: RwLock<HashMap<BufferKey, Vec<String>>>,
378 settings: TtsrSettings,
379 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#[derive(Debug, Clone)]
394pub struct TtsrSettings {
395 pub enabled: bool,
397 pub interrupt_mode: InterruptMode,
399 pub builtin_rules: bool,
401 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 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 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 pub fn set_ast_matcher(&self, matcher: TtsrAstMatcher) {
444 *self.ast_matcher.write() = Some(matcher);
445 }
446
447 pub fn clear_ast_matcher(&self) {
450 *self.ast_matcher.write() = None;
451 }
452
453 pub fn reset_buffers(&self) {
455 self.buffers.write().clear();
456 }
457
458 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 let full: String = buf.concat();
474 let mut matched = self.match_buffer(&full, ctx);
475
476 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 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 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 pub fn injected_records(&self) -> Vec<(String, u64)> {
521 self.rules.injected_records()
522 }
523
524 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 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 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 fn match_buffer(&self, buf: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
568 let mut matched = Vec::new();
571
572 let rules: Vec<Rule> = futures::executor::block_on(self.rules.rules());
575
576 for rule in rules {
577 if !self.scope_matches(&rule, ctx) {
579 continue;
580 }
581
582 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 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 fn scope_matches(&self, rule: &Rule, ctx: &TtsrMatchContext) -> bool {
605 if rule.scope.is_empty() {
606 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.is_empty() {
631 let any_match = ctx.file_paths.iter().any(|fp| {
632 globs.iter().any(|g| {
633 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 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#[cfg(test)]
665mod tests {
666 use super::*;
667 use regex::Regex;
668 use std::pin::Pin;
669
670 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 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 let results = engine.check_delta("This code is almost ", &ctx);
750 assert!(results.is_empty());
751
752 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, ..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 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 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 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 engine.check_delta("TODO", &ctx);
865 engine.reset_buffers();
867
868 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 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 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 #[test]
911 fn test_ast_match_detects_pattern() {
912 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 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 let result = matcher.check_tool_snapshot("src/main.rs", content);
945 assert!(result.is_none());
946
947 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 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 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 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 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 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}