things3-cli 2.0.0

CLI tool for Things 3 with integrated MCP server
Documentation
use crate::mcp::{CallToolResult, Content, McpError, McpResult, ThingsMcpServer};
use serde_json::Value;
use std::str::FromStr;
use things3_core::models::ThingsId;

impl ThingsMcpServer {
    pub(in crate::mcp) async fn handle_get_projects(
        &self,
        args: Value,
    ) -> McpResult<CallToolResult> {
        let _area_uuid = args
            .get("area_uuid")
            .and_then(|v| v.as_str())
            .and_then(|s| ThingsId::from_str(s).ok());

        let projects = self
            .db
            .get_projects(None)
            .await
            .map_err(|e| McpError::database_operation_failed("get_projects", e))?;

        let json = serde_json::to_string_pretty(&projects)
            .map_err(|e| McpError::serialization_failed("get_projects serialization", e))?;

        Ok(CallToolResult {
            content: vec![Content::Text { text: json }],
            is_error: false,
        })
    }

    pub(in crate::mcp) async fn handle_create_project(
        &self,
        args: Value,
    ) -> McpResult<CallToolResult> {
        let title = args
            .get("title")
            .and_then(|v| v.as_str())
            .ok_or_else(|| McpError::invalid_parameter("title", "Project title is required"))?
            .to_string();

        let notes = args.get("notes").and_then(|v| v.as_str()).map(String::from);

        let area_uuid: Option<ThingsId> = args
            .get("area_uuid")
            .and_then(|v| v.as_str())
            .map(|s| {
                ThingsId::from_str(s).map_err(|e| {
                    McpError::invalid_parameter("area_uuid", format!("Invalid ID: {e}"))
                })
            })
            .transpose()?;

        let start_date = args
            .get("start_date")
            .and_then(|v| v.as_str())
            .map(|s| {
                chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| {
                    McpError::invalid_parameter("start_date", format!("Invalid date: {e}"))
                })
            })
            .transpose()?;

        let deadline = args
            .get("deadline")
            .and_then(|v| v.as_str())
            .map(|s| {
                chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| {
                    McpError::invalid_parameter("deadline", format!("Invalid date: {e}"))
                })
            })
            .transpose()?;

        let tags = args.get("tags").and_then(|v| v.as_array()).map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect::<Vec<_>>()
        });

        let request = things3_core::models::CreateProjectRequest {
            title,
            notes,
            area_uuid,
            start_date,
            deadline,
            tags,
        };

        let id = self
            .mutations
            .create_project(request)
            .await
            .map_err(|e| McpError::database_operation_failed("create_project", e))?;

        let response = serde_json::json!({
            "message": "Project created successfully",
            "uuid": id.to_string()
        });

        Ok(CallToolResult {
            content: vec![Content::Text {
                text: serde_json::to_string_pretty(&response)
                    .map_err(|e| McpError::serialization_failed("create_project response", e))?,
            }],
            is_error: false,
        })
    }

    pub(in crate::mcp) async fn handle_update_project(
        &self,
        args: Value,
    ) -> McpResult<CallToolResult> {
        let uuid_str = args
            .get("uuid")
            .and_then(|v| v.as_str())
            .ok_or_else(|| McpError::invalid_parameter("uuid", "UUID is required"))?;

        let id = ThingsId::from_str(uuid_str)
            .map_err(|e| McpError::invalid_parameter("uuid", format!("Invalid ID: {e}")))?;

        let title = args.get("title").and_then(|v| v.as_str()).map(String::from);
        let notes = args.get("notes").and_then(|v| v.as_str()).map(String::from);

        let area_uuid: Option<ThingsId> = args
            .get("area_uuid")
            .and_then(|v| v.as_str())
            .map(|s| {
                ThingsId::from_str(s).map_err(|e| {
                    McpError::invalid_parameter("area_uuid", format!("Invalid ID: {e}"))
                })
            })
            .transpose()?;

        let start_date = args
            .get("start_date")
            .and_then(|v| v.as_str())
            .map(|s| {
                chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| {
                    McpError::invalid_parameter("start_date", format!("Invalid date: {e}"))
                })
            })
            .transpose()?;

        let deadline = args
            .get("deadline")
            .and_then(|v| v.as_str())
            .map(|s| {
                chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| {
                    McpError::invalid_parameter("deadline", format!("Invalid date: {e}"))
                })
            })
            .transpose()?;

        let tags = args.get("tags").and_then(|v| v.as_array()).map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect::<Vec<_>>()
        });

        let request = things3_core::models::UpdateProjectRequest {
            uuid: id,
            title,
            notes,
            area_uuid,
            start_date,
            deadline,
            tags,
        };

        self.mutations
            .update_project(request)
            .await
            .map_err(|e| McpError::database_operation_failed("update_project", e))?;

        let response = serde_json::json!({
            "message": "Project updated successfully",
            "uuid": uuid_str
        });

        Ok(CallToolResult {
            content: vec![Content::Text {
                text: serde_json::to_string_pretty(&response)
                    .map_err(|e| McpError::serialization_failed("update_project response", e))?,
            }],
            is_error: false,
        })
    }

    pub(in crate::mcp) async fn handle_complete_project(
        &self,
        args: Value,
    ) -> McpResult<CallToolResult> {
        let uuid_str = args
            .get("uuid")
            .and_then(|v| v.as_str())
            .ok_or_else(|| McpError::invalid_parameter("uuid", "UUID is required"))?;

        let id = ThingsId::from_str(uuid_str)
            .map_err(|e| McpError::invalid_parameter("uuid", format!("Invalid ID: {e}")))?;

        let child_handling_str = args
            .get("child_handling")
            .and_then(|v| v.as_str())
            .unwrap_or("error");

        let child_handling = match child_handling_str {
            "cascade" => things3_core::models::ProjectChildHandling::Cascade,
            "orphan" => things3_core::models::ProjectChildHandling::Orphan,
            _ => things3_core::models::ProjectChildHandling::Error,
        };

        self.mutations
            .complete_project(&id, child_handling)
            .await
            .map_err(|e| McpError::database_operation_failed("complete_project", e))?;

        let response = serde_json::json!({
            "message": "Project completed successfully",
            "uuid": uuid_str
        });

        Ok(CallToolResult {
            content: vec![Content::Text {
                text: serde_json::to_string_pretty(&response)
                    .map_err(|e| McpError::serialization_failed("complete_project response", e))?,
            }],
            is_error: false,
        })
    }

    pub(in crate::mcp) async fn handle_delete_project(
        &self,
        args: Value,
    ) -> McpResult<CallToolResult> {
        let uuid_str = args
            .get("uuid")
            .and_then(|v| v.as_str())
            .ok_or_else(|| McpError::invalid_parameter("uuid", "UUID is required"))?;

        let id = ThingsId::from_str(uuid_str)
            .map_err(|e| McpError::invalid_parameter("uuid", format!("Invalid ID: {e}")))?;

        let child_handling_str = args
            .get("child_handling")
            .and_then(|v| v.as_str())
            .unwrap_or("error");

        let child_handling = match child_handling_str {
            "cascade" => things3_core::models::ProjectChildHandling::Cascade,
            "orphan" => things3_core::models::ProjectChildHandling::Orphan,
            _ => things3_core::models::ProjectChildHandling::Error,
        };

        self.mutations
            .delete_project(&id, child_handling)
            .await
            .map_err(|e| McpError::database_operation_failed("delete_project", e))?;

        let response = serde_json::json!({
            "message": "Project deleted successfully",
            "uuid": uuid_str
        });

        Ok(CallToolResult {
            content: vec![Content::Text {
                text: serde_json::to_string_pretty(&response)
                    .map_err(|e| McpError::serialization_failed("delete_project response", e))?,
            }],
            is_error: false,
        })
    }
}