use std::sync::Arc;
use async_trait::async_trait;
use meerkat_core::ToolDef;
use serde::Deserialize;
use serde_json::Value;
use crate::builtin::store::TaskStore;
use crate::builtin::types::{NewTask, TaskId, TaskPriority};
use crate::builtin::{BuiltinTool, BuiltinToolError};
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TaskCreateParams {
subject: String,
description: String,
#[serde(default)]
priority: Option<TaskPriority>,
#[serde(default)]
labels: Option<Vec<String>>,
#[serde(default)]
blocks: Option<Vec<String>>,
#[serde(default)]
owner: Option<String>,
#[serde(default)]
metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(default)]
blocked_by: Option<Vec<String>>,
}
pub struct TaskCreateTool {
store: Arc<dyn TaskStore>,
session_id: Option<String>,
}
impl TaskCreateTool {
pub fn new(store: Arc<dyn TaskStore>) -> Self {
Self {
store,
session_id: None,
}
}
pub fn with_session(store: Arc<dyn TaskStore>, session_id: String) -> Self {
Self {
store,
session_id: Some(session_id),
}
}
pub fn with_session_opt(store: Arc<dyn TaskStore>, session_id: Option<String>) -> Self {
Self { store, session_id }
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl BuiltinTool for TaskCreateTool {
fn name(&self) -> &'static str {
"task_create"
}
fn def(&self) -> ToolDef {
ToolDef {
name: "task_create".into(),
description: "Create a new task in the project task list".into(),
input_schema: crate::schema::schema_for::<TaskCreateParams>(),
}
}
fn default_enabled(&self) -> bool {
true
}
async fn call(&self, args: Value) -> Result<Value, BuiltinToolError> {
let params: TaskCreateParams = serde_json::from_value(args)
.map_err(|e| BuiltinToolError::InvalidArgs(e.to_string()))?;
let new_task = NewTask {
subject: params.subject,
description: params.description,
priority: params.priority,
labels: params.labels,
blocks: params
.blocks
.map(|ids| ids.into_iter().map(TaskId).collect()),
owner: params.owner,
metadata: params.metadata,
blocked_by: params
.blocked_by
.map(|ids| ids.into_iter().map(TaskId).collect()),
};
let task = self
.store
.create(new_task, self.session_id.as_deref())
.await
.map_err(|e| BuiltinToolError::TaskError(e.to_string()))?;
serde_json::to_value(&task).map_err(|e| BuiltinToolError::ExecutionFailed(e.to_string()))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::builtin::MemoryTaskStore;
use crate::builtin::types::{Task, TaskPriority, TaskStatus};
fn schema_resolve_ref<'a>(root: &'a Value, ref_path: &str) -> Option<&'a Value> {
let path = ref_path.strip_prefix("#/")?;
let mut current = root;
for part in path.split('/') {
current = current.get(part)?;
}
Some(current)
}
fn schema_allows_value(root: &Value, schema: &Value, expected: &Value) -> bool {
if let Some(const_value) = schema.get("const") {
return const_value == expected;
}
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
return values.contains(expected);
}
if let Some(ref_path) = schema.get("$ref").and_then(Value::as_str)
&& let Some(resolved) = schema_resolve_ref(root, ref_path)
{
return schema_allows_value(root, resolved, expected);
}
for key in ["anyOf", "oneOf", "allOf"] {
let Some(options) = schema.get(key).and_then(Value::as_array) else {
continue;
};
if options
.iter()
.any(|option| schema_allows_value(root, option, expected))
{
return true;
}
}
false
}
#[test]
fn test_task_create_tool_def() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store);
assert_eq!(tool.name(), "task_create");
let def = tool.def();
assert_eq!(def.name, "task_create");
assert_eq!(
def.description,
"Create a new task in the project task list"
);
let schema = &def.input_schema;
assert_eq!(schema["type"], "object");
let properties = &schema["properties"];
assert!(properties["subject"].is_object());
assert!(properties["description"].is_object());
assert!(properties["priority"].is_object());
assert!(properties["labels"].is_object());
assert!(properties["blocks"].is_object());
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&serde_json::json!("subject")));
assert!(required.contains(&serde_json::json!("description")));
assert!(schema_allows_value(
schema,
&properties["priority"],
&serde_json::json!("low")
));
assert!(schema_allows_value(
schema,
&properties["priority"],
&serde_json::json!("medium")
));
assert!(schema_allows_value(
schema,
&properties["priority"],
&serde_json::json!("high")
));
}
#[test]
fn test_task_create_default_enabled() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store);
assert!(tool.default_enabled());
}
#[tokio::test]
async fn test_task_create_basic() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store.clone());
let args = serde_json::json!({
"subject": "Implement feature X",
"description": "Add the new feature X to the system"
});
let result = tool.call(args).await.unwrap();
let task: Task = serde_json::from_value(result).unwrap();
assert_eq!(task.subject, "Implement feature X");
assert_eq!(task.description, "Add the new feature X to the system");
assert_eq!(task.status, TaskStatus::Pending);
assert_eq!(task.priority, TaskPriority::Medium); assert!(task.labels.is_empty());
assert!(task.blocks.is_empty());
assert!(!task.id.0.is_empty());
assert!(!task.created_at.is_empty());
assert!(!task.updated_at.is_empty());
assert!(task.created_by_session.is_none());
assert_eq!(store.len(), 1);
}
#[tokio::test]
async fn test_task_create_with_session() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::with_session(store.clone(), "test-session-123".to_string());
let args = serde_json::json!({
"subject": "Task with session",
"description": "This task was created by a session"
});
let result = tool.call(args).await.unwrap();
let task: Task = serde_json::from_value(result).unwrap();
assert_eq!(
task.created_by_session,
Some("test-session-123".to_string())
);
assert_eq!(
task.updated_by_session,
Some("test-session-123".to_string())
);
}
#[tokio::test]
async fn test_task_create_with_priority() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store.clone());
let args = serde_json::json!({
"subject": "Low priority task",
"description": "Not urgent",
"priority": "low"
});
let result = tool.call(args).await.unwrap();
let task: Task = serde_json::from_value(result).unwrap();
assert_eq!(task.priority, TaskPriority::Low);
let args = serde_json::json!({
"subject": "Medium priority task",
"description": "Normal urgency",
"priority": "medium"
});
let result = tool.call(args).await.unwrap();
let task: Task = serde_json::from_value(result).unwrap();
assert_eq!(task.priority, TaskPriority::Medium);
let args = serde_json::json!({
"subject": "High priority task",
"description": "Very urgent",
"priority": "high"
});
let result = tool.call(args).await.unwrap();
let task: Task = serde_json::from_value(result).unwrap();
assert_eq!(task.priority, TaskPriority::High);
}
#[tokio::test]
async fn test_task_create_with_labels() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store.clone());
let args = serde_json::json!({
"subject": "Labeled task",
"description": "This task has labels",
"labels": ["bug", "urgent", "backend"]
});
let result = tool.call(args).await.unwrap();
let task: Task = serde_json::from_value(result).unwrap();
assert_eq!(task.labels.len(), 3);
assert!(task.labels.contains(&"bug".to_string()));
assert!(task.labels.contains(&"urgent".to_string()));
assert!(task.labels.contains(&"backend".to_string()));
}
#[tokio::test]
async fn test_task_create_with_blocks() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store.clone());
let args = serde_json::json!({
"subject": "Blocking task",
"description": "This task blocks other tasks",
"blocks": ["01ARZ3NDEKTSV4RRFFQ69G5FAV", "01ARZ3NDEKTSV4RRFFQ69G5FAW"]
});
let result = tool.call(args).await.unwrap();
let task: Task = serde_json::from_value(result).unwrap();
assert_eq!(task.blocks.len(), 2);
assert_eq!(task.blocks[0].0, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
assert_eq!(task.blocks[1].0, "01ARZ3NDEKTSV4RRFFQ69G5FAW");
}
#[tokio::test]
async fn test_task_create_with_all_fields() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::with_session(store.clone(), "session-all".to_string());
let args = serde_json::json!({
"subject": "Complete task",
"description": "This task has all fields populated",
"priority": "high",
"labels": ["feature", "v2"],
"blocks": ["01ARZ3NDEKTSV4RRFFQ69G5FAV"]
});
let result = tool.call(args).await.unwrap();
let task: Task = serde_json::from_value(result).unwrap();
assert_eq!(task.subject, "Complete task");
assert_eq!(task.description, "This task has all fields populated");
assert_eq!(task.priority, TaskPriority::High);
assert_eq!(task.labels, vec!["feature", "v2"]);
assert_eq!(task.blocks.len(), 1);
assert_eq!(task.blocks[0].0, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
assert_eq!(task.created_by_session, Some("session-all".to_string()));
assert_eq!(task.status, TaskStatus::Pending);
}
#[tokio::test]
async fn test_task_create_invalid_priority() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store);
let args = serde_json::json!({
"subject": "Task with invalid priority",
"description": "Should fail",
"priority": "critical" });
let result = tool.call(args).await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
BuiltinToolError::InvalidArgs(msg) => {
assert!(msg.contains("Invalid priority"));
assert!(msg.contains("critical"));
assert!(msg.contains("low, medium, or high"));
}
_ => unreachable!("Expected InvalidArgs error, got {:?}", err),
}
}
#[tokio::test]
async fn test_task_create_missing_required_field() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store);
let args = serde_json::json!({
"description": "Task without subject"
});
let result = tool.call(args).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, BuiltinToolError::InvalidArgs(_)));
let args = serde_json::json!({
"subject": "Task without description"
});
let result = tool.call(args).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, BuiltinToolError::InvalidArgs(_)));
}
#[tokio::test]
async fn test_task_create_empty_args() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store);
let args = serde_json::json!({});
let result = tool.call(args).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, BuiltinToolError::InvalidArgs(_)));
}
#[tokio::test]
async fn test_task_create_empty_labels() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store.clone());
let args = serde_json::json!({
"subject": "Task with empty labels",
"description": "Labels array is empty",
"labels": []
});
let result = tool.call(args).await.unwrap();
let task: Task = serde_json::from_value(result).unwrap();
assert!(task.labels.is_empty());
}
#[tokio::test]
async fn test_task_create_multiple_tasks() {
let store = Arc::new(MemoryTaskStore::new());
let tool = TaskCreateTool::new(store.clone());
for i in 1..=5 {
let args = serde_json::json!({
"subject": format!("Task {}", i),
"description": format!("Description for task {}", i)
});
tool.call(args).await.unwrap();
}
assert_eq!(store.len(), 5);
let tasks = store.list().await.unwrap();
let subjects: Vec<_> = tasks.iter().map(|t| t.subject.as_str()).collect();
assert!(subjects.contains(&"Task 1"));
assert!(subjects.contains(&"Task 5"));
}
}