feat(plan): Add plan execution system with external tool support
Plan Execution System: - Add PlanStep, AccumulatedPlan types for multi-turn tool call accumulation - Implement AccumulatedPlanStatus for tracking plan lifecycle - Support selective approval of proposed tool calls before execution External Tools Integration: - Add ExternalToolDefinition and ExternalToolTransport to plugins crate - Extend ToolContext with external_tools registry - Add external_tool_to_llm_tool conversion for LLM compatibility JSON-RPC Communication: - Add jsonrpc crate for JSON-RPC 2.0 protocol support - Enable stdio-based communication with external tool servers UI & Engine Updates: - Add plan_panel.rs component for displaying accumulated plans - Wire plan mode into engine loop - Add plan mode integration tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
//! Planning mode tools for the Owlen agent
|
||||
//!
|
||||
//! Provides EnterPlanMode and ExitPlanMode tools that allow the agent
|
||||
//! to enter a planning phase where only read-only operations are allowed,
|
||||
//! and then present a plan for user approval.
|
||||
//! This crate provides two related planning features:
|
||||
//!
|
||||
//! 1. **Plan Documents** - Markdown files for describing implementation plans
|
||||
//! (EnterPlanMode/ExitPlanMode tools)
|
||||
//!
|
||||
//! 2. **Plan Execution** - Accumulating proposed tool calls across LLM turns
|
||||
//! for selective approval before execution (PlanStep, AccumulatedPlan)
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -10,6 +14,291 @@ use std::path::PathBuf;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ============================================================================
|
||||
// Plan Execution Types (Multi-turn tool call accumulation)
|
||||
// ============================================================================
|
||||
|
||||
/// A single proposed tool call in an accumulated plan
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanStep {
|
||||
/// Unique identifier for this step (matches LLM's tool_call_id)
|
||||
pub id: String,
|
||||
/// Which LLM turn proposed this step (1-indexed)
|
||||
pub turn: usize,
|
||||
/// Tool name to execute
|
||||
pub tool: String,
|
||||
/// Arguments for the tool call
|
||||
pub args: serde_json::Value,
|
||||
/// LLM's reasoning/rationale for this step (from response content)
|
||||
pub rationale: Option<String>,
|
||||
/// User's approval decision: None = pending, Some(true) = approved, Some(false) = rejected
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
impl PlanStep {
|
||||
/// Create a new pending plan step
|
||||
pub fn new(id: String, turn: usize, tool: String, args: serde_json::Value) -> Self {
|
||||
Self {
|
||||
id,
|
||||
turn,
|
||||
tool,
|
||||
args,
|
||||
rationale: None,
|
||||
approved: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the rationale for this step
|
||||
pub fn with_rationale(mut self, rationale: String) -> Self {
|
||||
self.rationale = Some(rationale);
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if this step is pending approval
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.approved.is_none()
|
||||
}
|
||||
|
||||
/// Check if this step is approved
|
||||
pub fn is_approved(&self) -> bool {
|
||||
self.approved == Some(true)
|
||||
}
|
||||
|
||||
/// Check if this step is rejected
|
||||
pub fn is_rejected(&self) -> bool {
|
||||
self.approved == Some(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of an accumulated plan
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum AccumulatedPlanStatus {
|
||||
/// Agent is proposing steps (accumulating)
|
||||
#[default]
|
||||
Accumulating,
|
||||
/// User is reviewing the plan
|
||||
Reviewing,
|
||||
/// Approved steps are being executed
|
||||
Executing,
|
||||
/// All approved steps have been executed
|
||||
Completed,
|
||||
/// Plan was cancelled by user
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// An accumulated plan of proposed tool calls
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccumulatedPlan {
|
||||
/// Unique identifier for this plan
|
||||
pub id: String,
|
||||
/// Optional user-provided name
|
||||
pub name: Option<String>,
|
||||
/// When the plan was started
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Current status
|
||||
pub status: AccumulatedPlanStatus,
|
||||
/// Accumulated steps across LLM turns
|
||||
pub steps: Vec<PlanStep>,
|
||||
/// Current LLM turn counter
|
||||
pub current_turn: usize,
|
||||
}
|
||||
|
||||
impl Default for AccumulatedPlan {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AccumulatedPlan {
|
||||
/// Create a new empty accumulated plan
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: None,
|
||||
created_at: Utc::now(),
|
||||
status: AccumulatedPlanStatus::Accumulating,
|
||||
steps: Vec::new(),
|
||||
current_turn: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a plan with a specific name
|
||||
pub fn with_name(name: String) -> Self {
|
||||
let mut plan = Self::new();
|
||||
plan.name = Some(name);
|
||||
plan
|
||||
}
|
||||
|
||||
/// Add a step to the plan
|
||||
pub fn add_step(&mut self, step: PlanStep) {
|
||||
self.steps.push(step);
|
||||
}
|
||||
|
||||
/// Start a new turn (increment turn counter)
|
||||
pub fn next_turn(&mut self) {
|
||||
self.current_turn += 1;
|
||||
}
|
||||
|
||||
/// Add a step for the current turn
|
||||
pub fn add_step_for_current_turn(&mut self, id: String, tool: String, args: serde_json::Value) {
|
||||
let step = PlanStep::new(id, self.current_turn, tool, args);
|
||||
self.steps.push(step);
|
||||
}
|
||||
|
||||
/// Add a step with rationale for the current turn
|
||||
pub fn add_step_with_rationale(
|
||||
&mut self,
|
||||
id: String,
|
||||
tool: String,
|
||||
args: serde_json::Value,
|
||||
rationale: String,
|
||||
) {
|
||||
let step = PlanStep::new(id, self.current_turn, tool, args).with_rationale(rationale);
|
||||
self.steps.push(step);
|
||||
}
|
||||
|
||||
/// Get all pending steps
|
||||
pub fn pending_steps(&self) -> Vec<&PlanStep> {
|
||||
self.steps.iter().filter(|s| s.is_pending()).collect()
|
||||
}
|
||||
|
||||
/// Get all approved steps
|
||||
pub fn approved_steps(&self) -> Vec<&PlanStep> {
|
||||
self.steps.iter().filter(|s| s.is_approved()).collect()
|
||||
}
|
||||
|
||||
/// Get all rejected steps
|
||||
pub fn rejected_steps(&self) -> Vec<&PlanStep> {
|
||||
self.steps.iter().filter(|s| s.is_rejected()).collect()
|
||||
}
|
||||
|
||||
/// Approve a step by ID
|
||||
pub fn approve_step(&mut self, id: &str) -> bool {
|
||||
if let Some(step) = self.steps.iter_mut().find(|s| s.id == id) {
|
||||
step.approved = Some(true);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject a step by ID
|
||||
pub fn reject_step(&mut self, id: &str) -> bool {
|
||||
if let Some(step) = self.steps.iter_mut().find(|s| s.id == id) {
|
||||
step.approved = Some(false);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Approve all pending steps
|
||||
pub fn approve_all(&mut self) {
|
||||
for step in &mut self.steps {
|
||||
if step.approved.is_none() {
|
||||
step.approved = Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject all pending steps
|
||||
pub fn reject_all(&mut self) {
|
||||
for step in &mut self.steps {
|
||||
if step.approved.is_none() {
|
||||
step.approved = Some(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if all steps have been decided (no pending)
|
||||
pub fn all_decided(&self) -> bool {
|
||||
self.steps.iter().all(|s| s.approved.is_some())
|
||||
}
|
||||
|
||||
/// Get step count by approval status
|
||||
pub fn counts(&self) -> (usize, usize, usize) {
|
||||
let pending = self.steps.iter().filter(|s| s.is_pending()).count();
|
||||
let approved = self.steps.iter().filter(|s| s.is_approved()).count();
|
||||
let rejected = self.steps.iter().filter(|s| s.is_rejected()).count();
|
||||
(pending, approved, rejected)
|
||||
}
|
||||
|
||||
/// Transition to reviewing status
|
||||
pub fn finalize(&mut self) {
|
||||
self.status = AccumulatedPlanStatus::Reviewing;
|
||||
}
|
||||
|
||||
/// Transition to executing status
|
||||
pub fn start_execution(&mut self) {
|
||||
self.status = AccumulatedPlanStatus::Executing;
|
||||
}
|
||||
|
||||
/// Transition to completed status
|
||||
pub fn complete(&mut self) {
|
||||
self.status = AccumulatedPlanStatus::Completed;
|
||||
}
|
||||
|
||||
/// Cancel the plan
|
||||
pub fn cancel(&mut self) {
|
||||
self.status = AccumulatedPlanStatus::Cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
/// User's approval decisions for plan steps
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanApproval {
|
||||
/// IDs of steps that were approved
|
||||
pub approved_ids: Vec<String>,
|
||||
/// IDs of steps that were rejected
|
||||
pub rejected_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl PlanApproval {
|
||||
/// Create a new approval with empty lists
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
approved_ids: Vec::new(),
|
||||
rejected_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an approval that approves all given IDs
|
||||
pub fn approve_all(ids: Vec<String>) -> Self {
|
||||
Self {
|
||||
approved_ids: ids,
|
||||
rejected_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an approval that rejects all given IDs
|
||||
pub fn reject_all(ids: Vec<String>) -> Self {
|
||||
Self {
|
||||
approved_ids: Vec::new(),
|
||||
rejected_ids: ids,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply this approval to a plan
|
||||
pub fn apply_to(&self, plan: &mut AccumulatedPlan) {
|
||||
for id in &self.approved_ids {
|
||||
plan.approve_step(id);
|
||||
}
|
||||
for id in &self.rejected_ids {
|
||||
plan.reject_step(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlanApproval {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plan Document Types (Original functionality)
|
||||
// ============================================================================
|
||||
|
||||
/// Agent mode - normal execution or planning
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum AgentMode {
|
||||
@@ -218,6 +507,75 @@ impl PlanManager {
|
||||
plans.sort();
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// AccumulatedPlan persistence methods
|
||||
// ========================================================================
|
||||
|
||||
/// Get the directory for accumulated plans (JSON files)
|
||||
fn accumulated_plans_dir(&self) -> PathBuf {
|
||||
self.plans_dir.join("accumulated")
|
||||
}
|
||||
|
||||
/// Save an accumulated plan to disk
|
||||
pub async fn save_accumulated_plan(&self, plan: &AccumulatedPlan) -> Result<PathBuf> {
|
||||
let dir = self.accumulated_plans_dir();
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
|
||||
let filename = format!("{}.json", plan.id);
|
||||
let path = dir.join(&filename);
|
||||
|
||||
let json = serde_json::to_string_pretty(plan)?;
|
||||
tokio::fs::write(&path, json).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Load an accumulated plan by ID
|
||||
pub async fn load_accumulated_plan(&self, id: &str) -> Result<AccumulatedPlan> {
|
||||
let path = self.accumulated_plans_dir().join(format!("{}.json", id));
|
||||
let content = tokio::fs::read_to_string(&path).await?;
|
||||
let plan: AccumulatedPlan = serde_json::from_str(&content)?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// List all accumulated plans
|
||||
pub async fn list_accumulated_plans(&self) -> Result<Vec<AccumulatedPlan>> {
|
||||
let dir = self.accumulated_plans_dir();
|
||||
let mut plans = Vec::new();
|
||||
|
||||
if !dir.exists() {
|
||||
return Ok(plans);
|
||||
}
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "json") {
|
||||
if let Ok(content) = tokio::fs::read_to_string(&path).await {
|
||||
if let Ok(plan) = serde_json::from_str(&content) {
|
||||
plans.push(plan);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
plans.sort_by(|a: &AccumulatedPlan, b: &AccumulatedPlan| {
|
||||
b.created_at.cmp(&a.created_at)
|
||||
});
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
/// Delete an accumulated plan
|
||||
pub async fn delete_accumulated_plan(&self, id: &str) -> Result<()> {
|
||||
let path = self.accumulated_plans_dir().join(format!("{}.json", id));
|
||||
if path.exists() {
|
||||
tokio::fs::remove_file(&path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter planning mode
|
||||
@@ -247,6 +605,11 @@ pub fn is_tool_allowed_in_plan_mode(tool_name: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use serde_json::json;
|
||||
|
||||
// ========================================================================
|
||||
// Plan Document Tests
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_plan() {
|
||||
@@ -288,4 +651,231 @@ mod tests {
|
||||
assert!(!mode.is_planning());
|
||||
assert!(mode.plan_file().is_none());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Plan Step Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_plan_step_new() {
|
||||
let step = PlanStep::new(
|
||||
"call_1".to_string(),
|
||||
1,
|
||||
"read".to_string(),
|
||||
json!({"path": "/src/main.rs"}),
|
||||
);
|
||||
|
||||
assert_eq!(step.id, "call_1");
|
||||
assert_eq!(step.turn, 1);
|
||||
assert_eq!(step.tool, "read");
|
||||
assert!(step.is_pending());
|
||||
assert!(!step.is_approved());
|
||||
assert!(!step.is_rejected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_step_with_rationale() {
|
||||
let step = PlanStep::new(
|
||||
"call_1".to_string(),
|
||||
1,
|
||||
"read".to_string(),
|
||||
json!({"path": "/src/main.rs"}),
|
||||
).with_rationale("Need to read the main entry point".to_string());
|
||||
|
||||
assert_eq!(step.rationale, Some("Need to read the main entry point".to_string()));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Accumulated Plan Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_accumulated_plan_new() {
|
||||
let plan = AccumulatedPlan::new();
|
||||
assert!(!plan.id.is_empty());
|
||||
assert!(plan.name.is_none());
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating);
|
||||
assert!(plan.steps.is_empty());
|
||||
assert_eq!(plan.current_turn, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accumulated_plan_with_name() {
|
||||
let plan = AccumulatedPlan::with_name("Fix bug #123".to_string());
|
||||
assert_eq!(plan.name, Some("Fix bug #123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accumulated_plan_add_steps() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
|
||||
plan.next_turn();
|
||||
plan.add_step_for_current_turn(
|
||||
"call_1".to_string(),
|
||||
"read".to_string(),
|
||||
json!({"path": "src/main.rs"}),
|
||||
);
|
||||
|
||||
plan.next_turn();
|
||||
plan.add_step_with_rationale(
|
||||
"call_2".to_string(),
|
||||
"edit".to_string(),
|
||||
json!({"path": "src/main.rs", "old": "foo", "new": "bar"}),
|
||||
"Fix the typo".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(plan.steps.len(), 2);
|
||||
assert_eq!(plan.steps[0].turn, 1);
|
||||
assert_eq!(plan.steps[1].turn, 2);
|
||||
assert!(plan.steps[1].rationale.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accumulated_plan_approval() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("call_2".to_string(), "write".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("call_3".to_string(), "bash".to_string(), json!({}));
|
||||
|
||||
// Initial state: all pending
|
||||
let (pending, approved, rejected) = plan.counts();
|
||||
assert_eq!((pending, approved, rejected), (3, 0, 0));
|
||||
|
||||
// Approve one, reject one
|
||||
plan.approve_step("call_1");
|
||||
plan.reject_step("call_3");
|
||||
|
||||
let (pending, approved, rejected) = plan.counts();
|
||||
assert_eq!((pending, approved, rejected), (1, 1, 1));
|
||||
|
||||
assert!(!plan.all_decided());
|
||||
|
||||
// Approve remaining
|
||||
plan.approve_step("call_2");
|
||||
assert!(plan.all_decided());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accumulated_plan_approve_all() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("call_2".to_string(), "write".to_string(), json!({}));
|
||||
|
||||
plan.approve_all();
|
||||
|
||||
assert!(plan.all_decided());
|
||||
assert_eq!(plan.approved_steps().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accumulated_plan_status_transitions() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating);
|
||||
|
||||
plan.finalize();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Reviewing);
|
||||
|
||||
plan.start_execution();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Executing);
|
||||
|
||||
plan.complete();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accumulated_plan_cancel() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.cancel();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Cancelled);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Plan Approval Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_plan_approval_apply() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("call_2".to_string(), "write".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("call_3".to_string(), "bash".to_string(), json!({}));
|
||||
|
||||
let approval = PlanApproval {
|
||||
approved_ids: vec!["call_1".to_string(), "call_2".to_string()],
|
||||
rejected_ids: vec!["call_3".to_string()],
|
||||
};
|
||||
|
||||
approval.apply_to(&mut plan);
|
||||
|
||||
assert!(plan.steps[0].is_approved());
|
||||
assert!(plan.steps[1].is_approved());
|
||||
assert!(plan.steps[2].is_rejected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_approval_helpers() {
|
||||
let ids = vec!["a".to_string(), "b".to_string()];
|
||||
|
||||
let approval = PlanApproval::approve_all(ids.clone());
|
||||
assert_eq!(approval.approved_ids, ids);
|
||||
assert!(approval.rejected_ids.is_empty());
|
||||
|
||||
let rejection = PlanApproval::reject_all(ids.clone());
|
||||
assert!(rejection.approved_ids.is_empty());
|
||||
assert_eq!(rejection.rejected_ids, ids);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Accumulated Plan Persistence Tests
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_accumulated_plan() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = PlanManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let mut plan = AccumulatedPlan::with_name("Test Plan".to_string());
|
||||
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({"path": "test.txt"}));
|
||||
plan.approve_step("call_1");
|
||||
|
||||
let path = manager.save_accumulated_plan(&plan).await.unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
let loaded = manager.load_accumulated_plan(&plan.id).await.unwrap();
|
||||
assert_eq!(loaded.id, plan.id);
|
||||
assert_eq!(loaded.name, Some("Test Plan".to_string()));
|
||||
assert_eq!(loaded.steps.len(), 1);
|
||||
assert!(loaded.steps[0].is_approved());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_accumulated_plans() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = PlanManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// Create two plans
|
||||
let plan1 = AccumulatedPlan::with_name("Plan 1".to_string());
|
||||
let plan2 = AccumulatedPlan::with_name("Plan 2".to_string());
|
||||
|
||||
manager.save_accumulated_plan(&plan1).await.unwrap();
|
||||
manager.save_accumulated_plan(&plan2).await.unwrap();
|
||||
|
||||
let plans = manager.list_accumulated_plans().await.unwrap();
|
||||
assert_eq!(plans.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_accumulated_plan() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = PlanManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let plan = AccumulatedPlan::new();
|
||||
let id = plan.id.clone();
|
||||
|
||||
manager.save_accumulated_plan(&plan).await.unwrap();
|
||||
assert!(manager.load_accumulated_plan(&id).await.is_ok());
|
||||
|
||||
manager.delete_accumulated_plan(&id).await.unwrap();
|
||||
assert!(manager.load_accumulated_plan(&id).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user