butterfly-bot 0.8.0

Butterfly Bot is an opinionated personal-ops AI assistant built for people who want results, not setup overhead.
Documentation
use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::sync::RwLock;

use crate::error::{ButterflyBotError, Result};
use crate::interfaces::plugins::Tool;
use crate::todo::{default_todo_db_path, resolve_todo_db_path, TodoStatus, TodoStore};

pub struct TodoTool {
    sqlite_path: RwLock<Option<String>>,
    store: RwLock<Option<std::sync::Arc<TodoStore>>>,
}

impl Default for TodoTool {
    fn default() -> Self {
        Self::new()
    }
}

impl TodoTool {
    pub fn new() -> Self {
        Self {
            sqlite_path: RwLock::new(None),
            store: RwLock::new(None),
        }
    }

    async fn get_store(&self) -> Result<std::sync::Arc<TodoStore>> {
        if let Some(store) = self.store.read().await.as_ref() {
            return Ok(store.clone());
        }
        let path = self
            .sqlite_path
            .read()
            .await
            .clone()
            .unwrap_or_else(default_todo_db_path);
        let store = std::sync::Arc::new(TodoStore::new(path).await?);
        let mut guard = self.store.write().await;
        *guard = Some(store.clone());
        Ok(store)
    }
}

fn notes_with_explicit_sizing(
    notes: Option<&str>,
    t_shirt_size: Option<&str>,
    story_points: Option<i32>,
    estimate_optimistic_minutes: Option<i32>,
    estimate_likely_minutes: Option<i32>,
    estimate_pessimistic_minutes: Option<i32>,
) -> Option<String> {
    let mut chunks = Vec::new();
    if let Some(base) = notes {
        let trimmed = base.trim();
        if !trimmed.is_empty() {
            chunks.push(trimmed.to_string());
        }
    }

    if let Some(size) = t_shirt_size.map(|v| v.trim()).filter(|v| !v.is_empty()) {
        chunks.push(format!("T-Shirt Size: {}", size.to_ascii_uppercase()));
    }
    if let Some(points) = story_points.filter(|v| *v > 0) {
        chunks.push(format!("Story Points: {points}"));
    }
    if let Some(minutes) = estimate_likely_minutes.filter(|v| *v > 0) {
        chunks.push(format!("Time Estimate: {minutes} minutes"));
    }
    if let Some(minutes) = estimate_optimistic_minutes.filter(|v| *v > 0) {
        chunks.push(format!("Estimate Optimistic Minutes: {minutes}"));
    }
    if let Some(minutes) = estimate_pessimistic_minutes.filter(|v| *v > 0) {
        chunks.push(format!("Estimate Pessimistic Minutes: {minutes}"));
    }

    if chunks.is_empty() {
        None
    } else {
        Some(chunks.join(" | "))
    }
}

fn parse_dependency_refs(value: Option<&Value>) -> Vec<String> {
    let mut refs = Vec::new();
    if let Some(Value::Array(items)) = value {
        for item in items {
            if let Some(text) = item.as_str() {
                let trimmed = text.trim();
                if !trimmed.is_empty() {
                    refs.push(trimmed.to_ascii_lowercase());
                }
            }
        }
    }
    refs.sort();
    refs.dedup();
    refs
}

#[async_trait]
impl Tool for TodoTool {
    fn name(&self) -> &str {
        "todo"
    }

    fn description(&self) -> &str {
        "Manage an ordered todo list (create, list, reorder, complete, delete, clear)."
    }

    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["create", "list", "complete", "reopen", "delete", "clear", "reorder", "create_many"]
                },
                "user_id": { "type": "string" },
                "title": { "type": "string" },
                "notes": { "type": "string" },
                "t_shirt_size": { "type": "string", "enum": ["XS", "S", "M", "L", "XL", "XXL"] },
                "story_points": { "type": "integer" },
                "estimate_optimistic_minutes": { "type": "integer" },
                "estimate_likely_minutes": { "type": "integer" },
                "estimate_pessimistic_minutes": { "type": "integer" },
                "dependency_refs": { "type": "array", "items": { "type": "string" } },
                "items": {
                    "type": "array",
                    "items": {
                        "oneOf": [
                            {"type": "string"},
                            {"type": "object", "properties": {
                                "title": {"type": "string"},
                                "notes": {"type": "string"},
                                "t_shirt_size": { "type": "string", "enum": ["XS", "S", "M", "L", "XL", "XXL"] },
                                "story_points": { "type": "integer" },
                                "estimate_optimistic_minutes": { "type": "integer" },
                                "estimate_likely_minutes": { "type": "integer" },
                                "estimate_pessimistic_minutes": { "type": "integer" },
                                "dependency_refs": { "type": "array", "items": { "type": "string" } }
                            }}
                        ]
                    }
                },
                "status": { "type": "string", "enum": ["open", "completed", "all"] },
                "limit": { "type": "integer" },
                "id": { "type": "integer" },
                "ordered_ids": { "type": "array", "items": { "type": "integer" } }
            },
            "required": ["action", "user_id"]
        })
    }

    fn configure(&self, config: &Value) -> Result<()> {
        let path = resolve_todo_db_path(config);
        let mut guard = self
            .sqlite_path
            .try_write()
            .map_err(|_| ButterflyBotError::Runtime("Todo tool lock busy".to_string()))?;
        *guard = path;
        Ok(())
    }

    async fn execute(&self, params: Value) -> Result<Value> {
        let action = params
            .get("action")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let action = match action.as_str() {
            "add" | "new" => "create",
            "create_list" | "create_many" | "add_many" | "bulk_create" | "create_items" => {
                "create_many"
            }
            "clear_all" | "delete_all" | "remove_all" | "wipe" | "clean" => "clear",
            other => other,
        };
        let user_id = params
            .get("user_id")
            .and_then(|v| v.as_str())
            .ok_or_else(|| ButterflyBotError::Runtime("Missing user_id".to_string()))?;

        let store = self.get_store().await?;
        let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;

        match action {
            "create" => {
                let title = params
                    .get("title")
                    .and_then(|v| v.as_str())
                    .ok_or_else(|| ButterflyBotError::Runtime("Missing title".to_string()))?;
                let notes = params.get("notes").and_then(|v| v.as_str());
                let notes = notes_with_explicit_sizing(
                    notes,
                    params.get("t_shirt_size").and_then(|v| v.as_str()),
                    params
                        .get("story_points")
                        .and_then(|v| v.as_i64())
                        .map(|v| v as i32),
                    params
                        .get("estimate_optimistic_minutes")
                        .and_then(|v| v.as_i64())
                        .map(|v| v as i32),
                    params
                        .get("estimate_likely_minutes")
                        .and_then(|v| v.as_i64())
                        .map(|v| v as i32),
                    params
                        .get("estimate_pessimistic_minutes")
                        .and_then(|v| v.as_i64())
                        .map(|v| v as i32),
                );
                let dependency_refs = parse_dependency_refs(params.get("dependency_refs"));
                let item = store
                    .create_item(
                        user_id,
                        title,
                        notes.as_deref(),
                        if dependency_refs.is_empty() {
                            None
                        } else {
                            Some(dependency_refs.as_slice())
                        },
                    )
                    .await?;
                Ok(json!({"status": "ok", "item": item}))
            }
            "create_many" => {
                let items = params
                    .get("items")
                    .and_then(|v| v.as_array())
                    .ok_or_else(|| ButterflyBotError::Runtime("Missing items".to_string()))?;
                if items.is_empty() {
                    return Err(ButterflyBotError::Runtime("items empty".to_string()));
                }
                let mut created = Vec::new();
                for item in items {
                    match item {
                        Value::String(title) => {
                            let created_item =
                                store.create_item(user_id, title, None, None).await?;
                            created.push(created_item);
                        }
                        Value::Object(map) => {
                            let title =
                                map.get("title").and_then(|v| v.as_str()).ok_or_else(|| {
                                    ButterflyBotError::Runtime("Missing item title".to_string())
                                })?;
                            let notes = map.get("notes").and_then(|v| v.as_str());
                            let notes = notes_with_explicit_sizing(
                                notes,
                                map.get("t_shirt_size").and_then(|v| v.as_str()),
                                map.get("story_points")
                                    .and_then(|v| v.as_i64())
                                    .map(|v| v as i32),
                                map.get("estimate_optimistic_minutes")
                                    .and_then(|v| v.as_i64())
                                    .map(|v| v as i32),
                                map.get("estimate_likely_minutes")
                                    .and_then(|v| v.as_i64())
                                    .map(|v| v as i32),
                                map.get("estimate_pessimistic_minutes")
                                    .and_then(|v| v.as_i64())
                                    .map(|v| v as i32),
                            );
                            let dependency_refs = parse_dependency_refs(map.get("dependency_refs"));
                            let created_item = store
                                .create_item(
                                    user_id,
                                    title,
                                    notes.as_deref(),
                                    if dependency_refs.is_empty() {
                                        None
                                    } else {
                                        Some(dependency_refs.as_slice())
                                    },
                                )
                                .await?;
                            created.push(created_item);
                        }
                        _ => {
                            return Err(ButterflyBotError::Runtime(
                                "Invalid item format".to_string(),
                            ))
                        }
                    }
                }
                Ok(json!({"status": "ok", "items": created}))
            }
            "list" => {
                let status = TodoStatus::from_option(params.get("status").and_then(|v| v.as_str()));
                let items = store.list_items(user_id, status, limit).await?;
                Ok(json!({"status": "ok", "items": items}))
            }
            "complete" => {
                let id = params
                    .get("id")
                    .and_then(|v| v.as_i64())
                    .ok_or_else(|| ButterflyBotError::Runtime("Missing id".to_string()))?
                    as i32;
                let item = store.set_completed(id, true).await?;
                Ok(json!({"status": "ok", "item": item}))
            }
            "reopen" => {
                let id = params
                    .get("id")
                    .and_then(|v| v.as_i64())
                    .ok_or_else(|| ButterflyBotError::Runtime("Missing id".to_string()))?
                    as i32;
                let item = store.set_completed(id, false).await?;
                Ok(json!({"status": "ok", "item": item}))
            }
            "delete" => {
                let id = params
                    .get("id")
                    .and_then(|v| v.as_i64())
                    .ok_or_else(|| ButterflyBotError::Runtime("Missing id".to_string()))?
                    as i32;
                let deleted = store.delete_item(id).await?;
                Ok(json!({"status": "ok", "deleted": deleted}))
            }
            "clear" => {
                let status = TodoStatus::from_option(params.get("status").and_then(|v| v.as_str()));
                let deleted = store.clear_items(user_id, status).await?;
                Ok(json!({"status": "ok", "deleted": deleted}))
            }
            "reorder" => {
                let ordered_ids = params
                    .get("ordered_ids")
                    .and_then(|v| v.as_array())
                    .ok_or_else(|| ButterflyBotError::Runtime("Missing ordered_ids".to_string()))?;
                let ids: Vec<i32> = ordered_ids
                    .iter()
                    .filter_map(|value| value.as_i64().map(|v| v as i32))
                    .collect();
                if ids.is_empty() {
                    return Err(ButterflyBotError::Runtime("ordered_ids empty".to_string()));
                }
                store.reorder(user_id, &ids).await?;
                Ok(json!({"status": "ok"}))
            }
            _ => Err(ButterflyBotError::Runtime("Unsupported action".to_string())),
        }
    }
}