Skip to main content

driven/cli/
steer.rs

1//! Steering CLI Commands
2//!
3//! CLI commands for managing agent steering rules.
4
5use crate::steering::{AgentContext, SteeringEngine, SteeringInclusion, SteeringRule};
6use crate::{DrivenError, Result};
7use std::path::{Path, PathBuf};
8
9/// Steering command handler
10pub struct SteerCommand;
11
12impl SteerCommand {
13    /// List all steering rules
14    pub fn list(project_root: &Path) -> Result<Vec<SteeringInfo>> {
15        let steering_dir = project_root.join(".driven/steering");
16        let mut engine = SteeringEngine::with_steering_dir(&steering_dir);
17
18        if steering_dir.exists() {
19            engine.load_steering(&steering_dir)?;
20        }
21
22        let rules: Vec<SteeringInfo> = engine.list_rules().iter().map(SteeringInfo::from).collect();
23
24        Ok(rules)
25    }
26
27    /// Add a new steering rule
28    pub fn add(
29        project_root: &Path,
30        id: &str,
31        name: Option<&str>,
32        inclusion_type: &str,
33        pattern_or_key: Option<&str>,
34        content: &str,
35        priority: Option<u8>,
36    ) -> Result<()> {
37        let steering_dir = project_root.join(".driven/steering");
38        std::fs::create_dir_all(&steering_dir).map_err(DrivenError::Io)?;
39
40        // Parse inclusion type
41        let inclusion = Self::parse_inclusion(inclusion_type, pattern_or_key)?;
42
43        // Create rule
44        let rule = SteeringRule::new(id)
45            .with_name(name.unwrap_or(id))
46            .with_inclusion(inclusion)
47            .with_content(content)
48            .with_priority(priority.unwrap_or(100));
49
50        // Save rule to file
51        let rule_path = steering_dir.join(format!("{}.md", id));
52
53        let engine = SteeringEngine::new();
54        engine.save_rule(&rule, &rule_path)?;
55
56        super::print_success(&format!("Steering rule '{}' added successfully", id));
57        Ok(())
58    }
59
60    /// Remove a steering rule
61    pub fn remove(project_root: &Path, id: &str) -> Result<()> {
62        let steering_dir = project_root.join(".driven/steering");
63
64        if !steering_dir.exists() {
65            return Err(DrivenError::Config(
66                "No steering directory found".to_string(),
67            ));
68        }
69
70        let rule_path = steering_dir.join(format!("{}.md", id));
71
72        if rule_path.exists() {
73            std::fs::remove_file(&rule_path).map_err(DrivenError::Io)?;
74            super::print_success(&format!("Steering rule '{}' removed successfully", id));
75            Ok(())
76        } else {
77            Err(DrivenError::Config(format!(
78                "Steering rule with ID '{}' not found",
79                id
80            )))
81        }
82    }
83
84    /// Test which rules apply to a file
85    pub fn test(
86        project_root: &Path,
87        file_path: &Path,
88        manual_keys: &[String],
89    ) -> Result<Vec<SteeringInfo>> {
90        let steering_dir = project_root.join(".driven/steering");
91        let mut engine = SteeringEngine::with_steering_dir(&steering_dir);
92
93        if steering_dir.exists() {
94            engine.load_steering(&steering_dir)?;
95        }
96
97        // Build context
98        let mut context = AgentContext::new().with_file(file_path);
99        for key in manual_keys {
100            context = context.with_manual_key(key);
101        }
102
103        // Get applicable rules
104        let rules = engine.get_rules_for_context(&context);
105
106        Ok(rules.iter().map(|r| SteeringInfo::from(*r)).collect())
107    }
108
109    /// Show steering rule details
110    pub fn show(project_root: &Path, id: &str) -> Result<SteeringInfo> {
111        let steering_dir = project_root.join(".driven/steering");
112        let mut engine = SteeringEngine::with_steering_dir(&steering_dir);
113
114        if steering_dir.exists() {
115            engine.load_steering(&steering_dir)?;
116        }
117
118        let rule = engine.get_rule(id).ok_or_else(|| {
119            DrivenError::Config(format!("Steering rule with ID '{}' not found", id))
120        })?;
121
122        Ok(SteeringInfo::from(rule))
123    }
124
125    /// Get the combined steering content for a context
126    pub fn inject(
127        project_root: &Path,
128        file_path: Option<&Path>,
129        manual_keys: &[String],
130    ) -> Result<String> {
131        let steering_dir = project_root.join(".driven/steering");
132        let mut engine = SteeringEngine::with_steering_dir(&steering_dir);
133
134        if steering_dir.exists() {
135            engine.load_steering(&steering_dir)?;
136            engine.resolve_all_file_references(project_root)?;
137        }
138
139        // Build context
140        let mut context = AgentContext::new();
141        if let Some(path) = file_path {
142            context = context.with_file(path);
143        }
144        for key in manual_keys {
145            context = context.with_manual_key(key);
146        }
147
148        Ok(engine.inject_into_context(&context))
149    }
150
151    /// Parse inclusion type from string
152    fn parse_inclusion(
153        inclusion_type: &str,
154        pattern_or_key: Option<&str>,
155    ) -> Result<SteeringInclusion> {
156        match inclusion_type.to_lowercase().as_str() {
157            "always" => Ok(SteeringInclusion::Always),
158            "filematch" | "file_match" | "file" => {
159                let pattern = pattern_or_key.ok_or_else(|| {
160                    DrivenError::Config("fileMatch inclusion requires a pattern".to_string())
161                })?;
162                Ok(SteeringInclusion::FileMatch {
163                    pattern: pattern.to_string(),
164                })
165            }
166            "manual" => {
167                let key = pattern_or_key.ok_or_else(|| {
168                    DrivenError::Config("manual inclusion requires a key".to_string())
169                })?;
170                Ok(SteeringInclusion::Manual {
171                    key: key.to_string(),
172                })
173            }
174            _ => Err(DrivenError::Config(format!(
175                "Unknown inclusion type: {}. Valid: always, fileMatch, manual",
176                inclusion_type
177            ))),
178        }
179    }
180}
181
182/// Steering rule information for display
183#[derive(Debug, Clone)]
184pub struct SteeringInfo {
185    pub id: String,
186    pub name: String,
187    pub inclusion_type: String,
188    pub inclusion_value: Option<String>,
189    pub priority: u8,
190    pub content_preview: String,
191    pub file_reference_count: usize,
192    pub source_path: Option<PathBuf>,
193}
194
195impl From<&SteeringRule> for SteeringInfo {
196    fn from(rule: &SteeringRule) -> Self {
197        let (inclusion_type, inclusion_value) = match &rule.inclusion {
198            SteeringInclusion::Always => ("always".to_string(), None),
199            SteeringInclusion::FileMatch { pattern } => {
200                ("fileMatch".to_string(), Some(pattern.clone()))
201            }
202            SteeringInclusion::Manual { key } => ("manual".to_string(), Some(key.clone())),
203        };
204
205        // Create content preview (first 50 chars)
206        let content_preview = if rule.content.len() > 50 {
207            format!("{}...", &rule.content[..50].replace('\n', " "))
208        } else {
209            rule.content.replace('\n', " ")
210        };
211
212        Self {
213            id: rule.id.clone(),
214            name: rule.name.clone(),
215            inclusion_type,
216            inclusion_value,
217            priority: rule.priority,
218            content_preview,
219            file_reference_count: rule.file_references.len(),
220            source_path: rule.source_path.clone(),
221        }
222    }
223}
224
225/// Print steering rules in a formatted table
226pub fn print_steering_table(rules: &[SteeringInfo]) {
227    use console::style;
228
229    if rules.is_empty() {
230        println!("No steering rules configured.");
231        return;
232    }
233
234    // Print header
235    println!(
236        "{:<20} {:<15} {:<25} {:<8}",
237        style("ID").bold(),
238        style("Inclusion").bold(),
239        style("Pattern/Key").bold(),
240        style("Priority").bold(),
241    );
242    println!("{}", "-".repeat(70));
243
244    // Print rules
245    for rule in rules {
246        let inclusion_value = rule.inclusion_value.as_deref().unwrap_or("-");
247
248        println!(
249            "{:<20} {:<15} {:<25} {:<8}",
250            rule.id, rule.inclusion_type, inclusion_value, rule.priority,
251        );
252    }
253}
254
255/// Print detailed steering rule information
256pub fn print_steering_details(rule: &SteeringInfo) {
257    use console::style;
258
259    println!("{}", style("Steering Rule Details").bold().underlined());
260    println!();
261    println!("  {}: {}", style("ID").bold(), rule.id);
262    println!("  {}: {}", style("Name").bold(), rule.name);
263    println!(
264        "  {}: {}",
265        style("Inclusion Type").bold(),
266        rule.inclusion_type
267    );
268    if let Some(value) = &rule.inclusion_value {
269        println!("  {}: {}", style("Pattern/Key").bold(), value);
270    }
271    println!("  {}: {}", style("Priority").bold(), rule.priority);
272    println!(
273        "  {}: {}",
274        style("File References").bold(),
275        rule.file_reference_count
276    );
277    if let Some(path) = &rule.source_path {
278        println!("  {}: {}", style("Source").bold(), path.display());
279    }
280    println!();
281    println!(
282        "  {}: {}",
283        style("Content Preview").bold(),
284        rule.content_preview
285    );
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use tempfile::TempDir;
292
293    #[test]
294    fn test_parse_inclusion_always() {
295        let inclusion = SteerCommand::parse_inclusion("always", None).unwrap();
296        assert_eq!(inclusion, SteeringInclusion::Always);
297    }
298
299    #[test]
300    fn test_parse_inclusion_file_match() {
301        let inclusion = SteerCommand::parse_inclusion("fileMatch", Some("**/*.rs")).unwrap();
302        match inclusion {
303            SteeringInclusion::FileMatch { pattern } => {
304                assert_eq!(pattern, "**/*.rs");
305            }
306            _ => panic!("Wrong inclusion type"),
307        }
308    }
309
310    #[test]
311    fn test_parse_inclusion_manual() {
312        let inclusion = SteerCommand::parse_inclusion("manual", Some("rust-style")).unwrap();
313        match inclusion {
314            SteeringInclusion::Manual { key } => {
315                assert_eq!(key, "rust-style");
316            }
317            _ => panic!("Wrong inclusion type"),
318        }
319    }
320
321    #[test]
322    fn test_parse_inclusion_file_match_requires_pattern() {
323        let result = SteerCommand::parse_inclusion("fileMatch", None);
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn test_parse_inclusion_manual_requires_key() {
329        let result = SteerCommand::parse_inclusion("manual", None);
330        assert!(result.is_err());
331    }
332
333    #[test]
334    fn test_add_and_list_steering() {
335        let temp_dir = TempDir::new().unwrap();
336        let project_root = temp_dir.path();
337
338        // Add a steering rule
339        SteerCommand::add(
340            project_root,
341            "rust-standards",
342            Some("Rust Standards"),
343            "always",
344            None,
345            "# Rust Standards\n\nUse these standards.",
346            Some(50),
347        )
348        .unwrap();
349
350        // List steering rules
351        let rules = SteerCommand::list(project_root).unwrap();
352        assert_eq!(rules.len(), 1);
353        assert_eq!(rules[0].id, "rust-standards");
354        assert_eq!(rules[0].name, "Rust Standards");
355        assert_eq!(rules[0].inclusion_type, "always");
356        assert_eq!(rules[0].priority, 50);
357    }
358
359    #[test]
360    fn test_remove_steering() {
361        let temp_dir = TempDir::new().unwrap();
362        let project_root = temp_dir.path();
363
364        // Add a steering rule
365        SteerCommand::add(
366            project_root,
367            "test-rule",
368            None,
369            "always",
370            None,
371            "# Test",
372            None,
373        )
374        .unwrap();
375
376        // Remove the rule
377        SteerCommand::remove(project_root, "test-rule").unwrap();
378
379        // List should be empty
380        let rules = SteerCommand::list(project_root).unwrap();
381        assert!(rules.is_empty());
382    }
383
384    #[test]
385    fn test_test_steering() {
386        let temp_dir = TempDir::new().unwrap();
387        let project_root = temp_dir.path();
388
389        // Add an always rule
390        SteerCommand::add(
391            project_root,
392            "always-rule",
393            None,
394            "always",
395            None,
396            "# Always",
397            None,
398        )
399        .unwrap();
400
401        // Add a file match rule
402        SteerCommand::add(
403            project_root,
404            "rust-rule",
405            None,
406            "fileMatch",
407            Some("**/*.rs"),
408            "# Rust",
409            None,
410        )
411        .unwrap();
412
413        // Test with Rust file
414        let rules = SteerCommand::test(project_root, Path::new("src/main.rs"), &[]).unwrap();
415        assert_eq!(rules.len(), 2); // always + rust
416
417        // Test with Python file
418        let rules = SteerCommand::test(project_root, Path::new("src/main.py"), &[]).unwrap();
419        assert_eq!(rules.len(), 1); // only always
420    }
421
422    #[test]
423    fn test_inject_steering() {
424        let temp_dir = TempDir::new().unwrap();
425        let project_root = temp_dir.path();
426
427        // Add a steering rule
428        SteerCommand::add(
429            project_root,
430            "test-rule",
431            None,
432            "always",
433            None,
434            "# Test Content",
435            None,
436        )
437        .unwrap();
438
439        // Inject steering
440        let content = SteerCommand::inject(project_root, None, &[]).unwrap();
441        assert!(content.contains("# Test Content"));
442    }
443}