use crate::error::Result;
use crate::memory::store::Store;
use echo_core::agent::{Plan, PlanStore, PlanSummary};
use futures::future::BoxFuture;
use serde_json::json;
use std::sync::Arc;
pub struct SqlitePlanStore {
store: Arc<dyn Store>,
}
const PLAN_NAMESPACE: &[&str] = &["plans"];
impl SqlitePlanStore {
pub fn new(store: Arc<dyn Store>) -> Self {
Self { store }
}
fn plan_to_value(plan: &Plan) -> serde_json::Value {
json!({
"id": plan.id,
"version": plan.version,
"slug": plan.slug,
"steps": plan.steps,
"goal": plan.goal,
"parent_plan_id": plan.parent_plan_id,
"metadata": plan.metadata,
"created_at": plan.created_at,
"updated_at": plan.updated_at,
"content": plan.goal.clone().unwrap_or_default(),
})
}
}
impl PlanStore for SqlitePlanStore {
fn save_plan<'a>(&'a self, plan: &'a Plan) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let key = plan.id.as_deref().unwrap_or("unknown");
let value = Self::plan_to_value(plan);
self.store
.put(PLAN_NAMESPACE, key, value)
.await
.map_err(|e| crate::error::ReactError::Other(format!("save_plan: {}", e)))
})
}
fn load_plan<'a>(&'a self, plan_id: &'a str) -> BoxFuture<'a, Result<Option<Plan>>> {
Box::pin(async move {
let item = self
.store
.get(PLAN_NAMESPACE, plan_id)
.await
.map_err(|e| crate::error::ReactError::Other(format!("load_plan: {}", e)))?;
match item {
Some(item) => {
let plan = serde_json::from_value(item.value).map_err(|e| {
crate::error::ReactError::Other(format!("load_plan parse: {}", e))
})?;
Ok(Some(plan))
}
None => Ok(None),
}
})
}
fn load_plan_by_slug<'a>(&'a self, slug: &'a str) -> BoxFuture<'a, Result<Option<Plan>>> {
Box::pin(async move {
let results = self
.store
.search(PLAN_NAMESPACE, slug, 20)
.await
.map_err(|e| {
crate::error::ReactError::Other(format!("load_plan_by_slug: {}", e))
})?;
for item in results {
if let Ok(plan) = serde_json::from_value::<Plan>(item.value)
&& plan.slug.as_deref() == Some(slug)
{
return Ok(Some(plan));
}
}
Ok(None)
})
}
fn list_plans<'a>(&'a self, limit: usize) -> BoxFuture<'a, Result<Vec<PlanSummary>>> {
Box::pin(async move {
let results = self
.store
.search(PLAN_NAMESPACE, "", limit)
.await
.map_err(|e| crate::error::ReactError::Other(format!("list_plans: {}", e)))?;
let mut summaries = Vec::new();
for item in results {
if let Ok(plan) = serde_json::from_value::<Plan>(item.value) {
summaries.push(PlanSummary {
id: plan.id.clone().unwrap_or_default(),
slug: plan.slug.clone(),
goal: plan.goal.clone(),
version: plan.version,
total_steps: plan.steps.len(),
completed_steps: plan.completed_count(),
});
}
}
Ok(summaries)
})
}
fn delete_plan<'a>(&'a self, plan_id: &'a str) -> BoxFuture<'a, Result<bool>> {
Box::pin(async move {
self.store
.delete(PLAN_NAMESPACE, plan_id)
.await
.map_err(|e| crate::error::ReactError::Other(format!("delete_plan: {}", e)))
})
}
fn search_plans<'a>(
&'a self,
query: &'a str,
limit: usize,
) -> BoxFuture<'a, Result<Vec<PlanSummary>>> {
Box::pin(async move {
let results = self
.store
.search(PLAN_NAMESPACE, query, limit)
.await
.map_err(|e| crate::error::ReactError::Other(format!("search_plans: {}", e)))?;
let mut summaries = Vec::new();
for item in results {
if let Ok(plan) = serde_json::from_value::<Plan>(item.value) {
summaries.push(PlanSummary {
id: plan.id.clone().unwrap_or_default(),
slug: plan.slug.clone(),
goal: plan.goal.clone(),
version: plan.version,
total_steps: plan.steps.len(),
completed_steps: plan.completed_count(),
});
}
}
Ok(summaries)
})
}
}
pub fn generate_plan_slug() -> String {
const ADJECTIVES: &[&str] = &[
"swift", "bold", "calm", "dark", "fast", "keen", "lucky", "neat", "pure", "quiet", "rare",
"safe", "tidy", "vast", "warm", "wise", "zippy", "agile", "brave", "clean",
];
const NOUNS: &[&str] = &[
"fox", "wolf", "bear", "hawk", "lynx", "tiger", "eagle", "shark", "crane", "otter",
"raven", "whale", "panda", "cobra", "falcon", "badger", "heron", "moose", "robin", "stoat",
];
let adj = ADJECTIVES[fastrand::usize(0..ADJECTIVES.len())];
let noun = NOUNS[fastrand::usize(0..NOUNS.len())];
format!("{}-{}", adj, noun)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::store::InMemoryStore;
use echo_core::agent::{Plan, PlanStep};
fn sample_plan() -> Plan {
Plan::new(vec![
PlanStep::new("Analyze code structure"),
PlanStep::new("Optimize performance").with_dependencies(vec!["step_0".to_string()]),
])
.with_goal("Performance optimization")
.with_slug("test-plan")
}
#[tokio::test]
async fn test_save_and_load_plan() {
let store = Arc::new(InMemoryStore::new());
let plan_store = SqlitePlanStore::new(store);
let plan = sample_plan();
plan_store.save_plan(&plan).await.unwrap();
let plan_id = plan.id.as_deref().unwrap();
let loaded = plan_store.load_plan(plan_id).await.unwrap();
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.steps.len(), 2);
assert_eq!(loaded.goal.as_deref(), Some("Performance optimization"));
assert_eq!(loaded.steps[1].dependencies, vec!["step_0"]);
}
#[tokio::test]
async fn test_load_nonexistent_plan() {
let store = Arc::new(InMemoryStore::new());
let plan_store = SqlitePlanStore::new(store);
let loaded = plan_store.load_plan("nonexistent").await.unwrap();
assert!(loaded.is_none());
}
#[tokio::test]
async fn test_load_plan_by_slug() {
let store = Arc::new(InMemoryStore::new());
let plan_store = SqlitePlanStore::new(store);
let plan = sample_plan();
plan_store.save_plan(&plan).await.unwrap();
let loaded = plan_store.load_plan_by_slug("test-plan").await.unwrap();
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().slug.as_deref(), Some("test-plan"));
}
#[tokio::test]
async fn test_list_plans() {
let store = Arc::new(InMemoryStore::new());
let plan_store = SqlitePlanStore::new(store);
let p1 = Plan::new(vec![PlanStep::new("step A")]).with_slug("plan-a");
let p2 = Plan::new(vec![PlanStep::new("step B")]).with_slug("plan-b");
plan_store.save_plan(&p1).await.unwrap();
plan_store.save_plan(&p2).await.unwrap();
let summaries = plan_store.list_plans(10).await.unwrap();
assert_eq!(summaries.len(), 2);
}
#[tokio::test]
async fn test_delete_plan() {
let store = Arc::new(InMemoryStore::new());
let plan_store = SqlitePlanStore::new(store);
let plan = sample_plan();
plan_store.save_plan(&plan).await.unwrap();
let plan_id = plan.id.as_deref().unwrap();
let deleted = plan_store.delete_plan(plan_id).await.unwrap();
assert!(deleted);
let loaded = plan_store.load_plan(plan_id).await.unwrap();
assert!(loaded.is_none());
}
#[tokio::test]
async fn test_generate_slug() {
let slug = generate_plan_slug();
assert!(slug.contains('-'));
let parts: Vec<&str> = slug.split('-').collect();
assert_eq!(parts.len(), 2);
}
}