1use crate::{DrivenError, Result};
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SteeringRule {
38 pub id: String,
40 pub name: String,
42 pub inclusion: SteeringInclusion,
44 pub content: String,
46 pub file_references: Vec<FileReference>,
48 pub priority: u8,
50 pub source_path: Option<PathBuf>,
52}
53
54impl SteeringRule {
55 pub fn new(id: impl Into<String>) -> Self {
57 let id = id.into();
58 Self {
59 name: id.clone(),
60 id,
61 inclusion: SteeringInclusion::Always,
62 content: String::new(),
63 file_references: Vec::new(),
64 priority: 100,
65 source_path: None,
66 }
67 }
68
69 pub fn with_name(mut self, name: impl Into<String>) -> Self {
71 self.name = name.into();
72 self
73 }
74
75 pub fn with_inclusion(mut self, inclusion: SteeringInclusion) -> Self {
77 self.inclusion = inclusion;
78 self
79 }
80
81 pub fn with_content(mut self, content: impl Into<String>) -> Self {
83 self.content = content.into();
84 self
85 }
86
87 pub fn with_priority(mut self, priority: u8) -> Self {
89 self.priority = priority;
90 self
91 }
92
93 pub fn with_source_path(mut self, path: impl Into<PathBuf>) -> Self {
95 self.source_path = Some(path.into());
96 self
97 }
98
99 pub fn with_file_reference(mut self, reference: FileReference) -> Self {
101 self.file_references.push(reference);
102 self
103 }
104
105 pub fn applies_to(&self, context: &AgentContext) -> bool {
107 match &self.inclusion {
108 SteeringInclusion::Always => true,
109 SteeringInclusion::FileMatch { pattern } => {
110 if let Some(file_path) = &context.file_path {
111 glob_match(pattern, &file_path.to_string_lossy())
112 } else {
113 false
114 }
115 }
116 SteeringInclusion::Manual { key } => context.manual_keys.contains(key),
117 }
118 }
119
120 pub fn resolved_content(&self) -> String {
122 let mut content = self.content.clone();
123
124 for reference in &self.file_references {
125 if let Some(resolved) = &reference.resolved_content {
126 content = content.replace(&reference.syntax, resolved);
127 }
128 }
129
130 content
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136#[serde(tag = "type", rename_all = "snake_case")]
137pub enum SteeringInclusion {
138 Always,
140 FileMatch {
142 pattern: String,
144 },
145 Manual {
147 key: String,
149 },
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct FileReference {
155 pub syntax: String,
157 pub path: PathBuf,
159 pub resolved_content: Option<String>,
161}
162
163impl FileReference {
164 pub fn new(syntax: impl Into<String>, path: impl Into<PathBuf>) -> Self {
166 Self {
167 syntax: syntax.into(),
168 path: path.into(),
169 resolved_content: None,
170 }
171 }
172
173 pub fn with_resolved_content(mut self, content: impl Into<String>) -> Self {
175 self.resolved_content = Some(content.into());
176 self
177 }
178}
179
180#[derive(Debug, Clone, Default)]
182pub struct AgentContext {
183 pub file_path: Option<PathBuf>,
185 pub directory: Option<PathBuf>,
187 pub manual_keys: Vec<String>,
189 pub variables: HashMap<String, String>,
191}
192
193impl AgentContext {
194 pub fn new() -> Self {
196 Self::default()
197 }
198
199 pub fn with_file(mut self, path: impl Into<PathBuf>) -> Self {
201 self.file_path = Some(path.into());
202 self
203 }
204
205 pub fn with_directory(mut self, path: impl Into<PathBuf>) -> Self {
207 self.directory = Some(path.into());
208 self
209 }
210
211 pub fn with_manual_key(mut self, key: impl Into<String>) -> Self {
213 self.manual_keys.push(key.into());
214 self
215 }
216
217 pub fn with_variable(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
219 self.variables.insert(key.into(), value.into());
220 self
221 }
222}
223
224pub struct SteeringEngine {
226 rules: Vec<SteeringRule>,
228 steering_dir: PathBuf,
230 inheritance_cache: HashMap<PathBuf, Vec<String>>,
232}
233
234impl SteeringEngine {
235 pub fn new() -> Self {
237 Self {
238 rules: Vec::new(),
239 steering_dir: PathBuf::from(".driven/steering"),
240 inheritance_cache: HashMap::new(),
241 }
242 }
243
244 pub fn with_steering_dir(steering_dir: impl Into<PathBuf>) -> Self {
246 Self {
247 rules: Vec::new(),
248 steering_dir: steering_dir.into(),
249 inheritance_cache: HashMap::new(),
250 }
251 }
252
253 pub fn register_rule(&mut self, rule: SteeringRule) -> Result<()> {
255 if self.rules.iter().any(|r| r.id == rule.id) {
257 return Err(DrivenError::Config(format!(
258 "Steering rule with ID '{}' already exists",
259 rule.id
260 )));
261 }
262
263 self.rules.push(rule);
264
265 self.rules.sort_by_key(|r| r.priority);
267
268 Ok(())
269 }
270
271 pub fn unregister_rule(&mut self, id: &str) -> Result<SteeringRule> {
273 let pos = self.rules.iter().position(|r| r.id == id).ok_or_else(|| {
274 DrivenError::Config(format!("Steering rule with ID '{}' not found", id))
275 })?;
276
277 self.inheritance_cache.clear();
279
280 Ok(self.rules.remove(pos))
281 }
282
283 pub fn get_rule(&self, id: &str) -> Option<&SteeringRule> {
285 self.rules.iter().find(|r| r.id == id)
286 }
287
288 pub fn get_rule_mut(&mut self, id: &str) -> Option<&mut SteeringRule> {
290 self.rules.iter_mut().find(|r| r.id == id)
291 }
292
293 pub fn list_rules(&self) -> &[SteeringRule] {
295 &self.rules
296 }
297
298 pub fn get_rules_for_context(&self, context: &AgentContext) -> Vec<&SteeringRule> {
300 self.rules
301 .iter()
302 .filter(|r| r.applies_to(context))
303 .collect()
304 }
305
306 pub fn load_steering(&mut self, path: &Path) -> Result<usize> {
308 if !path.exists() {
309 return Ok(0);
310 }
311
312 let mut count = 0;
313
314 for entry in std::fs::read_dir(path).map_err(DrivenError::Io)? {
315 let entry = entry.map_err(DrivenError::Io)?;
316 let path = entry.path();
317
318 if path.extension().is_some_and(|e| e == "md") {
319 match self.load_steering_file(&path) {
320 Ok(rule) => {
321 if let Err(e) = self.register_rule(rule) {
322 tracing::warn!(
323 "Failed to register steering rule from {:?}: {}",
324 path,
325 e
326 );
327 } else {
328 count += 1;
329 }
330 }
331 Err(e) => {
332 tracing::warn!("Failed to load steering rule from {:?}: {}", path, e);
333 }
334 }
335 }
336 }
337
338 Ok(count)
339 }
340
341 fn load_steering_file(&self, path: &Path) -> Result<SteeringRule> {
343 let content = std::fs::read_to_string(path).map_err(DrivenError::Io)?;
344
345 let (front_matter, body) = Self::parse_front_matter(&content)?;
347
348 let id = path
350 .file_stem()
351 .and_then(|s| s.to_str())
352 .unwrap_or("unknown")
353 .to_string();
354
355 let inclusion = Self::parse_inclusion(&front_matter)?;
357
358 let priority = front_matter
360 .get("priority")
361 .and_then(|v| v.parse::<u8>().ok())
362 .unwrap_or(100);
363
364 let file_references = Self::parse_file_references(&body);
366
367 let name = front_matter
369 .get("name")
370 .cloned()
371 .unwrap_or_else(|| id.clone());
372
373 Ok(SteeringRule {
374 id,
375 name,
376 inclusion,
377 content: body,
378 file_references,
379 priority,
380 source_path: Some(path.to_path_buf()),
381 })
382 }
383
384 fn parse_front_matter(content: &str) -> Result<(HashMap<String, String>, String)> {
386 let content = content.trim();
387
388 if !content.starts_with("---") {
389 return Ok((HashMap::new(), content.to_string()));
390 }
391
392 let rest = &content[3..];
394 let end_pos = rest
395 .find("---")
396 .ok_or_else(|| DrivenError::Parse("Unclosed front matter".to_string()))?;
397
398 let front_matter_str = &rest[..end_pos].trim();
399 let body = rest[end_pos + 3..].trim().to_string();
400
401 let mut front_matter = HashMap::new();
403 for line in front_matter_str.lines() {
404 let line = line.trim();
405 if line.is_empty() || line.starts_with('#') {
406 continue;
407 }
408
409 if let Some((key, value)) = line.split_once(':') {
410 let key = key.trim().to_string();
411 let value = value
412 .trim()
413 .trim_matches('"')
414 .trim_matches('\'')
415 .to_string();
416 front_matter.insert(key, value);
417 }
418 }
419
420 Ok((front_matter, body))
421 }
422
423 fn parse_inclusion(front_matter: &HashMap<String, String>) -> Result<SteeringInclusion> {
425 let inclusion_type = front_matter
426 .get("inclusion")
427 .map(|s| s.as_str())
428 .unwrap_or("always");
429
430 match inclusion_type.to_lowercase().as_str() {
431 "always" => Ok(SteeringInclusion::Always),
432 "filematch" | "file_match" => {
433 let pattern = front_matter
434 .get("fileMatchPattern")
435 .or_else(|| front_matter.get("file_match_pattern"))
436 .or_else(|| front_matter.get("pattern"))
437 .ok_or_else(|| {
438 DrivenError::Parse(
439 "fileMatch inclusion requires fileMatchPattern".to_string(),
440 )
441 })?;
442 Ok(SteeringInclusion::FileMatch {
443 pattern: pattern.clone(),
444 })
445 }
446 "manual" => {
447 let key = front_matter
448 .get("key")
449 .or_else(|| front_matter.get("manualKey"))
450 .ok_or_else(|| {
451 DrivenError::Parse("manual inclusion requires key".to_string())
452 })?;
453 Ok(SteeringInclusion::Manual { key: key.clone() })
454 }
455 _ => Err(DrivenError::Parse(format!(
456 "Unknown inclusion type: {}. Valid: always, fileMatch, manual",
457 inclusion_type
458 ))),
459 }
460 }
461
462 fn parse_file_references(content: &str) -> Vec<FileReference> {
464 let mut references = Vec::new();
465
466 let pattern = "#[[file:";
468 let mut pos = 0;
469
470 while let Some(start) = content[pos..].find(pattern) {
471 let abs_start = pos + start;
472 let ref_start = abs_start + pattern.len();
473
474 if let Some(end) = content[ref_start..].find("]]") {
475 let path_str = &content[ref_start..ref_start + end];
476 let syntax = format!("#[[file:{}]]", path_str);
477
478 references.push(FileReference::new(syntax, PathBuf::from(path_str)));
479
480 pos = ref_start + end + 2;
481 } else {
482 break;
483 }
484 }
485
486 references
487 }
488
489 pub fn resolve_file_references(&self, rule: &mut SteeringRule, base_path: &Path) -> Result<()> {
491 for reference in &mut rule.file_references {
492 let full_path = if reference.path.is_absolute() {
493 reference.path.clone()
494 } else {
495 base_path.join(&reference.path)
496 };
497
498 match std::fs::read_to_string(&full_path) {
499 Ok(content) => {
500 reference.resolved_content = Some(content);
501 }
502 Err(e) => {
503 tracing::warn!("Failed to resolve file reference {:?}: {}", full_path, e);
504 }
505 }
506 }
507
508 Ok(())
509 }
510
511 pub fn resolve_all_file_references(&mut self, base_path: &Path) -> Result<()> {
513 for rule in &mut self.rules {
514 for reference in &mut rule.file_references {
515 let full_path = if reference.path.is_absolute() {
516 reference.path.clone()
517 } else {
518 base_path.join(&reference.path)
519 };
520
521 match std::fs::read_to_string(&full_path) {
522 Ok(content) => {
523 reference.resolved_content = Some(content);
524 }
525 Err(e) => {
526 tracing::warn!("Failed to resolve file reference {:?}: {}", full_path, e);
527 }
528 }
529 }
530 }
531
532 Ok(())
533 }
534
535 pub fn get_rules_with_inheritance(&self, directory: &Path) -> Vec<&SteeringRule> {
537 let mut applicable_rules: Vec<&SteeringRule> = Vec::new();
538 let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
539
540 let mut current = Some(directory);
542 while let Some(dir) = current {
543 let steering_dir = dir.join(".driven/steering");
545
546 for rule in &self.rules {
548 if let Some(source) = &rule.source_path {
549 if source.starts_with(&steering_dir) && !seen_ids.contains(rule.id.as_str()) {
550 applicable_rules.push(rule);
551 seen_ids.insert(&rule.id);
552 }
553 }
554 }
555
556 current = dir.parent();
557 }
558
559 applicable_rules.sort_by_key(|r| r.priority);
561
562 applicable_rules
563 }
564
565 pub fn inject_into_context(&self, context: &AgentContext) -> String {
567 let rules = self.get_rules_for_context(context);
568
569 let mut output = String::new();
570
571 for rule in rules {
572 if !output.is_empty() {
573 output.push_str("\n\n---\n\n");
574 }
575 output.push_str(&rule.resolved_content());
576 }
577
578 output
579 }
580
581 pub fn save_rule(&self, rule: &SteeringRule, path: &Path) -> Result<()> {
583 let mut content = String::new();
584
585 content.push_str("---\n");
587 content.push_str(&format!("name: \"{}\"\n", rule.name));
588
589 match &rule.inclusion {
590 SteeringInclusion::Always => {
591 content.push_str("inclusion: always\n");
592 }
593 SteeringInclusion::FileMatch { pattern } => {
594 content.push_str("inclusion: fileMatch\n");
595 content.push_str(&format!("fileMatchPattern: \"{}\"\n", pattern));
596 }
597 SteeringInclusion::Manual { key } => {
598 content.push_str("inclusion: manual\n");
599 content.push_str(&format!("key: \"{}\"\n", key));
600 }
601 }
602
603 content.push_str(&format!("priority: {}\n", rule.priority));
604 content.push_str("---\n\n");
605
606 content.push_str(&rule.content);
608
609 if let Some(parent) = path.parent() {
611 std::fs::create_dir_all(parent).map_err(DrivenError::Io)?;
612 }
613
614 std::fs::write(path, content).map_err(DrivenError::Io)?;
615
616 Ok(())
617 }
618
619 pub fn steering_dir(&self) -> &Path {
621 &self.steering_dir
622 }
623}
624
625impl Default for SteeringEngine {
626 fn default() -> Self {
627 Self::new()
628 }
629}
630
631fn glob_match(pattern: &str, text: &str) -> bool {
633 if pattern.contains("**") {
635 let parts: Vec<&str> = pattern.split("**").collect();
636 if parts.len() == 2 {
637 let prefix = parts[0].trim_end_matches('/');
638 let suffix = parts[1].trim_start_matches('/');
639
640 if !prefix.is_empty() && !text.starts_with(prefix) {
641 return false;
642 }
643 if !suffix.is_empty() && !glob_match(suffix, text.rsplit('/').next().unwrap_or(text)) {
644 return false;
645 }
646 return true;
647 }
648 }
649
650 if pattern.contains('*') && !pattern.contains("**") {
652 let parts: Vec<&str> = pattern.split('*').collect();
653 let mut pos = 0;
654
655 for (i, part) in parts.iter().enumerate() {
656 if part.is_empty() {
657 continue;
658 }
659
660 if i == 0 {
661 if !text.starts_with(part) {
663 return false;
664 }
665 pos = part.len();
666 } else if i == parts.len() - 1 {
667 if !text.ends_with(part) {
669 return false;
670 }
671 } else {
672 if let Some(found) = text[pos..].find(part) {
674 pos += found + part.len();
675 } else {
676 return false;
677 }
678 }
679 }
680
681 return true;
682 }
683
684 pattern == text
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691
692 #[test]
693 fn test_steering_rule_creation() {
694 let rule = SteeringRule::new("test-rule")
695 .with_name("Test Rule")
696 .with_inclusion(SteeringInclusion::Always)
697 .with_content("# Test Content")
698 .with_priority(50);
699
700 assert_eq!(rule.id, "test-rule");
701 assert_eq!(rule.name, "Test Rule");
702 assert_eq!(rule.content, "# Test Content");
703 assert_eq!(rule.priority, 50);
704 }
705
706 #[test]
707 fn test_always_inclusion() {
708 let rule = SteeringRule::new("always-rule").with_inclusion(SteeringInclusion::Always);
709
710 let context = AgentContext::new();
711 assert!(rule.applies_to(&context));
712
713 let context_with_file = AgentContext::new().with_file(Path::new("src/main.rs"));
714 assert!(rule.applies_to(&context_with_file));
715 }
716
717 #[test]
718 fn test_file_match_inclusion() {
719 let rule = SteeringRule::new("rust-rule").with_inclusion(SteeringInclusion::FileMatch {
720 pattern: "**/*.rs".to_string(),
721 });
722
723 let rust_context = AgentContext::new().with_file(Path::new("src/main.rs"));
724 assert!(rule.applies_to(&rust_context));
725
726 let python_context = AgentContext::new().with_file(Path::new("src/main.py"));
727 assert!(!rule.applies_to(&python_context));
728
729 let no_file_context = AgentContext::new();
730 assert!(!rule.applies_to(&no_file_context));
731 }
732
733 #[test]
734 fn test_manual_inclusion() {
735 let rule = SteeringRule::new("manual-rule").with_inclusion(SteeringInclusion::Manual {
736 key: "rust-style".to_string(),
737 });
738
739 let context_without_key = AgentContext::new();
740 assert!(!rule.applies_to(&context_without_key));
741
742 let context_with_key = AgentContext::new().with_manual_key("rust-style");
743 assert!(rule.applies_to(&context_with_key));
744
745 let context_with_wrong_key = AgentContext::new().with_manual_key("python-style");
746 assert!(!rule.applies_to(&context_with_wrong_key));
747 }
748
749 #[test]
750 fn test_file_reference_parsing() {
751 let content = r#"
752# Rust Style Guide
753
754Follow these rules:
755
756#[[file:docs/rust-style.md]]
757
758Also see:
759
760#[[file:Cargo.toml]]
761"#;
762
763 let references = SteeringEngine::parse_file_references(content);
764 assert_eq!(references.len(), 2);
765 assert_eq!(references[0].path, PathBuf::from("docs/rust-style.md"));
766 assert_eq!(references[1].path, PathBuf::from("Cargo.toml"));
767 }
768
769 #[test]
770 fn test_front_matter_parsing() {
771 let content = r#"---
772name: "Rust Standards"
773inclusion: fileMatch
774fileMatchPattern: "**/*.rs"
775priority: 10
776---
777
778# Rust Code Standards
779
780Use these standards when writing Rust code.
781"#;
782
783 let (front_matter, body) = SteeringEngine::parse_front_matter(content).unwrap();
784
785 assert_eq!(
786 front_matter.get("name"),
787 Some(&"Rust Standards".to_string())
788 );
789 assert_eq!(
790 front_matter.get("inclusion"),
791 Some(&"fileMatch".to_string())
792 );
793 assert_eq!(
794 front_matter.get("fileMatchPattern"),
795 Some(&"**/*.rs".to_string())
796 );
797 assert_eq!(front_matter.get("priority"), Some(&"10".to_string()));
798 assert!(body.contains("# Rust Code Standards"));
799 }
800
801 #[test]
802 fn test_front_matter_parsing_no_front_matter() {
803 let content = "# Just Content\n\nNo front matter here.";
804
805 let (front_matter, body) = SteeringEngine::parse_front_matter(content).unwrap();
806
807 assert!(front_matter.is_empty());
808 assert_eq!(body, content);
809 }
810
811 #[test]
812 fn test_steering_engine_register() {
813 let mut engine = SteeringEngine::new();
814
815 let rule = SteeringRule::new("test-rule");
816 engine.register_rule(rule).unwrap();
817
818 assert_eq!(engine.list_rules().len(), 1);
819 assert!(engine.get_rule("test-rule").is_some());
820 }
821
822 #[test]
823 fn test_steering_engine_duplicate_id() {
824 let mut engine = SteeringEngine::new();
825
826 let rule1 = SteeringRule::new("test-rule");
827 let rule2 = SteeringRule::new("test-rule");
828
829 engine.register_rule(rule1).unwrap();
830 assert!(engine.register_rule(rule2).is_err());
831 }
832
833 #[test]
834 fn test_steering_engine_unregister() {
835 let mut engine = SteeringEngine::new();
836
837 let rule = SteeringRule::new("test-rule");
838 engine.register_rule(rule).unwrap();
839
840 let removed = engine.unregister_rule("test-rule").unwrap();
841 assert_eq!(removed.id, "test-rule");
842 assert!(engine.get_rule("test-rule").is_none());
843 }
844
845 #[test]
846 fn test_steering_engine_priority_ordering() {
847 let mut engine = SteeringEngine::new();
848
849 let low_priority = SteeringRule::new("low").with_priority(200);
850 let high_priority = SteeringRule::new("high").with_priority(50);
851 let medium_priority = SteeringRule::new("medium").with_priority(100);
852
853 engine.register_rule(low_priority).unwrap();
854 engine.register_rule(high_priority).unwrap();
855 engine.register_rule(medium_priority).unwrap();
856
857 let rules = engine.list_rules();
858 assert_eq!(rules[0].id, "high");
859 assert_eq!(rules[1].id, "medium");
860 assert_eq!(rules[2].id, "low");
861 }
862
863 #[test]
864 fn test_steering_engine_get_rules_for_context() {
865 let mut engine = SteeringEngine::new();
866
867 let always_rule = SteeringRule::new("always").with_inclusion(SteeringInclusion::Always);
868
869 let rust_rule = SteeringRule::new("rust").with_inclusion(SteeringInclusion::FileMatch {
870 pattern: "**/*.rs".to_string(),
871 });
872
873 let manual_rule = SteeringRule::new("manual").with_inclusion(SteeringInclusion::Manual {
874 key: "special".to_string(),
875 });
876
877 engine.register_rule(always_rule).unwrap();
878 engine.register_rule(rust_rule).unwrap();
879 engine.register_rule(manual_rule).unwrap();
880
881 let rust_context = AgentContext::new().with_file(Path::new("src/main.rs"));
883 let rules = engine.get_rules_for_context(&rust_context);
884 assert_eq!(rules.len(), 2); let python_context = AgentContext::new().with_file(Path::new("src/main.py"));
888 let rules = engine.get_rules_for_context(&python_context);
889 assert_eq!(rules.len(), 1); let manual_context = AgentContext::new().with_manual_key("special");
893 let rules = engine.get_rules_for_context(&manual_context);
894 assert_eq!(rules.len(), 2); }
896
897 #[test]
898 fn test_resolved_content() {
899 let mut rule = SteeringRule::new("test").with_content("See #[[file:test.md]] for details.");
900
901 rule.file_references.push(
902 FileReference::new("#[[file:test.md]]", "test.md")
903 .with_resolved_content("# Test Content"),
904 );
905
906 let resolved = rule.resolved_content();
907 assert_eq!(resolved, "See # Test Content for details.");
908 }
909
910 #[test]
911 fn test_glob_match_star() {
912 assert!(glob_match("*.rs", "main.rs"));
913 assert!(glob_match("*.rs", "lib.rs"));
914 assert!(!glob_match("*.rs", "main.py"));
915 assert!(glob_match("test_*", "test_something"));
916 assert!(!glob_match("test_*", "something_test"));
917 }
918
919 #[test]
920 fn test_glob_match_double_star() {
921 assert!(glob_match("**/*.rs", "src/main.rs"));
922 assert!(glob_match("**/*.rs", "src/lib/mod.rs"));
923 assert!(glob_match("**/*.rs", "main.rs"));
924 assert!(!glob_match("**/*.rs", "main.py"));
925 }
926
927 #[test]
928 fn test_glob_match_exact() {
929 assert!(glob_match("main.rs", "main.rs"));
930 assert!(!glob_match("main.rs", "lib.rs"));
931 }
932
933 #[test]
934 fn test_agent_context() {
935 let context = AgentContext::new()
936 .with_file(Path::new("src/main.rs"))
937 .with_directory(Path::new("src"))
938 .with_manual_key("rust-style")
939 .with_variable("project", "driven");
940
941 assert_eq!(context.file_path, Some(PathBuf::from("src/main.rs")));
942 assert_eq!(context.directory, Some(PathBuf::from("src")));
943 assert!(context.manual_keys.contains(&"rust-style".to_string()));
944 assert_eq!(
945 context.variables.get("project"),
946 Some(&"driven".to_string())
947 );
948 }
949
950 #[test]
951 fn test_inject_into_context() {
952 let mut engine = SteeringEngine::new();
953
954 let rule1 = SteeringRule::new("rule1")
955 .with_inclusion(SteeringInclusion::Always)
956 .with_content("# Rule 1 Content")
957 .with_priority(10);
958
959 let rule2 = SteeringRule::new("rule2")
960 .with_inclusion(SteeringInclusion::Always)
961 .with_content("# Rule 2 Content")
962 .with_priority(20);
963
964 engine.register_rule(rule1).unwrap();
965 engine.register_rule(rule2).unwrap();
966
967 let context = AgentContext::new();
968 let output = engine.inject_into_context(&context);
969
970 assert!(output.contains("# Rule 1 Content"));
971 assert!(output.contains("# Rule 2 Content"));
972 assert!(output.contains("---")); }
974}
975
976#[cfg(test)]
977mod property_tests {
978 use super::*;
979 use proptest::prelude::*;
980
981 fn arb_extension() -> impl Strategy<Value = String> {
983 prop_oneof![
984 Just("rs".to_string()),
985 Just("py".to_string()),
986 Just("js".to_string()),
987 Just("ts".to_string()),
988 Just("md".to_string()),
989 Just("json".to_string()),
990 Just("yaml".to_string()),
991 ]
992 }
993
994 fn arb_file_path() -> impl Strategy<Value = PathBuf> {
996 (
997 prop_oneof![Just("src"), Just("tests"), Just("lib"), Just("bin"),],
998 "[a-z_]+",
999 arb_extension(),
1000 )
1001 .prop_map(|(dir, name, ext)| PathBuf::from(format!("{}/{}.{}", dir, name, ext)))
1002 }
1003
1004 fn arb_inclusion() -> impl Strategy<Value = SteeringInclusion> {
1006 prop_oneof![
1007 Just(SteeringInclusion::Always),
1008 arb_extension().prop_map(|ext| SteeringInclusion::FileMatch {
1009 pattern: format!("**/*.{}", ext),
1010 }),
1011 "[a-z_]+".prop_map(|key| SteeringInclusion::Manual { key }),
1012 ]
1013 }
1014
1015 proptest! {
1016 #![proptest_config(ProptestConfig::with_cases(100))]
1017
1018 #[test]
1023 fn prop_always_rules_always_apply(
1024 path in arb_file_path(),
1025 content in "[a-zA-Z ]+",
1026 ) {
1027 let mut engine = SteeringEngine::new();
1028
1029 let rule = SteeringRule::new("always-rule")
1030 .with_inclusion(SteeringInclusion::Always)
1031 .with_content(&content);
1032 engine.register_rule(rule).unwrap();
1033
1034 let context = AgentContext::new().with_file(&path);
1036 let rules = engine.get_rules_for_context(&context);
1037
1038 prop_assert_eq!(rules.len(), 1);
1039 prop_assert_eq!(&rules[0].id, "always-rule");
1040
1041 let empty_context = AgentContext::new();
1043 let rules = engine.get_rules_for_context(&empty_context);
1044
1045 prop_assert_eq!(rules.len(), 1);
1046 }
1047
1048 #[test]
1050 fn prop_file_match_rules_apply_on_match(
1051 ext in arb_extension(),
1052 dir in prop_oneof![Just("src"), Just("tests"), Just("lib")],
1053 name in "[a-z_]+",
1054 ) {
1055 let mut engine = SteeringEngine::new();
1056
1057 let pattern = format!("**/*.{}", ext);
1058 let rule = SteeringRule::new("file-rule")
1059 .with_inclusion(SteeringInclusion::FileMatch { pattern });
1060 engine.register_rule(rule).unwrap();
1061
1062 let matching_path = PathBuf::from(format!("{}/{}.{}", dir, name, ext));
1064 let context = AgentContext::new().with_file(&matching_path);
1065 let rules = engine.get_rules_for_context(&context);
1066
1067 prop_assert_eq!(rules.len(), 1);
1068
1069 let non_matching_path = PathBuf::from(format!("{}/{}.different", dir, name));
1071 let context = AgentContext::new().with_file(&non_matching_path);
1072 let rules = engine.get_rules_for_context(&context);
1073
1074 prop_assert_eq!(rules.len(), 0);
1075 }
1076
1077 #[test]
1079 fn prop_manual_rules_apply_on_key(
1080 key in "[a-z_]+",
1081 other_key in "[a-z_]+",
1082 ) {
1083 let mut engine = SteeringEngine::new();
1084
1085 let rule = SteeringRule::new("manual-rule")
1086 .with_inclusion(SteeringInclusion::Manual { key: key.clone() });
1087 engine.register_rule(rule).unwrap();
1088
1089 let context = AgentContext::new().with_manual_key(&key);
1091 let rules = engine.get_rules_for_context(&context);
1092
1093 prop_assert_eq!(rules.len(), 1);
1094
1095 if key != other_key {
1097 let context = AgentContext::new().with_manual_key(&other_key);
1098 let rules = engine.get_rules_for_context(&context);
1099
1100 prop_assert_eq!(rules.len(), 0);
1101 }
1102 }
1103
1104 #[test]
1109 fn prop_file_references_are_parsed(
1110 path1 in "[a-z_/]+\\.md",
1111 path2 in "[a-z_/]+\\.txt",
1112 ) {
1113 let content = format!(
1114 "See #[[file:{}]] and #[[file:{}]] for details.",
1115 path1, path2
1116 );
1117
1118 let references = SteeringEngine::parse_file_references(&content);
1119
1120 prop_assert_eq!(references.len(), 2);
1121 prop_assert_eq!(references[0].path.clone(), PathBuf::from(&path1));
1122 prop_assert_eq!(references[1].path.clone(), PathBuf::from(&path2));
1123 }
1124
1125 #[test]
1127 fn prop_steering_rule_roundtrip(
1128 id in "[a-z_]+",
1129 name in "[a-zA-Z ]+",
1130 content in "[a-zA-Z ]+",
1131 priority in 0u8..255,
1132 ) {
1133 let rule = SteeringRule::new(id.clone())
1134 .with_name(name.clone())
1135 .with_inclusion(SteeringInclusion::Always)
1136 .with_content(content.clone())
1137 .with_priority(priority);
1138
1139 let json = serde_json::to_string(&rule).expect("Should serialize");
1141
1142 let loaded: SteeringRule = serde_json::from_str(&json).expect("Should deserialize");
1144
1145 prop_assert_eq!(loaded.id, id);
1147 prop_assert_eq!(loaded.name, name);
1148 prop_assert_eq!(loaded.content, content);
1149 prop_assert_eq!(loaded.priority, priority);
1150 prop_assert_eq!(loaded.inclusion, SteeringInclusion::Always);
1151 }
1152 }
1153}