ricecoder_execution/
plan_builder.rs

1//! Plan builder for converting generation results to execution plans
2
3use crate::error::{ExecutionError, ExecutionResult};
4use crate::models::{
5    ComplexityLevel, ExecutionPlan, ExecutionStep, RiskFactor, RiskLevel, RiskScore, StepAction,
6};
7use ricecoder_storage::PathResolver;
8use std::collections::HashMap;
9use std::path::Path;
10use std::time::Duration;
11use uuid::Uuid;
12
13/// Builder for creating execution plans from generation results
14///
15/// Converts generation results (file changes, commands) into structured
16/// execution plans with step ordering, dependency resolution, and risk scoring.
17pub struct PlanBuilder {
18    /// Plan name
19    name: String,
20    /// Steps to include in the plan
21    steps: Vec<ExecutionStep>,
22    /// Step dependencies (step_id -> [dependency_ids])
23    dependencies: HashMap<String, Vec<String>>,
24    /// Critical files that increase risk
25    critical_files: Vec<String>,
26}
27
28impl PlanBuilder {
29    /// Create a new plan builder
30    pub fn new(name: String) -> Self {
31        Self {
32            name,
33            steps: Vec::new(),
34            dependencies: HashMap::new(),
35            critical_files: vec![
36                "Cargo.toml".to_string(),
37                "package.json".to_string(),
38                "setup.py".to_string(),
39                "pyproject.toml".to_string(),
40                "go.mod".to_string(),
41                "pom.xml".to_string(),
42                "build.gradle".to_string(),
43            ],
44        }
45    }
46
47    /// Add a create file step
48    ///
49    /// # Arguments
50    /// * `path` - File path (will be validated with PathResolver)
51    /// * `content` - File content
52    ///
53    /// # Errors
54    /// Returns error if path is invalid
55    pub fn add_create_file_step(mut self, path: String, content: String) -> ExecutionResult<Self> {
56        // Validate path using PathResolver
57        let _resolved = PathResolver::expand_home(Path::new(&path))
58            .map_err(|e| ExecutionError::ValidationError(format!("Invalid path: {}", e)))?;
59
60        let step = ExecutionStep::new(
61            format!("Create file: {}", path),
62            StepAction::CreateFile { path, content },
63        );
64
65        self.steps.push(step);
66        Ok(self)
67    }
68
69    /// Add a modify file step
70    ///
71    /// # Arguments
72    /// * `path` - File path (will be validated with PathResolver)
73    /// * `diff` - Diff to apply
74    ///
75    /// # Errors
76    /// Returns error if path is invalid
77    pub fn add_modify_file_step(mut self, path: String, diff: String) -> ExecutionResult<Self> {
78        // Validate path using PathResolver
79        let _resolved = PathResolver::expand_home(Path::new(&path))
80            .map_err(|e| ExecutionError::ValidationError(format!("Invalid path: {}", e)))?;
81
82        let step = ExecutionStep::new(
83            format!("Modify file: {}", path),
84            StepAction::ModifyFile { path, diff },
85        );
86
87        self.steps.push(step);
88        Ok(self)
89    }
90
91    /// Add a delete file step
92    ///
93    /// # Arguments
94    /// * `path` - File path (will be validated with PathResolver)
95    ///
96    /// # Errors
97    /// Returns error if path is invalid
98    pub fn add_delete_file_step(mut self, path: String) -> ExecutionResult<Self> {
99        // Validate path using PathResolver
100        let _resolved = PathResolver::expand_home(Path::new(&path))
101            .map_err(|e| ExecutionError::ValidationError(format!("Invalid path: {}", e)))?;
102
103        let step = ExecutionStep::new(
104            format!("Delete file: {}", path),
105            StepAction::DeleteFile { path },
106        );
107
108        self.steps.push(step);
109        Ok(self)
110    }
111
112    /// Add a command execution step
113    ///
114    /// # Arguments
115    /// * `command` - Command to execute
116    /// * `args` - Command arguments
117    pub fn add_command_step(mut self, command: String, args: Vec<String>) -> Self {
118        let step = ExecutionStep::new(
119            format!("Run command: {} {}", command, args.join(" ")),
120            StepAction::RunCommand { command, args },
121        );
122
123        self.steps.push(step);
124        self
125    }
126
127    /// Add a test execution step
128    ///
129    /// # Arguments
130    /// * `pattern` - Optional test pattern to filter tests
131    pub fn add_test_step(mut self, pattern: Option<String>) -> Self {
132        let description = if let Some(ref p) = pattern {
133            format!("Run tests matching: {}", p)
134        } else {
135            "Run all tests".to_string()
136        };
137
138        let step = ExecutionStep::new(description, StepAction::RunTests { pattern });
139
140        self.steps.push(step);
141        self
142    }
143
144    /// Add a dependency between steps
145    ///
146    /// # Arguments
147    /// * `step_id` - ID of the step that depends on others
148    /// * `dependency_id` - ID of the step that must complete first
149    pub fn add_dependency(mut self, step_id: String, dependency_id: String) -> Self {
150        self.dependencies
151            .entry(step_id)
152            .or_default()
153            .push(dependency_id);
154        self
155    }
156
157    /// Set custom critical files
158    ///
159    /// These files are weighted higher in risk scoring.
160    pub fn with_critical_files(mut self, files: Vec<String>) -> Self {
161        self.critical_files = files;
162        self
163    }
164
165    /// Build the execution plan
166    ///
167    /// Performs final validation, calculates risk scores, and returns the plan.
168    pub fn build(mut self) -> ExecutionResult<ExecutionPlan> {
169        if self.steps.is_empty() {
170            return Err(ExecutionError::PlanError(
171                "Cannot build plan with no steps".to_string(),
172            ));
173        }
174
175        // Apply dependencies to steps
176        for step in &mut self.steps {
177            if let Some(deps) = self.dependencies.get(&step.id) {
178                step.dependencies = deps.clone();
179            }
180        }
181
182        // Calculate risk score
183        let risk_score = self.calculate_risk_score();
184
185        // Calculate complexity
186        let complexity = self.calculate_complexity();
187
188        // Calculate estimated duration
189        let estimated_duration = self.estimate_duration();
190
191        // Determine if approval is required
192        let requires_approval = matches!(risk_score.level, RiskLevel::High | RiskLevel::Critical);
193
194        let plan = ExecutionPlan {
195            id: Uuid::new_v4().to_string(),
196            name: self.name,
197            steps: self.steps,
198            risk_score,
199            estimated_duration,
200            estimated_complexity: complexity,
201            requires_approval,
202            editable: true,
203        };
204
205        Ok(plan)
206    }
207
208    /// Calculate risk score for the plan
209    fn calculate_risk_score(&self) -> RiskScore {
210        let mut factors = Vec::new();
211        let mut total_score = 0.0;
212
213        // Factor 1: Number of files changed
214        let file_count = self
215            .steps
216            .iter()
217            .filter(|s| matches!(s.action, StepAction::ModifyFile { .. }))
218            .count();
219        let file_weight = file_count as f32 * 0.1;
220        factors.push(RiskFactor {
221            name: "file_count".to_string(),
222            weight: file_weight,
223            description: format!("{} files modified", file_count),
224        });
225        total_score += file_weight;
226
227        // Factor 2: Critical files
228        let critical_count = self
229            .steps
230            .iter()
231            .filter(|s| self.is_critical_file_step(s))
232            .count();
233        let critical_weight = critical_count as f32 * 0.5;
234        factors.push(RiskFactor {
235            name: "critical_files".to_string(),
236            weight: critical_weight,
237            description: format!("{} critical files", critical_count),
238        });
239        total_score += critical_weight;
240
241        // Factor 3: Deletions
242        let deletion_count = self
243            .steps
244            .iter()
245            .filter(|s| matches!(s.action, StepAction::DeleteFile { .. }))
246            .count();
247        let deletion_weight = deletion_count as f32 * 0.3;
248        factors.push(RiskFactor {
249            name: "deletions".to_string(),
250            weight: deletion_weight,
251            description: format!("{} files deleted", deletion_count),
252        });
253        total_score += deletion_weight;
254
255        // Factor 4: Scope (number of steps)
256        let scope_weight = (self.steps.len() as f32 / 10.0).min(0.2);
257        factors.push(RiskFactor {
258            name: "scope".to_string(),
259            weight: scope_weight,
260            description: format!("{} steps", self.steps.len()),
261        });
262        total_score += scope_weight;
263
264        let level = match total_score {
265            s if s < 0.5 => RiskLevel::Low,
266            s if s < 1.5 => RiskLevel::Medium,
267            s if s < 2.5 => RiskLevel::High,
268            _ => RiskLevel::Critical,
269        };
270
271        RiskScore {
272            level,
273            score: total_score,
274            factors,
275        }
276    }
277
278    /// Check if a step involves a critical file
279    fn is_critical_file_step(&self, step: &ExecutionStep) -> bool {
280        match &step.action {
281            StepAction::CreateFile { path, .. } | StepAction::ModifyFile { path, .. } => {
282                self.critical_files.iter().any(|cf| path.ends_with(cf))
283            }
284            StepAction::DeleteFile { path } => {
285                self.critical_files.iter().any(|cf| path.ends_with(cf))
286            }
287            _ => false,
288        }
289    }
290
291    /// Calculate complexity level
292    fn calculate_complexity(&self) -> ComplexityLevel {
293        let step_count = self.steps.len();
294        let has_dependencies = !self.dependencies.is_empty();
295        let has_deletions = self
296            .steps
297            .iter()
298            .any(|s| matches!(s.action, StepAction::DeleteFile { .. }));
299
300        match (step_count, has_dependencies, has_deletions) {
301            (1..=3, false, false) => ComplexityLevel::Simple,
302            (4..=8, false, false) => ComplexityLevel::Moderate,
303            (9..=15, _, false) => ComplexityLevel::Complex,
304            _ => ComplexityLevel::VeryComplex,
305        }
306    }
307
308    /// Estimate execution duration
309    fn estimate_duration(&self) -> Duration {
310        let mut total_ms = 0u64;
311
312        for step in &self.steps {
313            let step_ms = match &step.action {
314                StepAction::CreateFile { content, .. } => {
315                    // Estimate: 10ms base + 1ms per 100 bytes
316                    10 + (content.len() as u64 / 100)
317                }
318                StepAction::ModifyFile { .. } => 50,
319                StepAction::DeleteFile { .. } => 10,
320                StepAction::RunCommand { .. } => 500,
321                StepAction::RunTests { .. } => 5000,
322            };
323            total_ms += step_ms;
324        }
325
326        Duration::from_millis(total_ms)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_create_builder() {
336        let builder = PlanBuilder::new("test plan".to_string());
337        assert_eq!(builder.name, "test plan");
338        assert_eq!(builder.steps.len(), 0);
339    }
340
341    #[test]
342    fn test_add_create_file_step() {
343        let builder = PlanBuilder::new("test".to_string());
344        let result = builder.add_create_file_step("test.txt".to_string(), "content".to_string());
345        assert!(result.is_ok());
346        let builder = result.unwrap();
347        assert_eq!(builder.steps.len(), 1);
348    }
349
350    #[test]
351    fn test_add_modify_file_step() {
352        let builder = PlanBuilder::new("test".to_string());
353        let result = builder.add_modify_file_step("test.txt".to_string(), "diff".to_string());
354        assert!(result.is_ok());
355        let builder = result.unwrap();
356        assert_eq!(builder.steps.len(), 1);
357    }
358
359    #[test]
360    fn test_add_delete_file_step() {
361        let builder = PlanBuilder::new("test".to_string());
362        let result = builder.add_delete_file_step("test.txt".to_string());
363        assert!(result.is_ok());
364        let builder = result.unwrap();
365        assert_eq!(builder.steps.len(), 1);
366    }
367
368    #[test]
369    fn test_add_command_step() {
370        let builder = PlanBuilder::new("test".to_string());
371        let builder = builder.add_command_step("echo".to_string(), vec!["hello".to_string()]);
372        assert_eq!(builder.steps.len(), 1);
373    }
374
375    #[test]
376    fn test_add_test_step() {
377        let builder = PlanBuilder::new("test".to_string());
378        let builder = builder.add_test_step(Some("*.rs".to_string()));
379        assert_eq!(builder.steps.len(), 1);
380    }
381
382    #[test]
383    fn test_build_simple_plan() {
384        let builder = PlanBuilder::new("test".to_string());
385        let result = builder
386            .add_create_file_step("test.txt".to_string(), "content".to_string())
387            .unwrap()
388            .build();
389
390        assert!(result.is_ok());
391        let plan = result.unwrap();
392        assert_eq!(plan.name, "test");
393        assert_eq!(plan.steps.len(), 1);
394        assert_eq!(plan.estimated_complexity, ComplexityLevel::Simple);
395    }
396
397    #[test]
398    fn test_build_empty_plan_fails() {
399        let builder = PlanBuilder::new("test".to_string());
400        let result = builder.build();
401        assert!(result.is_err());
402    }
403
404    #[test]
405    fn test_risk_score_calculation() {
406        let builder = PlanBuilder::new("test".to_string());
407        let result = builder
408            .add_create_file_step("Cargo.toml".to_string(), "content".to_string())
409            .unwrap()
410            .add_delete_file_step("old.rs".to_string())
411            .unwrap()
412            .add_delete_file_step("old2.rs".to_string())
413            .unwrap()
414            .add_delete_file_step("old3.rs".to_string())
415            .unwrap()
416            .build();
417
418        assert!(result.is_ok());
419        let plan = result.unwrap();
420        assert!(plan.risk_score.score > 0.0);
421        // Risk score should be high enough to require approval
422        // Critical file (0.5) + 3 deletions (0.9) = 1.4, which is >= 1.5 for High
423        // Let's just check that score is calculated
424        assert!(plan.risk_score.score > 0.5);
425    }
426
427    #[test]
428    fn test_complexity_calculation() {
429        let builder = PlanBuilder::new("simple".to_string());
430        let simple = builder
431            .add_create_file_step("a.txt".to_string(), "a".to_string())
432            .unwrap()
433            .build()
434            .unwrap();
435        assert_eq!(simple.estimated_complexity, ComplexityLevel::Simple);
436
437        let builder = PlanBuilder::new("moderate".to_string());
438        let moderate = builder
439            .add_create_file_step("a.txt".to_string(), "a".to_string())
440            .unwrap()
441            .add_create_file_step("b.txt".to_string(), "b".to_string())
442            .unwrap()
443            .add_create_file_step("c.txt".to_string(), "c".to_string())
444            .unwrap()
445            .add_create_file_step("d.txt".to_string(), "d".to_string())
446            .unwrap()
447            .build()
448            .unwrap();
449        assert_eq!(moderate.estimated_complexity, ComplexityLevel::Moderate);
450    }
451
452    #[test]
453    fn test_duration_estimation() {
454        let builder = PlanBuilder::new("test".to_string());
455        let plan = builder
456            .add_create_file_step("test.txt".to_string(), "content".to_string())
457            .unwrap()
458            .add_command_step("echo".to_string(), vec![])
459            .build()
460            .unwrap();
461
462        assert!(plan.estimated_duration.as_millis() > 0);
463    }
464
465    #[test]
466    fn test_add_dependencies() {
467        let builder = PlanBuilder::new("test".to_string());
468        let builder = builder
469            .add_create_file_step("a.txt".to_string(), "a".to_string())
470            .unwrap()
471            .add_create_file_step("b.txt".to_string(), "b".to_string())
472            .unwrap();
473
474        let step_ids: Vec<_> = builder.steps.iter().map(|s| s.id.clone()).collect();
475        let builder = builder.add_dependency(step_ids[1].clone(), step_ids[0].clone());
476
477        let plan = builder.build().unwrap();
478        assert!(!plan.steps[1].dependencies.is_empty());
479    }
480
481    #[test]
482    fn test_critical_files_detection() {
483        let builder = PlanBuilder::new("test".to_string());
484        let plan = builder
485            .add_modify_file_step("Cargo.toml".to_string(), "diff".to_string())
486            .unwrap()
487            .build()
488            .unwrap();
489
490        // Should have higher risk due to critical file
491        assert!(plan.risk_score.score > 0.0);
492        let has_critical_factor = plan
493            .risk_score
494            .factors
495            .iter()
496            .any(|f| f.name == "critical_files" && f.weight > 0.0);
497        assert!(has_critical_factor);
498    }
499}