Skip to main content

driven/steering/
mod.rs

1//! Agent Steering System
2//!
3//! This module provides a system for guiding agent behavior and responses
4//! through configurable steering rules that can be applied based on context.
5//!
6//! ## Features
7//!
8//! - Always-included steering rules that apply to all interactions
9//! - Conditional steering rules based on file patterns (fileMatch)
10//! - Manual steering rules invoked via `#key` syntax
11//! - Rule inheritance from parent directories
12//! - File reference resolution via `#[[file:<path>]]` syntax
13//!
14//! ## Usage
15//!
16//! ```rust,ignore
17//! use driven::steering::{SteeringRule, SteeringInclusion, SteeringEngine};
18//!
19//! let mut engine = SteeringEngine::new();
20//! engine.load_steering(Path::new(".driven/steering"))?;
21//!
22//! // Get rules for a specific context
23//! let context = AgentContext::new()
24//!     .with_file(Path::new("src/main.rs"));
25//! let rules = engine.get_rules_for_context(&context);
26//! ```
27
28use crate::{DrivenError, Result};
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33/// Steering rule definition
34///
35/// Represents a rule that guides agent behavior based on context.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SteeringRule {
38    /// Unique identifier for the rule
39    pub id: String,
40    /// Human-readable name
41    pub name: String,
42    /// How this rule should be included
43    pub inclusion: SteeringInclusion,
44    /// The content of the steering rule (markdown)
45    pub content: String,
46    /// File references to resolve
47    pub file_references: Vec<FileReference>,
48    /// Priority (lower = higher priority)
49    pub priority: u8,
50    /// Source file path
51    pub source_path: Option<PathBuf>,
52}
53
54impl SteeringRule {
55    /// Create a new steering rule with the given ID
56    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    /// Set the rule name
70    pub fn with_name(mut self, name: impl Into<String>) -> Self {
71        self.name = name.into();
72        self
73    }
74
75    /// Set the inclusion type
76    pub fn with_inclusion(mut self, inclusion: SteeringInclusion) -> Self {
77        self.inclusion = inclusion;
78        self
79    }
80
81    /// Set the content
82    pub fn with_content(mut self, content: impl Into<String>) -> Self {
83        self.content = content.into();
84        self
85    }
86
87    /// Set the priority
88    pub fn with_priority(mut self, priority: u8) -> Self {
89        self.priority = priority;
90        self
91    }
92
93    /// Set the source path
94    pub fn with_source_path(mut self, path: impl Into<PathBuf>) -> Self {
95        self.source_path = Some(path.into());
96        self
97    }
98
99    /// Add a file reference
100    pub fn with_file_reference(mut self, reference: FileReference) -> Self {
101        self.file_references.push(reference);
102        self
103    }
104
105    /// Check if this rule applies to the given context
106    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    /// Get the resolved content with file references inlined
121    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/// How a steering rule should be included
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136#[serde(tag = "type", rename_all = "snake_case")]
137pub enum SteeringInclusion {
138    /// Always include this rule
139    Always,
140    /// Include when file matches pattern
141    FileMatch {
142        /// Glob pattern to match
143        pattern: String,
144    },
145    /// Include when manually invoked via #key
146    Manual {
147        /// Key to invoke this rule
148        key: String,
149    },
150}
151
152/// File reference in a steering rule
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct FileReference {
155    /// The original syntax (e.g., "#[[file:path/to/file.md]]")
156    pub syntax: String,
157    /// The path to the referenced file
158    pub path: PathBuf,
159    /// The resolved content (if resolved)
160    pub resolved_content: Option<String>,
161}
162
163impl FileReference {
164    /// Create a new file reference
165    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    /// Set the resolved content
174    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/// Context for agent interactions
181#[derive(Debug, Clone, Default)]
182pub struct AgentContext {
183    /// Current file being worked on
184    pub file_path: Option<PathBuf>,
185    /// Current directory
186    pub directory: Option<PathBuf>,
187    /// Manually invoked steering keys
188    pub manual_keys: Vec<String>,
189    /// Additional context variables
190    pub variables: HashMap<String, String>,
191}
192
193impl AgentContext {
194    /// Create a new empty context
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Set the current file
200    pub fn with_file(mut self, path: impl Into<PathBuf>) -> Self {
201        self.file_path = Some(path.into());
202        self
203    }
204
205    /// Set the current directory
206    pub fn with_directory(mut self, path: impl Into<PathBuf>) -> Self {
207        self.directory = Some(path.into());
208        self
209    }
210
211    /// Add a manual key
212    pub fn with_manual_key(mut self, key: impl Into<String>) -> Self {
213        self.manual_keys.push(key.into());
214        self
215    }
216
217    /// Set a variable
218    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
224/// Steering engine for managing and applying steering rules
225pub struct SteeringEngine {
226    /// Registered steering rules
227    rules: Vec<SteeringRule>,
228    /// Steering directory
229    steering_dir: PathBuf,
230    /// Inheritance cache (directory -> applicable rules)
231    inheritance_cache: HashMap<PathBuf, Vec<String>>,
232}
233
234impl SteeringEngine {
235    /// Create a new steering engine
236    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    /// Create a steering engine with a custom steering directory
245    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    /// Register a steering rule
254    pub fn register_rule(&mut self, rule: SteeringRule) -> Result<()> {
255        // Check for duplicate ID
256        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        // Sort by priority
266        self.rules.sort_by_key(|r| r.priority);
267
268        Ok(())
269    }
270
271    /// Unregister a steering rule by ID
272    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        // Clear inheritance cache
278        self.inheritance_cache.clear();
279
280        Ok(self.rules.remove(pos))
281    }
282
283    /// Get a rule by ID
284    pub fn get_rule(&self, id: &str) -> Option<&SteeringRule> {
285        self.rules.iter().find(|r| r.id == id)
286    }
287
288    /// Get a mutable reference to a rule by ID
289    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    /// List all rules
294    pub fn list_rules(&self) -> &[SteeringRule] {
295        &self.rules
296    }
297
298    /// Get rules that apply to a given context
299    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    /// Load steering rules from a directory
307    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    /// Load a single steering rule from a markdown file
342    fn load_steering_file(&self, path: &Path) -> Result<SteeringRule> {
343        let content = std::fs::read_to_string(path).map_err(DrivenError::Io)?;
344
345        // Parse front matter and content
346        let (front_matter, body) = Self::parse_front_matter(&content)?;
347
348        // Generate ID from filename
349        let id = path
350            .file_stem()
351            .and_then(|s| s.to_str())
352            .unwrap_or("unknown")
353            .to_string();
354
355        // Parse inclusion type from front matter
356        let inclusion = Self::parse_inclusion(&front_matter)?;
357
358        // Parse priority from front matter
359        let priority = front_matter
360            .get("priority")
361            .and_then(|v| v.parse::<u8>().ok())
362            .unwrap_or(100);
363
364        // Parse file references from content
365        let file_references = Self::parse_file_references(&body);
366
367        // Get name from front matter or use ID
368        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    /// Parse YAML front matter from markdown content
385    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        // Find the end of front matter
393        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        // Parse simple YAML key-value pairs
402        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    /// Parse inclusion type from front matter
424    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    /// Parse file references from content
463    fn parse_file_references(content: &str) -> Vec<FileReference> {
464        let mut references = Vec::new();
465
466        // Pattern: #[[file:<path>]]
467        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    /// Resolve file references in a rule
490    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    /// Resolve all file references in all rules
512    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    /// Get rules with inheritance from parent directories
536    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        // Walk up the directory tree
541        let mut current = Some(directory);
542        while let Some(dir) = current {
543            // Check for steering directory in this location
544            let steering_dir = dir.join(".driven/steering");
545
546            // Find rules that came from this directory
547            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        // Sort by priority (child rules have been added first, so they take precedence)
560        applicable_rules.sort_by_key(|r| r.priority);
561
562        applicable_rules
563    }
564
565    /// Inject applicable steering rules into an agent context
566    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    /// Save a steering rule to a file
582    pub fn save_rule(&self, rule: &SteeringRule, path: &Path) -> Result<()> {
583        let mut content = String::new();
584
585        // Write front matter
586        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        // Write content
607        content.push_str(&rule.content);
608
609        // Ensure parent directory exists
610        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    /// Get the steering directory
620    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
631/// Simple glob pattern matching
632fn glob_match(pattern: &str, text: &str) -> bool {
633    // Handle ** (match any path)
634    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    // Handle * (match any characters except /)
651    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                // First part must be at the start
662                if !text.starts_with(part) {
663                    return false;
664                }
665                pos = part.len();
666            } else if i == parts.len() - 1 {
667                // Last part must be at the end
668                if !text.ends_with(part) {
669                    return false;
670                }
671            } else {
672                // Middle parts must exist somewhere
673                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    // Exact match
685    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        // Test with Rust file
882        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); // always + rust
885
886        // Test with Python file
887        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); // only always
890
891        // Test with manual key
892        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); // always + manual
895    }
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("---")); // Separator between rules
973    }
974}
975
976#[cfg(test)]
977mod property_tests {
978    use super::*;
979    use proptest::prelude::*;
980
981    /// Generate an arbitrary file extension
982    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    /// Generate an arbitrary file path
995    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    /// Generate an arbitrary inclusion type
1005    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        /// Property 12: Steering Rule Application
1019        /// *For any* agent context, the steering engine SHALL inject all applicable rules
1020        /// based on inclusion type (always, fileMatch, manual).
1021        /// **Validates: Requirements 4.1, 4.2, 4.3, 4.5**
1022        #[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            // Test with file context
1035            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            // Test with empty context
1042            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        /// Property 12b: FileMatch rules apply only when pattern matches
1049        #[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            // Test with matching file
1063            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            // Test with non-matching file
1070            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        /// Property 12c: Manual rules apply only when key is present
1078        #[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            // Test with matching key
1090            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            // Test with different key (if different)
1096            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        /// Property 14: Steering File Reference Resolution
1105        /// *For any* steering rule with file references, all `#[[file:<path>]]` references
1106        /// SHALL be resolved to their file contents.
1107        /// **Validates: Requirements 4.9**
1108        #[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        /// Property: Steering rule serialization round-trip
1126        #[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            // Serialize to JSON
1140            let json = serde_json::to_string(&rule).expect("Should serialize");
1141
1142            // Deserialize back
1143            let loaded: SteeringRule = serde_json::from_str(&json).expect("Should deserialize");
1144
1145            // Verify fields match
1146            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}