Skip to main content

rustant_core/
plan.rs

1//! Plan mode: structured multi-step execution plans.
2//!
3//! When plan mode is enabled, the agent generates a structured plan from
4//! plain English input, lets the user review/edit/approve it, and then
5//! executes the approved plan step-by-step with progress tracking.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::types::RiskLevel;
12
13/// Status of the overall execution plan.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum PlanStatus {
17    /// Plan is being generated by the LLM.
18    Generating,
19    /// Plan is ready for user review.
20    PendingReview,
21    /// User is editing the plan.
22    Editing,
23    /// Plan is being executed step-by-step.
24    Executing,
25    /// All steps completed successfully.
26    Completed,
27    /// One or more steps failed.
28    Failed,
29    /// User cancelled the plan.
30    Cancelled,
31}
32
33impl std::fmt::Display for PlanStatus {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            PlanStatus::Generating => write!(f, "generating"),
37            PlanStatus::PendingReview => write!(f, "pending review"),
38            PlanStatus::Editing => write!(f, "editing"),
39            PlanStatus::Executing => write!(f, "executing"),
40            PlanStatus::Completed => write!(f, "completed"),
41            PlanStatus::Failed => write!(f, "failed"),
42            PlanStatus::Cancelled => write!(f, "cancelled"),
43        }
44    }
45}
46
47/// Status of an individual plan step.
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum StepStatus {
51    #[default]
52    Pending,
53    InProgress,
54    Completed,
55    Failed,
56    Skipped,
57}
58
59impl std::fmt::Display for StepStatus {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            StepStatus::Pending => write!(f, "pending"),
63            StepStatus::InProgress => write!(f, "in progress"),
64            StepStatus::Completed => write!(f, "completed"),
65            StepStatus::Failed => write!(f, "failed"),
66            StepStatus::Skipped => write!(f, "skipped"),
67        }
68    }
69}
70
71/// A single step in an execution plan.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct PlanStep {
74    /// Step index (0-based).
75    pub index: usize,
76    /// Human-readable description of what this step does.
77    pub description: String,
78    /// Tool to invoke, if any.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub tool: Option<String>,
81    /// Arguments for the tool, if known ahead of time.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub tool_args: Option<serde_json::Value>,
84    /// Indices of steps that must complete before this one.
85    #[serde(default)]
86    pub depends_on: Vec<usize>,
87    /// Current status of this step.
88    #[serde(default)]
89    pub status: StepStatus,
90    /// Result text after execution.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub result: Option<String>,
93    /// Risk level of this step, if known.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub risk_level: Option<RiskLevel>,
96    /// Whether this step needs explicit user approval before execution.
97    #[serde(default)]
98    pub requires_approval: bool,
99}
100
101impl Default for PlanStep {
102    fn default() -> Self {
103        Self {
104            index: 0,
105            description: String::new(),
106            tool: None,
107            tool_args: None,
108            depends_on: Vec::new(),
109            status: StepStatus::Pending,
110            result: None,
111            risk_level: None,
112            requires_approval: false,
113        }
114    }
115}
116
117/// An alternative approach the LLM considered but did not choose.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct PlanAlternative {
120    pub name: String,
121    pub description: String,
122    #[serde(default)]
123    pub reason_not_chosen: String,
124    #[serde(default)]
125    pub estimated_steps: usize,
126}
127
128/// A structured multi-step execution plan.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct ExecutionPlan {
131    /// Unique identifier for this plan.
132    pub id: Uuid,
133    /// The user's original goal/task.
134    pub goal: String,
135    /// Brief summary of the plan approach.
136    pub summary: String,
137    /// Ordered list of steps.
138    pub steps: Vec<PlanStep>,
139    /// Alternative approaches considered.
140    #[serde(default)]
141    pub alternatives: Vec<PlanAlternative>,
142    /// Questions the LLM wants to ask the user for clarification.
143    #[serde(default)]
144    pub clarifications: Vec<String>,
145    /// Overall plan status.
146    pub status: PlanStatus,
147    /// When the plan was created.
148    pub created_at: DateTime<Utc>,
149    /// When the plan was last updated.
150    pub updated_at: DateTime<Utc>,
151    /// Index of the step currently executing (if any).
152    #[serde(default)]
153    pub current_step: Option<usize>,
154    /// Estimated LLM cost for executing this plan (USD).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub estimated_cost: Option<f64>,
157    /// Whether this plan was generated via the LLM council.
158    #[serde(default)]
159    pub council_generated: bool,
160}
161
162impl ExecutionPlan {
163    /// Create a new plan for a given goal.
164    pub fn new(goal: impl Into<String>, summary: impl Into<String>) -> Self {
165        let now = Utc::now();
166        Self {
167            id: Uuid::new_v4(),
168            goal: goal.into(),
169            summary: summary.into(),
170            steps: Vec::new(),
171            alternatives: Vec::new(),
172            clarifications: Vec::new(),
173            status: PlanStatus::Generating,
174            created_at: now,
175            updated_at: now,
176            current_step: None,
177            estimated_cost: None,
178            council_generated: false,
179        }
180    }
181
182    /// Find the next step that is pending and has all dependencies met.
183    pub fn next_pending_step(&self) -> Option<usize> {
184        for step in &self.steps {
185            if step.status == StepStatus::Pending && self.dependencies_met(step.index) {
186                return Some(step.index);
187            }
188        }
189        None
190    }
191
192    /// Check whether all dependencies for a given step are satisfied.
193    pub fn dependencies_met(&self, step_index: usize) -> bool {
194        if let Some(step) = self.steps.get(step_index) {
195            step.depends_on.iter().all(|&dep| {
196                self.steps
197                    .get(dep)
198                    .map(|s| s.status == StepStatus::Completed)
199                    .unwrap_or(false)
200            })
201        } else {
202            false
203        }
204    }
205
206    /// Mark a step as completed with its result.
207    pub fn complete_step(&mut self, step_index: usize, result: impl Into<String>) {
208        if let Some(step) = self.steps.get_mut(step_index) {
209            step.status = StepStatus::Completed;
210            step.result = Some(result.into());
211            self.updated_at = Utc::now();
212        }
213    }
214
215    /// Mark a step as failed with an error message.
216    pub fn fail_step(&mut self, step_index: usize, error: impl Into<String>) {
217        if let Some(step) = self.steps.get_mut(step_index) {
218            step.status = StepStatus::Failed;
219            step.result = Some(error.into());
220            self.updated_at = Utc::now();
221        }
222    }
223
224    /// Return a human-readable progress summary (e.g., "3/5 steps completed").
225    pub fn progress_summary(&self) -> String {
226        let completed = self
227            .steps
228            .iter()
229            .filter(|s| s.status == StepStatus::Completed)
230            .count();
231        let failed = self
232            .steps
233            .iter()
234            .filter(|s| s.status == StepStatus::Failed)
235            .count();
236        let total = self.steps.len();
237
238        if failed > 0 {
239            format!(
240                "{}/{} steps completed ({} failed) — {}",
241                completed, total, failed, self.status
242            )
243        } else {
244            format!("{}/{} steps completed — {}", completed, total, self.status)
245        }
246    }
247}
248
249impl std::fmt::Display for ExecutionPlan {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        writeln!(f, "Plan: {}", self.goal)?;
252        writeln!(f, "Summary: {}", self.summary)?;
253        writeln!(f, "Status: {}", self.status)?;
254        writeln!(f)?;
255
256        for step in &self.steps {
257            let icon = match step.status {
258                StepStatus::Pending => "○",
259                StepStatus::InProgress => "●",
260                StepStatus::Completed => "✓",
261                StepStatus::Failed => "✗",
262                StepStatus::Skipped => "⊘",
263            };
264
265            let tool_info = step
266                .tool
267                .as_deref()
268                .map(|t| format!(" [{}]", t))
269                .unwrap_or_default();
270
271            let risk_badge = step
272                .risk_level
273                .as_ref()
274                .map(|r| format!(" ({})", r))
275                .unwrap_or_default();
276
277            let approval = if step.requires_approval {
278                " ⚠ approval"
279            } else {
280                ""
281            };
282
283            writeln!(
284                f,
285                "  {} {}. {}{}{}{}",
286                icon,
287                step.index + 1,
288                step.description,
289                tool_info,
290                risk_badge,
291                approval
292            )?;
293        }
294
295        if !self.alternatives.is_empty() {
296            writeln!(f)?;
297            writeln!(f, "Alternatives considered:")?;
298            for alt in &self.alternatives {
299                writeln!(f, "  - {} ({})", alt.name, alt.reason_not_chosen)?;
300            }
301        }
302
303        if let Some(cost) = self.estimated_cost {
304            writeln!(f)?;
305            writeln!(f, "Estimated cost: ${:.4}", cost)?;
306        }
307
308        Ok(())
309    }
310}
311
312/// User decision on a plan under review.
313#[derive(Debug, Clone)]
314pub enum PlanDecision {
315    /// Approve the plan and start execution.
316    Approve,
317    /// Reject the plan entirely.
318    Reject,
319    /// Edit a specific step's description.
320    EditStep(usize, String),
321    /// Remove a step by index.
322    RemoveStep(usize),
323    /// Add a new step at the given index with the given description.
324    AddStep(usize, String),
325    /// Reorder steps (new index ordering).
326    ReorderSteps(Vec<usize>),
327    /// Ask a question about the plan.
328    AskQuestion(String),
329}
330
331/// System prompt addendum that instructs the LLM to output structured JSON plans.
332pub const PLAN_GENERATION_PROMPT: &str = r#"You are generating a structured execution plan. Respond ONLY with valid JSON (no markdown fences, no extra text).
333
334The JSON must have this structure:
335{
336  "summary": "Brief 1-2 sentence summary of the approach",
337  "steps": [
338    {
339      "description": "Human-readable description of what this step does",
340      "tool": "tool_name or null if no specific tool",
341      "tool_args": { ... } or null if tool arguments should be determined at execution time,
342      "depends_on": [0, 1] (indices of prerequisite steps, empty array if none),
343      "risk_level": "read_only" | "write" | "execute" | "network" | "destructive" | null,
344      "requires_approval": false
345    }
346  ],
347  "alternatives": [
348    {
349      "name": "Alternative approach name",
350      "description": "What it would do differently",
351      "reason_not_chosen": "Why the chosen approach is better",
352      "estimated_steps": 5
353    }
354  ],
355  "clarifications": ["Question for the user if anything is unclear"],
356  "estimated_cost": 0.05
357}
358
359Guidelines:
360- Keep plans concise: prefer fewer, well-described steps over many granular ones
361- Maximum 20 steps
362- Set requires_approval=true for destructive or irreversible operations
363- Include tool_args only when you are confident about the values
364- Use depends_on to express execution order dependencies
365- List alternatives only when meaningfully different approaches exist
366- Include clarifications only for genuinely ambiguous requirements
367"#;
368
369/// Parse LLM-generated plan JSON into an `ExecutionPlan`.
370///
371/// Handles common LLM output quirks:
372/// - Strips markdown code fences (```json ... ```)
373/// - Handles trailing commas (via a lenient approach)
374/// - Falls back to a single-step plan if parsing fails entirely
375pub fn parse_plan_json(text: &str, goal: &str) -> ExecutionPlan {
376    // Strip markdown code fences if present
377    let cleaned = strip_code_fences(text);
378
379    // Try parsing as JSON
380    match serde_json::from_str::<serde_json::Value>(&cleaned) {
381        Ok(value) => build_plan_from_json(value, goal),
382        Err(_) => {
383            // Try stripping trailing commas (common LLM mistake)
384            let fixed = strip_trailing_commas(&cleaned);
385            match serde_json::from_str::<serde_json::Value>(&fixed) {
386                Ok(value) => build_plan_from_json(value, goal),
387                Err(_) => fallback_single_step_plan(goal, text),
388            }
389        }
390    }
391}
392
393/// Strip markdown code fences from LLM output.
394fn strip_code_fences(text: &str) -> String {
395    let trimmed = text.trim();
396    if let Some(rest) = trimmed.strip_prefix("```json")
397        && let Some(inner) = rest.strip_suffix("```")
398    {
399        return inner.trim().to_string();
400    }
401    if let Some(rest) = trimmed.strip_prefix("```")
402        && let Some(inner) = rest.strip_suffix("```")
403    {
404        return inner.trim().to_string();
405    }
406    trimmed.to_string()
407}
408
409/// Remove trailing commas before } or ] (common LLM JSON mistake).
410fn strip_trailing_commas(text: &str) -> String {
411    let mut result = String::with_capacity(text.len());
412    let chars: Vec<char> = text.chars().collect();
413    let len = chars.len();
414
415    let mut i = 0;
416    while i < len {
417        if chars[i] == ',' {
418            // Look ahead past whitespace for } or ]
419            let mut j = i + 1;
420            while j < len && chars[j].is_whitespace() {
421                j += 1;
422            }
423            if j < len && (chars[j] == '}' || chars[j] == ']') {
424                // Skip the comma
425                i += 1;
426                continue;
427            }
428        }
429        result.push(chars[i]);
430        i += 1;
431    }
432    result
433}
434
435/// Build an ExecutionPlan from parsed JSON.
436fn build_plan_from_json(value: serde_json::Value, goal: &str) -> ExecutionPlan {
437    let summary = value["summary"]
438        .as_str()
439        .unwrap_or("Plan generated by LLM")
440        .to_string();
441
442    let mut steps = Vec::new();
443    if let Some(step_arr) = value["steps"].as_array() {
444        for (i, step_val) in step_arr.iter().enumerate() {
445            let desc = step_val["description"]
446                .as_str()
447                .unwrap_or("(no description)")
448                .to_string();
449
450            let tool = step_val["tool"].as_str().map(|s| s.to_string());
451
452            let tool_args = if step_val["tool_args"].is_null() {
453                None
454            } else {
455                Some(step_val["tool_args"].clone())
456            };
457
458            let depends_on = step_val["depends_on"]
459                .as_array()
460                .map(|arr| {
461                    arr.iter()
462                        .filter_map(|v| v.as_u64().map(|n| n as usize))
463                        .collect()
464                })
465                .unwrap_or_default();
466
467            let risk_level = step_val["risk_level"].as_str().and_then(parse_risk_level);
468
469            let requires_approval = step_val["requires_approval"].as_bool().unwrap_or(false);
470
471            steps.push(PlanStep {
472                index: i,
473                description: desc,
474                tool,
475                tool_args,
476                depends_on,
477                status: StepStatus::Pending,
478                result: None,
479                risk_level,
480                requires_approval,
481            });
482        }
483    }
484
485    let mut alternatives = Vec::new();
486    if let Some(alt_arr) = value["alternatives"].as_array() {
487        for alt_val in alt_arr {
488            alternatives.push(PlanAlternative {
489                name: alt_val["name"]
490                    .as_str()
491                    .unwrap_or("Alternative")
492                    .to_string(),
493                description: alt_val["description"].as_str().unwrap_or("").to_string(),
494                reason_not_chosen: alt_val["reason_not_chosen"]
495                    .as_str()
496                    .unwrap_or("")
497                    .to_string(),
498                estimated_steps: alt_val["estimated_steps"].as_u64().unwrap_or(0) as usize,
499            });
500        }
501    }
502
503    let clarifications = value["clarifications"]
504        .as_array()
505        .map(|arr| {
506            arr.iter()
507                .filter_map(|v| v.as_str().map(|s| s.to_string()))
508                .collect()
509        })
510        .unwrap_or_default();
511
512    let estimated_cost = value["estimated_cost"].as_f64();
513
514    let now = Utc::now();
515    ExecutionPlan {
516        id: Uuid::new_v4(),
517        goal: goal.to_string(),
518        summary,
519        steps,
520        alternatives,
521        clarifications,
522        status: PlanStatus::PendingReview,
523        created_at: now,
524        updated_at: now,
525        current_step: None,
526        estimated_cost,
527        council_generated: false,
528    }
529}
530
531/// Parse a risk level string from JSON.
532fn parse_risk_level(s: &str) -> Option<RiskLevel> {
533    match s {
534        "read_only" => Some(RiskLevel::ReadOnly),
535        "write" => Some(RiskLevel::Write),
536        "execute" => Some(RiskLevel::Execute),
537        "network" => Some(RiskLevel::Network),
538        "destructive" => Some(RiskLevel::Destructive),
539        _ => None,
540    }
541}
542
543/// Create a fallback single-step plan when JSON parsing fails.
544fn fallback_single_step_plan(goal: &str, raw_text: &str) -> ExecutionPlan {
545    let now = Utc::now();
546    let description = if raw_text.len() > 200 {
547        format!("Execute task as described: {}...", &raw_text[..200])
548    } else {
549        format!("Execute task as described: {}", raw_text)
550    };
551
552    ExecutionPlan {
553        id: Uuid::new_v4(),
554        goal: goal.to_string(),
555        summary: "Single-step execution (plan parsing failed, using fallback)".to_string(),
556        steps: vec![PlanStep {
557            index: 0,
558            description,
559            tool: None,
560            tool_args: None,
561            depends_on: Vec::new(),
562            status: StepStatus::Pending,
563            result: None,
564            risk_level: None,
565            requires_approval: false,
566        }],
567        alternatives: Vec::new(),
568        clarifications: Vec::new(),
569        status: PlanStatus::PendingReview,
570        created_at: now,
571        updated_at: now,
572        current_step: None,
573        estimated_cost: None,
574        council_generated: false,
575    }
576}
577
578/// Plan mode configuration.
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct PlanConfig {
581    /// Whether plan mode is enabled by default.
582    pub enabled: bool,
583    /// Whether to use the LLM council for plan generation.
584    pub use_council: bool,
585    /// Maximum number of steps in a plan.
586    pub max_steps: usize,
587    /// Whether to auto-approve read-only steps.
588    pub auto_approve_readonly: bool,
589}
590
591impl Default for PlanConfig {
592    fn default() -> Self {
593        Self {
594            enabled: false,
595            use_council: false,
596            max_steps: 20,
597            auto_approve_readonly: false,
598        }
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[test]
607    fn test_plan_status_display() {
608        assert_eq!(PlanStatus::Generating.to_string(), "generating");
609        assert_eq!(PlanStatus::PendingReview.to_string(), "pending review");
610        assert_eq!(PlanStatus::Executing.to_string(), "executing");
611        assert_eq!(PlanStatus::Completed.to_string(), "completed");
612        assert_eq!(PlanStatus::Failed.to_string(), "failed");
613        assert_eq!(PlanStatus::Cancelled.to_string(), "cancelled");
614    }
615
616    #[test]
617    fn test_step_status_default() {
618        assert_eq!(StepStatus::default(), StepStatus::Pending);
619    }
620
621    #[test]
622    fn test_plan_step_default() {
623        let step = PlanStep::default();
624        assert_eq!(step.index, 0);
625        assert!(step.description.is_empty());
626        assert!(step.tool.is_none());
627        assert!(step.tool_args.is_none());
628        assert!(step.depends_on.is_empty());
629        assert_eq!(step.status, StepStatus::Pending);
630        assert!(step.result.is_none());
631        assert!(!step.requires_approval);
632    }
633
634    #[test]
635    fn test_execution_plan_new() {
636        let plan = ExecutionPlan::new("Refactor auth module", "Split into separate files");
637        assert_eq!(plan.goal, "Refactor auth module");
638        assert_eq!(plan.summary, "Split into separate files");
639        assert_eq!(plan.status, PlanStatus::Generating);
640        assert!(plan.steps.is_empty());
641        assert!(plan.current_step.is_none());
642    }
643
644    #[test]
645    fn test_next_pending_step() {
646        let mut plan = ExecutionPlan::new("test", "test");
647        plan.steps = vec![
648            PlanStep {
649                index: 0,
650                description: "Step 0".into(),
651                status: StepStatus::Completed,
652                ..Default::default()
653            },
654            PlanStep {
655                index: 1,
656                description: "Step 1".into(),
657                depends_on: vec![0],
658                ..Default::default()
659            },
660            PlanStep {
661                index: 2,
662                description: "Step 2".into(),
663                depends_on: vec![1],
664                ..Default::default()
665            },
666        ];
667
668        // Step 0 is completed, step 1 depends on 0 (met), step 2 depends on 1 (not met)
669        assert_eq!(plan.next_pending_step(), Some(1));
670    }
671
672    #[test]
673    fn test_next_pending_step_no_deps() {
674        let mut plan = ExecutionPlan::new("test", "test");
675        plan.steps = vec![
676            PlanStep {
677                index: 0,
678                description: "Step 0".into(),
679                ..Default::default()
680            },
681            PlanStep {
682                index: 1,
683                description: "Step 1".into(),
684                ..Default::default()
685            },
686        ];
687
688        assert_eq!(plan.next_pending_step(), Some(0));
689    }
690
691    #[test]
692    fn test_next_pending_step_all_completed() {
693        let mut plan = ExecutionPlan::new("test", "test");
694        plan.steps = vec![PlanStep {
695            index: 0,
696            description: "Step 0".into(),
697            status: StepStatus::Completed,
698            ..Default::default()
699        }];
700
701        assert_eq!(plan.next_pending_step(), None);
702    }
703
704    #[test]
705    fn test_dependencies_met() {
706        let mut plan = ExecutionPlan::new("test", "test");
707        plan.steps = vec![
708            PlanStep {
709                index: 0,
710                status: StepStatus::Completed,
711                ..Default::default()
712            },
713            PlanStep {
714                index: 1,
715                depends_on: vec![0],
716                ..Default::default()
717            },
718            PlanStep {
719                index: 2,
720                depends_on: vec![0, 1],
721                ..Default::default()
722            },
723        ];
724
725        assert!(plan.dependencies_met(0)); // no deps
726        assert!(plan.dependencies_met(1)); // dep 0 is completed
727        assert!(!plan.dependencies_met(2)); // dep 1 is still pending
728    }
729
730    #[test]
731    fn test_complete_step() {
732        let mut plan = ExecutionPlan::new("test", "test");
733        plan.steps = vec![PlanStep {
734            index: 0,
735            description: "Read file".into(),
736            ..Default::default()
737        }];
738
739        plan.complete_step(0, "File contents: hello world");
740        assert_eq!(plan.steps[0].status, StepStatus::Completed);
741        assert_eq!(
742            plan.steps[0].result.as_deref(),
743            Some("File contents: hello world")
744        );
745    }
746
747    #[test]
748    fn test_fail_step() {
749        let mut plan = ExecutionPlan::new("test", "test");
750        plan.steps = vec![PlanStep {
751            index: 0,
752            description: "Write file".into(),
753            ..Default::default()
754        }];
755
756        plan.fail_step(0, "Permission denied");
757        assert_eq!(plan.steps[0].status, StepStatus::Failed);
758        assert_eq!(plan.steps[0].result.as_deref(), Some("Permission denied"));
759    }
760
761    #[test]
762    fn test_progress_summary() {
763        let mut plan = ExecutionPlan::new("test", "test");
764        plan.status = PlanStatus::Executing;
765        plan.steps = vec![
766            PlanStep {
767                index: 0,
768                status: StepStatus::Completed,
769                ..Default::default()
770            },
771            PlanStep {
772                index: 1,
773                status: StepStatus::Completed,
774                ..Default::default()
775            },
776            PlanStep {
777                index: 2,
778                status: StepStatus::Failed,
779                ..Default::default()
780            },
781            PlanStep {
782                index: 3,
783                ..Default::default()
784            },
785        ];
786
787        let summary = plan.progress_summary();
788        assert!(summary.contains("2/4"));
789        assert!(summary.contains("1 failed"));
790    }
791
792    #[test]
793    fn test_progress_summary_no_failures() {
794        let mut plan = ExecutionPlan::new("test", "test");
795        plan.status = PlanStatus::Executing;
796        plan.steps = vec![
797            PlanStep {
798                index: 0,
799                status: StepStatus::Completed,
800                ..Default::default()
801            },
802            PlanStep {
803                index: 1,
804                ..Default::default()
805            },
806        ];
807
808        let summary = plan.progress_summary();
809        assert!(summary.contains("1/2"));
810        assert!(!summary.contains("failed"));
811    }
812
813    #[test]
814    fn test_parse_plan_json_valid() {
815        let json = r#"{
816            "summary": "Read and analyze a file",
817            "steps": [
818                {
819                    "description": "Read the source file",
820                    "tool": "file_read",
821                    "tool_args": {"path": "src/main.rs"},
822                    "depends_on": [],
823                    "risk_level": "read_only",
824                    "requires_approval": false
825                },
826                {
827                    "description": "Analyze the code structure",
828                    "tool": null,
829                    "tool_args": null,
830                    "depends_on": [0],
831                    "risk_level": null,
832                    "requires_approval": false
833                }
834            ],
835            "alternatives": [
836                {
837                    "name": "Use codebase_search",
838                    "description": "Search for patterns instead of reading the whole file",
839                    "reason_not_chosen": "Direct file read is more thorough",
840                    "estimated_steps": 3
841                }
842            ],
843            "clarifications": [],
844            "estimated_cost": 0.02
845        }"#;
846
847        let plan = parse_plan_json(json, "Analyze main.rs");
848        assert_eq!(plan.goal, "Analyze main.rs");
849        assert_eq!(plan.summary, "Read and analyze a file");
850        assert_eq!(plan.steps.len(), 2);
851        assert_eq!(plan.steps[0].tool.as_deref(), Some("file_read"));
852        assert_eq!(plan.steps[0].risk_level, Some(RiskLevel::ReadOnly));
853        assert_eq!(plan.steps[1].depends_on, vec![0]);
854        assert!(plan.steps[1].tool.is_none());
855        assert_eq!(plan.alternatives.len(), 1);
856        assert_eq!(plan.estimated_cost, Some(0.02));
857        assert_eq!(plan.status, PlanStatus::PendingReview);
858    }
859
860    #[test]
861    fn test_parse_plan_json_with_code_fences() {
862        let json = r#"```json
863{
864    "summary": "Simple plan",
865    "steps": [
866        {
867            "description": "Do something",
868            "tool": null,
869            "tool_args": null,
870            "depends_on": [],
871            "risk_level": null,
872            "requires_approval": false
873        }
874    ],
875    "alternatives": [],
876    "clarifications": []
877}
878```"#;
879
880        let plan = parse_plan_json(json, "Test task");
881        assert_eq!(plan.summary, "Simple plan");
882        assert_eq!(plan.steps.len(), 1);
883    }
884
885    #[test]
886    fn test_parse_plan_json_with_trailing_commas() {
887        let json = r#"{
888            "summary": "Plan with trailing commas",
889            "steps": [
890                {
891                    "description": "Step one",
892                    "tool": null,
893                    "tool_args": null,
894                    "depends_on": [],
895                    "risk_level": null,
896                    "requires_approval": false,
897                },
898            ],
899            "alternatives": [],
900            "clarifications": [],
901        }"#;
902
903        let plan = parse_plan_json(json, "Test");
904        assert_eq!(plan.steps.len(), 1);
905        assert_eq!(plan.steps[0].description, "Step one");
906    }
907
908    #[test]
909    fn test_parse_plan_json_invalid_fallback() {
910        let text = "This is not JSON at all, it's just a description of what to do.";
911        let plan = parse_plan_json(text, "Do something");
912        assert_eq!(plan.goal, "Do something");
913        assert_eq!(plan.steps.len(), 1);
914        assert!(plan.summary.contains("fallback"));
915        assert!(
916            plan.steps[0]
917                .description
918                .contains("Execute task as described")
919        );
920    }
921
922    #[test]
923    fn test_serialization_roundtrip() {
924        let mut plan = ExecutionPlan::new("Test goal", "Test summary");
925        plan.steps = vec![
926            PlanStep {
927                index: 0,
928                description: "Step 0".into(),
929                tool: Some("file_read".into()),
930                risk_level: Some(RiskLevel::ReadOnly),
931                ..Default::default()
932            },
933            PlanStep {
934                index: 1,
935                description: "Step 1".into(),
936                depends_on: vec![0],
937                requires_approval: true,
938                ..Default::default()
939            },
940        ];
941        plan.status = PlanStatus::PendingReview;
942
943        let json = serde_json::to_string(&plan).unwrap();
944        let deserialized: ExecutionPlan = serde_json::from_str(&json).unwrap();
945
946        assert_eq!(deserialized.goal, "Test goal");
947        assert_eq!(deserialized.steps.len(), 2);
948        assert_eq!(deserialized.steps[0].tool.as_deref(), Some("file_read"));
949        assert_eq!(deserialized.steps[1].depends_on, vec![0]);
950        assert!(deserialized.steps[1].requires_approval);
951    }
952
953    #[test]
954    fn test_plan_display() {
955        let mut plan = ExecutionPlan::new("Refactor auth", "Split into modules");
956        plan.status = PlanStatus::Executing;
957        plan.steps = vec![
958            PlanStep {
959                index: 0,
960                description: "Read auth.rs".into(),
961                tool: Some("file_read".into()),
962                status: StepStatus::Completed,
963                risk_level: Some(RiskLevel::ReadOnly),
964                ..Default::default()
965            },
966            PlanStep {
967                index: 1,
968                description: "Create auth/mod.rs".into(),
969                tool: Some("file_write".into()),
970                status: StepStatus::InProgress,
971                risk_level: Some(RiskLevel::Write),
972                requires_approval: true,
973                ..Default::default()
974            },
975            PlanStep {
976                index: 2,
977                description: "Update imports".into(),
978                status: StepStatus::Pending,
979                ..Default::default()
980            },
981        ];
982
983        let display = format!("{}", plan);
984        assert!(display.contains("Refactor auth"));
985        assert!(display.contains("Split into modules"));
986        assert!(display.contains("✓")); // completed
987        assert!(display.contains("●")); // in progress
988        assert!(display.contains("○")); // pending
989        assert!(display.contains("[file_read]"));
990        assert!(display.contains("approval"));
991    }
992
993    #[test]
994    fn test_plan_config_defaults() {
995        let config = PlanConfig::default();
996        assert!(!config.enabled);
997        assert!(!config.use_council);
998        assert_eq!(config.max_steps, 20);
999        assert!(!config.auto_approve_readonly);
1000    }
1001
1002    #[test]
1003    fn test_plan_config_serialization() {
1004        let config = PlanConfig {
1005            enabled: true,
1006            use_council: true,
1007            max_steps: 10,
1008            auto_approve_readonly: true,
1009        };
1010        let json = serde_json::to_string(&config).unwrap();
1011        let deserialized: PlanConfig = serde_json::from_str(&json).unwrap();
1012        assert!(deserialized.enabled);
1013        assert!(deserialized.use_council);
1014        assert_eq!(deserialized.max_steps, 10);
1015        assert!(deserialized.auto_approve_readonly);
1016    }
1017
1018    #[test]
1019    fn test_strip_code_fences() {
1020        assert_eq!(strip_code_fences("```json\n{\"a\":1}\n```"), "{\"a\":1}");
1021        assert_eq!(strip_code_fences("```\n{\"a\":1}\n```"), "{\"a\":1}");
1022        assert_eq!(strip_code_fences("{\"a\":1}"), "{\"a\":1}");
1023    }
1024
1025    #[test]
1026    fn test_strip_trailing_commas() {
1027        assert_eq!(strip_trailing_commas("{\"a\":1,}"), "{\"a\":1}");
1028        assert_eq!(strip_trailing_commas("[1,2,]"), "[1,2]");
1029        assert_eq!(strip_trailing_commas("{\"a\":[1,],}"), "{\"a\":[1]}");
1030        // Normal JSON should pass through unchanged
1031        assert_eq!(
1032            strip_trailing_commas("{\"a\":1,\"b\":2}"),
1033            "{\"a\":1,\"b\":2}"
1034        );
1035    }
1036
1037    #[test]
1038    fn test_parse_plan_json_minimal() {
1039        // LLM might return minimal JSON
1040        let json = r#"{"summary": "Do it", "steps": []}"#;
1041        let plan = parse_plan_json(json, "Goal");
1042        assert_eq!(plan.summary, "Do it");
1043        assert!(plan.steps.is_empty());
1044    }
1045
1046    #[test]
1047    fn test_complete_step_out_of_bounds() {
1048        let mut plan = ExecutionPlan::new("test", "test");
1049        plan.steps = vec![PlanStep {
1050            index: 0,
1051            ..Default::default()
1052        }];
1053        // Should not panic
1054        plan.complete_step(99, "result");
1055        assert_eq!(plan.steps[0].status, StepStatus::Pending);
1056    }
1057
1058    #[test]
1059    fn test_dependencies_met_out_of_bounds() {
1060        let plan = ExecutionPlan::new("test", "test");
1061        assert!(!plan.dependencies_met(99));
1062    }
1063}