use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
use super::common::TaskSummary;
use crate::tasks::task_store::{TaskStore, TaskStoreError};
use crate::tasks::types::{TaskId, TaskUpdate};
fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
Ok(value.and_then(|s| {
let trimmed = s.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}))
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TaskCreateInput {
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none", alias = "parent_id")]
pub parent_id: Option<String>,
#[serde(default)]
pub assignee: Option<String>,
#[serde(default)]
pub deps: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TaskCreateOutput {
pub task: TaskSummary,
}
pub fn execute_task_create(input: &TaskCreateInput, store: &mut TaskStore) -> Result<TaskCreateOutput, TaskStoreError> {
let fields = TaskUpdate {
description: input.description.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned),
assignee: input.assignee.clone(),
deps: input.deps.as_ref().map(|deps| deps.iter().map(|id| TaskId::from(id.as_str())).collect()),
..Default::default()
};
let parent = input.parent_id.as_deref().map(TaskId::from);
let task = store.create(parent.as_ref(), &input.title, fields)?;
Ok(TaskCreateOutput { task: TaskSummary::from(&task) })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TestTaskStore;
use serde_json::json;
#[test]
fn test_create_root_task() {
let mut tasks = TestTaskStore::new();
let input = TaskCreateInput {
title: "Research AI agents".to_string(),
description: Some("Investigate multi-agent patterns".to_string()),
parent_id: None,
assignee: Some("orchestrator".to_string()),
deps: None,
};
let output = execute_task_create(&input, tasks.store_mut()).unwrap();
assert_eq!(output.task.title, "Research AI agents");
assert_eq!(output.task.assignee, Some("orchestrator".to_string()));
assert_eq!(output.task.parent, None);
}
#[test]
fn test_create_subtask() {
let mut tasks = TestTaskStore::new();
let root_input = TaskCreateInput {
title: "Root task".to_string(),
description: Some("Root task description".to_string()),
parent_id: None,
assignee: None,
deps: None,
};
let root = execute_task_create(&root_input, tasks.store_mut()).unwrap();
let sub_input = TaskCreateInput {
title: "Subtask 1".to_string(),
description: Some("Do something specific".to_string()),
parent_id: Some(root.task.id.clone()),
assignee: Some("worker-1".to_string()),
deps: None,
};
let output = execute_task_create(&sub_input, tasks.store_mut()).unwrap();
assert_eq!(output.task.title, "Subtask 1");
assert_eq!(output.task.parent, Some(root.task.id));
}
#[test]
fn test_create_with_deps() {
let mut tasks = TestTaskStore::new();
let root = tasks.root_task("Root");
let sub1 = tasks.subtask(&root.id, "Subtask 1");
let input = TaskCreateInput {
title: "Subtask 2".to_string(),
description: Some("Subtask with dependency".to_string()),
parent_id: Some(root.id.to_string()),
assignee: None,
deps: Some(vec![sub1.id.to_string()]),
};
let output = execute_task_create(&input, tasks.store_mut()).unwrap();
assert_eq!(output.task.deps, vec![sub1.id.to_string()]);
}
#[test]
fn test_parent_id_empty_string_deserializes_to_none() {
let input: TaskCreateInput = serde_json::from_value(json!({
"title": "Root via empty parent",
"description": "Should be root",
"parent_id": ""
}))
.unwrap();
assert_eq!(input.parent_id, None);
}
#[test]
fn test_parent_id_whitespace_deserializes_to_none() {
let input: TaskCreateInput = serde_json::from_value(json!({
"title": "Root via whitespace parent",
"description": "Should be root",
"parent_id": " "
}))
.unwrap();
assert_eq!(input.parent_id, None);
}
#[test]
fn test_description_can_be_omitted_for_root_task() {
let mut tasks = TestTaskStore::new();
let input: TaskCreateInput = serde_json::from_value(json!({
"title": "Root without description"
}))
.unwrap();
let output = execute_task_create(&input, tasks.store_mut()).unwrap();
let created = tasks.store().get(&TaskId::from(output.task.id.as_str())).unwrap();
assert_eq!(output.task.title, "Root without description");
assert_eq!(output.task.parent, None);
assert_eq!(created.description, None);
}
#[test]
fn test_description_can_be_omitted_for_subtask() {
let mut tasks = TestTaskStore::new();
let root = tasks.root_task("Root");
let input: TaskCreateInput = serde_json::from_value(json!({
"title": "Subtask without description",
"parent_id": root.id
}))
.unwrap();
let output = execute_task_create(&input, tasks.store_mut()).unwrap();
let created = tasks.store().get(&TaskId::from(output.task.id.as_str())).unwrap();
assert_eq!(output.task.title, "Subtask without description");
assert_eq!(output.task.parent, Some(root.id.to_string()));
assert_eq!(created.description, None);
}
#[test]
fn test_create_with_empty_parent_id_treated_as_root() {
let mut tasks = TestTaskStore::new();
let input: TaskCreateInput = serde_json::from_value(json!({
"title": "Root from empty parent_id",
"description": "Created as root",
"parent_id": ""
}))
.unwrap();
let output = execute_task_create(&input, tasks.store_mut()).unwrap();
assert_eq!(output.task.parent, None);
}
#[test]
fn test_create_with_whitespace_parent_id_treated_as_root() {
let mut tasks = TestTaskStore::new();
let input: TaskCreateInput = serde_json::from_value(json!({
"title": "Root from whitespace parent_id",
"description": "Created as root",
"parent_id": " \t\n "
}))
.unwrap();
let output = execute_task_create(&input, tasks.store_mut()).unwrap();
assert_eq!(output.task.parent, None);
}
#[test]
fn test_create_with_invalid_parent() {
let mut tasks = TestTaskStore::new();
let input = TaskCreateInput {
title: "Orphan".to_string(),
description: Some("Orphan task".to_string()),
parent_id: Some("at-nonexistent".to_string()),
assignee: None,
deps: None,
};
let result = execute_task_create(&input, tasks.store_mut());
assert!(result.is_err());
}
#[test]
fn invalid_deps_create_nothing() {
let mut tasks = TestTaskStore::new();
let root = tasks.root_task("Root");
for parent_id in [None, Some(root.id.to_string())] {
let input = TaskCreateInput {
title: "Blocked".to_string(),
description: None,
parent_id,
assignee: None,
deps: Some(vec!["at-missing".to_string()]),
};
let result = execute_task_create(&input, tasks.store_mut());
assert!(matches!(result, Err(TaskStoreError::DependencyNotFound { .. })), "{result:?}");
}
tasks.reopen();
assert_eq!(tasks.store().tasks().count(), 1);
}
#[test]
fn subtask_of_a_missing_subtask_is_rejected() {
let mut tasks = TestTaskStore::new();
let root = tasks.root_task("Root");
let input = TaskCreateInput {
title: "Orphan".to_string(),
description: None,
parent_id: Some(format!("{}.99", root.id)),
assignee: None,
deps: None,
};
let result = execute_task_create(&input, tasks.store_mut());
assert!(matches!(result, Err(TaskStoreError::ParentNotFound { .. })), "{result:?}");
tasks.reopen();
assert_eq!(tasks.store().tasks().count(), 1);
}
#[test]
fn test_input_accepts_camel_case() {
let input: TaskCreateInput = serde_json::from_value(json!({
"title": "Task with camelCase",
"description": "Using parentId",
"parentId": "at-parent"
}))
.unwrap();
assert_eq!(input.title, "Task with camelCase");
assert_eq!(input.parent_id, Some("at-parent".to_string()));
}
#[test]
fn test_input_accepts_snake_case_alias() {
let input: TaskCreateInput = serde_json::from_value(json!({
"title": "Task with snake_case",
"description": "Using parent_id",
"parent_id": "at-parent"
}))
.unwrap();
assert_eq!(input.title, "Task with snake_case");
assert_eq!(input.parent_id, Some("at-parent".to_string()));
}
}