use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TodoStatus {
#[default]
Pending,
InProgress,
PendingVerification,
Completed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TodoItem {
pub id: String,
pub content: String,
#[serde(default)]
pub status: TodoStatus,
pub creation_confidence: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_confidence: Option<u8>,
#[serde(default)]
pub verification_attempts: u8,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TodoState {
#[serde(default)]
pub items: Vec<TodoItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TodoConfig {
pub completion_confidence_delta: u8,
}
impl Default for TodoConfig {
fn default() -> Self {
Self {
completion_confidence_delta: 40,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TodoMutation {
pub state: TodoState,
pub verification_required: bool,
}
pub(crate) fn apply_request(
state: &mut TodoState,
config: &TodoConfig,
value: &serde_json::Value,
) -> Result<TodoMutation, String> {
let action = value
.get("action")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "action required".to_string())?;
match action {
"list" => {}
"clear" => state.items.clear(),
"add" | "create" => {
let items = value
.get("items")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "items required for create".to_string())?;
if items.is_empty() {
return Err("items required for create".into());
}
for item in items {
let content = required_string(item, "content")?;
let confidence = required_confidence(item, "confidence")?;
let id = item
.get("id")
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
if state.items.iter().any(|existing| existing.id == id) {
return Err(format!("todo id already exists: {id}"));
}
state.items.push(TodoItem {
id,
content,
status: TodoStatus::Pending,
creation_confidence: confidence,
completion_confidence: None,
verification_attempts: 0,
});
}
}
"update" => {
let items = required_items(value, action)?;
for item in items {
let todo = find_item_mut(&mut state.items, item)?;
if let Some(content) = item.get("content").and_then(serde_json::Value::as_str) {
todo.content = content.to_owned();
}
if let Some(status) = item.get("status").and_then(serde_json::Value::as_str) {
todo.status = parse_status(status)?;
}
}
}
"complete" => {
let items = required_items(value, action)?;
let mut verification_required = false;
for item in items {
let confidence = required_confidence(item, "completion_confidence")?;
let todo = find_item_mut(&mut state.items, item)?;
todo.completion_confidence = Some(confidence);
let delta = confidence.saturating_sub(todo.creation_confidence);
if todo.verification_attempts == 0 && delta > config.completion_confidence_delta {
todo.verification_attempts = 1;
todo.status = TodoStatus::PendingVerification;
verification_required = true;
} else {
todo.status = TodoStatus::Completed;
}
}
return Ok(TodoMutation {
state: state.clone(),
verification_required,
});
}
other => return Err(format!("unknown action: {other}")),
}
Ok(TodoMutation {
state: state.clone(),
verification_required: false,
})
}
fn required_items<'a>(
value: &'a serde_json::Value,
action: &str,
) -> Result<&'a [serde_json::Value], String> {
value
.get("items")
.and_then(serde_json::Value::as_array)
.filter(|items| !items.is_empty())
.map(Vec::as_slice)
.ok_or_else(|| format!("items required for {action}"))
}
fn find_item_mut<'a>(
items: &'a mut [TodoItem],
value: &serde_json::Value,
) -> Result<&'a mut TodoItem, String> {
let id = required_string(value, "id")?;
items
.iter_mut()
.find(|item| item.id == id)
.ok_or_else(|| format!("todo not found: {id}"))
}
fn required_string(value: &serde_json::Value, field: &str) -> Result<String, String> {
value
.get(field)
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
.ok_or_else(|| format!("{field} required"))
}
fn required_confidence(value: &serde_json::Value, field: &str) -> Result<u8, String> {
value
.get(field)
.and_then(serde_json::Value::as_u64)
.filter(|value| *value <= 100)
.map(|value| value as u8)
.ok_or_else(|| format!("{field} must be an integer from 0 to 100"))
}
fn parse_status(status: &str) -> Result<TodoStatus, String> {
match status {
"pending" => Ok(TodoStatus::Pending),
"in_progress" => Ok(TodoStatus::InProgress),
"pending_verification" => Ok(TodoStatus::PendingVerification),
"completed" => Ok(TodoStatus::Completed),
_ => Err(format!("unknown todo status: {status}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn high_confidence_jump_requires_one_verification() {
let mut state = TodoState::default();
apply_request(
&mut state,
&TodoConfig::default(),
&serde_json::json!({"action":"create","items":[{"id":"one","content":"test","confidence":30}]}),
)
.unwrap();
let first = apply_request(
&mut state,
&TodoConfig::default(),
&serde_json::json!({"action":"complete","items":[{"id":"one","completion_confidence":80}]}),
)
.unwrap();
assert!(first.verification_required);
assert_eq!(state.items[0].status, TodoStatus::PendingVerification);
let second = apply_request(
&mut state,
&TodoConfig::default(),
&serde_json::json!({"action":"complete","items":[{"id":"one","completion_confidence":80}]}),
)
.unwrap();
assert!(!second.verification_required);
assert_eq!(state.items[0].status, TodoStatus::Completed);
}
}