Skip to main content

forge_agent/
planner.rs

1//! Planning engine - Execution plan generation from observations.
2//!
3//! This module implements the planning phase of the agent loop, generating
4//! execution steps from observations and constraints.
5
6use crate::{AgentError, Result};
7use forge_core::Forge;
8use std::sync::Arc;
9
10/// Planner for generating execution plans.
11///
12/// The Planner creates ordered steps from observations, ensuring
13/// dependencies are satisfied and conflicts are detected.
14#[derive(Clone)]
15pub struct Planner {
16    /// Forge SDK for graph queries
17    forge: Arc<Forge>,
18}
19
20impl Planner {
21    /// Creates a new planner.
22    pub fn new(forge: Forge) -> Self {
23        Self {
24            forge: Arc::new(forge),
25        }
26    }
27
28    /// Generates execution steps from an observation.
29    ///
30    /// # Arguments
31    ///
32    /// * `observation` - The observation data
33    pub async fn generate_steps(
34        &self,
35        observation: &super::observe::Observation,
36    ) -> Result<Vec<PlanStep>> {
37        let mut steps = Vec::new();
38
39        // For each symbol in the observation, create appropriate steps
40        for symbol in &observation.symbols {
41            // In production, this would use LLM to decide what operations
42            // For now, create placeholder steps
43            steps.push(PlanStep {
44                description: format!("Process symbol {}", symbol.name),
45                operation: PlanOperation::Inspect {
46                    symbol_id: symbol.id,
47                    symbol_name: symbol.name.clone(),
48                },
49            });
50        }
51
52        Ok(steps)
53    }
54
55    /// Estimates impact of a plan.
56    ///
57    /// # Arguments
58    ///
59    /// * `steps` - The planned steps
60    pub async fn estimate_impact(&self, steps: &[PlanStep]) -> Result<ImpactEstimate> {
61        let mut affected_files = std::collections::HashSet::new();
62
63        // Collect all affected files
64        for step in steps {
65            match &step.operation {
66                PlanOperation::Rename { old, .. } => {
67                    // Extract file from symbol name (simplified)
68                    if let Some(file) = self.extract_file_from_symbol(old) {
69                        affected_files.insert(file);
70                    }
71                }
72                PlanOperation::Delete { name } => {
73                    if let Some(file) = self.extract_file_from_symbol(name) {
74                        affected_files.insert(file);
75                    }
76                }
77                PlanOperation::Create { path, .. } => {
78                    affected_files.insert(path.clone());
79                }
80                PlanOperation::Inspect { .. } => {
81                    // Inspect doesn't modify files
82                }
83                PlanOperation::Modify { file, .. } => {
84                    affected_files.insert(file.clone());
85                }
86            }
87        }
88
89        Ok(ImpactEstimate {
90            affected_files: affected_files.into_iter().collect(),
91            complexity: steps.len(),
92        })
93    }
94
95    /// Detects conflicts between steps.
96    ///
97    /// # Arguments
98    ///
99    /// * `steps` - The planned steps
100    pub fn detect_conflicts(&self, steps: &[PlanStep]) -> Result<Vec<Conflict>> {
101        let mut conflicts = Vec::new();
102        let mut file_regions: std::collections::HashMap<String, Vec<(usize, usize, usize)>> =
103            std::collections::HashMap::new();
104
105        // Track regions in each file
106        for (idx, step) in steps.iter().enumerate() {
107            if let Some(region) = self.get_step_region(step) {
108                file_regions
109                    .entry(region.file.clone())
110                    .or_insert_with(Vec::new)
111                    .push((idx, region.start, region.end));
112            }
113        }
114
115        // Check for overlaps
116        for (file, regions) in &file_regions {
117            for i in 0..regions.len() {
118                for j in (i + 1)..regions.len() {
119                    let (idx1, start1, end1) = regions[i];
120                    let (idx2, start2, _end2) = regions[j];
121
122                    // Check for overlap (no dereference needed, values are already usize)
123                    if start1 < end1 && start2 < end1 {
124                        conflicts.push(Conflict {
125                            step_indices: vec![idx1, idx2],
126                            file: file.clone(),
127                            reason: ConflictReason::OverlappingRegion {
128                                start: start1,
129                                end: end1,
130                            },
131                        });
132                    }
133                }
134            }
135        }
136
137        Ok(conflicts)
138    }
139
140    /// Orders steps based on dependencies.
141    ///
142    /// # Arguments
143    ///
144    /// * `steps` - The planned steps
145    pub fn order_steps(&self, steps: &mut Vec<PlanStep>) -> Result<()> {
146        // Simple topological sort based on step dependencies
147        // For now, keep existing order (production would use DAG)
148        // In a full implementation, this would:
149        // 1. Build dependency graph
150        // 2. Topologically sort
151        // 3. Detect cycles
152
153        // Ensure Rename comes before Delete for same symbol
154        let mut rename_indices = Vec::new();
155        let mut delete_indices = Vec::new();
156
157        for (idx, step) in steps.iter().enumerate() {
158            match &step.operation {
159                PlanOperation::Rename { old, .. } => {
160                    rename_indices.push((idx, old.clone()));
161                }
162                PlanOperation::Delete { name } => {
163                    delete_indices.push((idx, name.clone()));
164                }
165                _ => {}
166            }
167        }
168
169        // Move renames before deletes for same symbols
170        for (rename_idx, name) in &rename_indices {
171            for (delete_idx, delete_name) in &delete_indices {
172                if name == delete_name && rename_idx > delete_idx {
173                    // Swap the steps
174                    steps.swap(*rename_idx, *delete_idx);
175                }
176            }
177        }
178
179        Ok(())
180    }
181
182    /// Generates rollback plan.
183    ///
184    /// # Arguments
185    ///
186    /// * `steps` - The planned steps
187    pub fn generate_rollback(&self, steps: &[PlanStep]) -> Vec<RollbackStep> {
188        steps
189            .iter()
190            .rev()
191            .map(|step| RollbackStep {
192                description: format!("Rollback: {}", step.description),
193                operation: match &step.operation {
194                    PlanOperation::Rename { old, .. } => RollbackOperation::Rename {
195                        new_name: old.clone(),
196                    },
197                    PlanOperation::Delete { name } => {
198                        RollbackOperation::Restore { name: name.clone() }
199                    }
200                    PlanOperation::Create { path, .. } => {
201                        RollbackOperation::Delete { path: path.clone() }
202                    }
203                    PlanOperation::Inspect { .. } => RollbackOperation::None,
204                    PlanOperation::Modify { file, .. } => {
205                        RollbackOperation::Restore { name: file.clone() }
206                    }
207                },
208            })
209            .collect()
210    }
211
212    /// Extracts file path from symbol name (simplified).
213    fn extract_file_from_symbol(&self, _symbol: &str) -> Option<String> {
214        // In production, this would query the graph for symbol location
215        // For now, return None
216        None
217    }
218
219    /// Gets the file region affected by a step.
220    fn get_step_region(&self, step: &PlanStep) -> Option<FileRegion> {
221        match &step.operation {
222            PlanOperation::Rename { .. } | PlanOperation::Delete { .. } => None,
223            PlanOperation::Create { path, .. } => Some(FileRegion {
224                file: path.clone(),
225                start: 0,
226                end: usize::MAX,
227            }),
228            PlanOperation::Inspect { .. } => None,
229            PlanOperation::Modify { file, start, end } => Some(FileRegion {
230                file: file.clone(),
231                start: *start,
232                end: *end,
233            }),
234        }
235    }
236}
237
238/// A step in the execution plan.
239#[derive(Clone, Debug)]
240pub struct PlanStep {
241    /// Step description
242    pub description: String,
243    /// Operation to perform
244    pub operation: PlanOperation,
245}
246
247/// Operation to perform in a plan step.
248#[derive(Clone, Debug)]
249pub enum PlanOperation {
250    /// Rename a symbol
251    Rename { old: String, new: String },
252    /// Delete a symbol
253    Delete { name: String },
254    /// Create new code
255    Create { path: String, content: String },
256    /// Inspect a symbol (read-only)
257    Inspect {
258        symbol_id: forge_core::types::SymbolId,
259        symbol_name: String,
260    },
261    /// Modify existing code
262    Modify {
263        file: String,
264        start: usize,
265        end: usize,
266    },
267}
268
269/// Estimated impact of a plan.
270#[derive(Clone, Debug)]
271pub struct ImpactEstimate {
272    /// Files to be modified
273    pub affected_files: Vec<String>,
274    /// Estimated complexity
275    pub complexity: usize,
276}
277
278/// A conflict detected between steps.
279#[derive(Clone, Debug)]
280pub struct Conflict {
281    /// Indices of conflicting steps
282    pub step_indices: Vec<usize>,
283    /// File where conflict occurs
284    pub file: String,
285    /// Reason for conflict
286    pub reason: ConflictReason,
287}
288
289/// Reason for a conflict.
290#[derive(Clone, Debug)]
291pub enum ConflictReason {
292    /// Overlapping regions in the same file
293    OverlappingRegion { start: usize, end: usize },
294    /// Circular dependency
295    CircularDependency,
296    /// Missing dependency
297    MissingDependency,
298}
299
300/// A rollback step.
301#[derive(Clone, Debug)]
302pub struct RollbackStep {
303    /// Step description
304    pub description: String,
305    /// Rollback operation
306    pub operation: RollbackOperation,
307}
308
309/// Rollback operation.
310#[derive(Clone, Debug)]
311pub enum RollbackOperation {
312    /// Rollback by renaming back
313    Rename { new_name: String },
314    /// Rollback by restoring deleted content
315    Restore { name: String },
316    /// Rollback by deleting created content
317    Delete { path: String },
318    /// No rollback needed
319    None,
320}
321
322/// A file region.
323#[derive(Clone, Debug)]
324struct FileRegion {
325    /// File path
326    file: String,
327    /// Region start
328    start: usize,
329    /// Region end
330    end: usize,
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use tempfile::TempDir;
337
338    #[tokio::test]
339    async fn test_planner_creation() {
340        let temp_dir = TempDir::new().unwrap();
341        let forge = Forge::open(temp_dir.path()).await.unwrap();
342        let _planner = Planner::new(forge);
343
344        // Should create successfully
345        assert!(true);
346    }
347
348    #[tokio::test]
349    async fn test_generate_steps() {
350        let temp_dir = TempDir::new().unwrap();
351        let forge = Forge::open(temp_dir.path()).await.unwrap();
352        let planner = Planner::new(forge);
353
354        let observation = crate::observe::Observation {
355            query: "test".to_string(),
356            symbols: vec![],
357        };
358
359        let steps = planner.generate_steps(&observation).await.unwrap();
360        // Should succeed (even if empty)
361        assert!(steps.is_empty());
362    }
363
364    #[tokio::test]
365    async fn test_detect_conflicts_empty() {
366        let temp_dir = TempDir::new().unwrap();
367        let forge = Forge::open(temp_dir.path()).await.unwrap();
368        let planner = Planner::new(forge);
369
370        let steps = vec![];
371        let conflicts = planner.detect_conflicts(&steps).unwrap();
372        assert!(conflicts.is_empty());
373    }
374
375    #[tokio::test]
376    async fn test_order_steps() {
377        let temp_dir = TempDir::new().unwrap();
378        let forge = Forge::open(temp_dir.path()).await.unwrap();
379        let planner = Planner::new(forge);
380
381        let mut steps = vec![
382            PlanStep {
383                description: "Delete foo".to_string(),
384                operation: PlanOperation::Delete {
385                    name: "foo".to_string(),
386                },
387            },
388            PlanStep {
389                description: "Rename foo to bar".to_string(),
390                operation: PlanOperation::Rename {
391                    old: "foo".to_string(),
392                    new: "bar".to_string(),
393                },
394            },
395        ];
396
397        planner.order_steps(&mut steps).unwrap();
398
399        // Rename should now come before Delete
400        assert!(matches!(steps[0].operation, PlanOperation::Rename { .. }));
401        assert!(matches!(steps[1].operation, PlanOperation::Delete { .. }));
402    }
403
404    #[tokio::test]
405    async fn test_generate_rollback() {
406        let temp_dir = TempDir::new().unwrap();
407        let forge = Forge::open(temp_dir.path()).await.unwrap();
408        let planner = Planner::new(forge);
409
410        let steps = vec![PlanStep {
411            description: "Create file".to_string(),
412            operation: PlanOperation::Create {
413                path: "/tmp/test.rs".to_string(),
414                content: "fn test() {}".to_string(),
415            },
416        }];
417
418        let rollback = planner.generate_rollback(&steps);
419
420        assert_eq!(rollback.len(), 1);
421        assert_eq!(rollback[0].description, "Rollback: Create file");
422        assert!(matches!(
423            rollback[0].operation,
424            RollbackOperation::Delete { .. }
425        ));
426    }
427
428    #[tokio::test]
429    async fn test_estimate_impact() {
430        let temp_dir = TempDir::new().unwrap();
431        let forge = Forge::open(temp_dir.path()).await.unwrap();
432        let planner = Planner::new(forge);
433
434        let steps = vec![PlanStep {
435            description: "Create test.rs".to_string(),
436            operation: PlanOperation::Create {
437                path: "/tmp/test.rs".to_string(),
438                content: "fn test() {}".to_string(),
439            },
440        }];
441
442        let impact = planner.estimate_impact(&steps).await.unwrap();
443
444        assert!(!impact.affected_files.is_empty() || impact.affected_files.len() >= 0);
445    }
446}