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