structure

This commit is contained in:
Alex Emmet 2026-04-30 17:10:40 +02:00
commit ae7ec3ea8a
13 changed files with 991 additions and 19 deletions

138
src/coral.rs Normal file
View file

@ -0,0 +1,138 @@
use uuid::Uuid;
use crate::krill::KrillId;
pub type CoralId = Uuid;
#[derive(Debug, Clone)]
pub struct Coral {
pub id: CoralId,
pub name: String,
pub description: String,
pub krill_ids: Vec<KrillId>,
pub capabilities: Vec<String>,
pub score: f64,
pub total_runs: u32,
pub successful_runs: u32,
}
impl Default for Coral {
fn default() -> Self {
Self {
id: Uuid::new_v4(),
name: String::new(),
description: String::new(),
krill_ids: Vec::new(),
capabilities: Vec::new(),
score: 0.0,
total_runs: 0,
successful_runs: 0,
}
}
}
impl Coral {
pub fn new(name: String, description: String) -> Self {
Self {
id: Uuid::new_v4(),
name,
description,
krill_ids: Vec::new(),
capabilities: Vec::new(),
score: 0.0,
total_runs: 0,
successful_runs: 0,
}
}
pub fn add_krill(&mut self, krill_id: KrillId, capability: String) {
if !self.krill_ids.contains(&krill_id) {
self.krill_ids.push(krill_id);
}
if !self.capabilities.contains(&capability) {
self.capabilities.push(capability);
}
}
pub fn calculate_score(&self) -> f64 {
if self.total_runs == 0 {
return 0.0;
}
self.successful_runs as f64 / self.total_runs as f64
}
pub fn add_run(&mut self, success: bool) {
self.total_runs += 1;
if success {
self.successful_runs += 1;
}
self.score = self.calculate_score();
}
pub fn has_capability(&self, capability: &str) -> bool {
self.capabilities
.iter()
.any(|c| c.eq_ignore_ascii_case(capability))
}
pub fn can_handle(&self, required_capabilities: &[String]) -> bool {
required_capabilities
.iter()
.all(|cap| self.has_capability(cap))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_coral_new() {
let coral = Coral::new("Test Coral".to_string(), "A test coral".to_string());
assert!(!coral.id.is_nil());
assert_eq!(coral.name, "Test Coral");
assert_eq!(coral.score, 0.0);
assert_eq!(coral.total_runs, 0);
}
#[test]
fn test_coral_add_krill() {
let mut coral = Coral::default();
coral.add_krill(Uuid::new_v4(), "coding".to_string());
coral.add_krill(Uuid::new_v4(), "analysis".to_string());
assert_eq!(coral.krill_ids.len(), 2);
assert_eq!(coral.capabilities.len(), 2);
assert!(coral.has_capability("coding"));
}
#[test]
fn test_coral_add_run() {
let mut coral = Coral::default();
coral.add_run(true);
assert_eq!(coral.total_runs, 1);
assert_eq!(coral.successful_runs, 1);
assert_eq!(coral.score, 1.0);
coral.add_run(false);
assert_eq!(coral.total_runs, 2);
assert_eq!(coral.successful_runs, 1);
assert_eq!(coral.score, 0.5);
coral.add_run(true);
coral.add_run(true);
assert_eq!(coral.score, 0.75);
}
#[test]
fn test_coral_can_handle() {
let mut coral = Coral::default();
coral.add_krill(Uuid::new_v4(), "coding".to_string());
coral.add_krill(Uuid::new_v4(), "analysis".to_string());
assert!(coral.can_handle(&["coding".to_string()]));
assert!(coral.can_handle(&["coding".to_string(), "analysis".to_string()]));
assert!(!coral.can_handle(&["coding".to_string(), "vision".to_string()]));
}
}

190
src/enums.rs Normal file
View file

@ -0,0 +1,190 @@
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToDoStatus {
Pending,
InProgress,
Completed,
Blocked,
}
impl Default for ToDoStatus {
fn default() -> Self {
ToDoStatus::Pending
}
}
impl fmt::Display for ToDoStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ToDoStatus::Pending => write!(f, "pending"),
ToDoStatus::InProgress => write!(f, "in_progress"),
ToDoStatus::Completed => write!(f, "completed"),
ToDoStatus::Blocked => write!(f, "blocked"),
}
}
}
impl std::str::FromStr for ToDoStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pending" => Ok(ToDoStatus::Pending),
"in_progress" => Ok(ToDoStatus::InProgress),
"completed" => Ok(ToDoStatus::Completed),
"blocked" => Ok(ToDoStatus::Blocked),
_ => Err(format!("unknown status: {}", s)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskStatus {
Pending,
Running,
Completed,
Failed,
}
impl Default for TaskStatus {
fn default() -> Self {
TaskStatus::Pending
}
}
impl fmt::Display for TaskStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TaskStatus::Pending => write!(f, "pending"),
TaskStatus::Running => write!(f, "running"),
TaskStatus::Completed => write!(f, "completed"),
TaskStatus::Failed => write!(f, "failed"),
}
}
}
impl std::str::FromStr for TaskStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pending" => Ok(TaskStatus::Pending),
"running" => Ok(TaskStatus::Running),
"completed" => Ok(TaskStatus::Completed),
"failed" => Ok(TaskStatus::Failed),
_ => Err(format!("unknown status: {}", s)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ModelType {
Llm,
CodeGen,
Vision,
Audio,
Text,
Embedding,
}
impl fmt::Display for ModelType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ModelType::Llm => write!(f, "llm"),
ModelType::CodeGen => write!(f, "codegen"),
ModelType::Vision => write!(f, "vision"),
ModelType::Audio => write!(f, "audio"),
ModelType::Text => write!(f, "text"),
ModelType::Embedding => write!(f, "embedding"),
}
}
}
impl std::str::FromStr for ModelType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"llm" => Ok(ModelType::Llm),
"codegen" => Ok(ModelType::CodeGen),
"vision" => Ok(ModelType::Vision),
"audio" => Ok(ModelType::Audio),
"text" => Ok(ModelType::Text),
"embedding" => Ok(ModelType::Embedding),
_ => Err(format!("unknown model type: {}", s)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DocType {
ApiDoc,
Readme,
CodeComment,
Architecture,
Other(String),
}
impl fmt::Display for DocType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DocType::ApiDoc => write!(f, "api_doc"),
DocType::Readme => write!(f, "readme"),
DocType::CodeComment => write!(f, "code_comment"),
DocType::Architecture => write!(f, "architecture"),
DocType::Other(s) => write!(f, "other:{}", s),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PromptType {
Authority,
Confirm,
Select,
}
impl fmt::Display for PromptType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PromptType::Authority => write!(f, "authority"),
PromptType::Confirm => write!(f, "confirm"),
PromptType::Select => write!(f, "select"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PromptOption {
Accept,
Deny,
AcceptAlways,
Forbid,
Yes,
No,
OptionA,
OptionB,
OptionC,
}
impl fmt::Display for PromptOption {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PromptOption::Accept => write!(f, "accept"),
PromptOption::Deny => write!(f, "deny"),
PromptOption::AcceptAlways => write!(f, "accept_always"),
PromptOption::Forbid => write!(f, "forbid"),
PromptOption::Yes => write!(f, "yes"),
PromptOption::No => write!(f, "no"),
PromptOption::OptionA => write!(f, "option_a"),
PromptOption::OptionB => write!(f, "option_b"),
PromptOption::OptionC => write!(f, "option_c"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskResultType {
Success,
Error,
Split,
}

45
src/errors.rs Normal file
View file

@ -0,0 +1,45 @@
use thiserror::Error;
#[derive(Debug, Clone, Error)]
pub enum ValidationError {
#[error("title cannot be empty")]
EmptyTitle,
#[error("title exceeds maximum length of 500 characters")]
TitleTooLong,
#[error("description exceeds maximum length of 10000 characters")]
DescriptionTooLong,
#[error("priority must be between 1 and 1000")]
InvalidPriority,
#[error("circular dependency detected: {0}")]
CircularDependency(String),
#[error("dependency not found: {0}")]
DependencyNotFound(String),
#[error("invalid status transition from {from} to {to}")]
InvalidStatusTransition { from: String, to: String },
#[error("invalid field: {0}")]
InvalidField(String),
}
#[derive(Debug, Clone, Error)]
pub enum ShoalError {
#[error("storage error: {0}")]
StorageError(String),
#[error("network error: {0}")]
NetworkError(String),
#[error("validation error: {0}")]
ValidationError(String),
#[error("not found: {0}")]
NotFound(String),
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("database error: {0}")]
DatabaseError(String),
#[error("internal error: {0}")]
InternalError(String),
}
impl From<ValidationError> for ShoalError {
fn from(e: ValidationError) -> Self {
ShoalError::ValidationError(e.to_string())
}
}

133
src/krill.rs Normal file
View file

@ -0,0 +1,133 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::enums::ModelType;
pub type KrillId = Uuid;
#[derive(Debug, Clone)]
pub struct KrillDescriptor {
pub id: KrillId,
pub name: String,
pub capabilities: Vec<String>,
pub model_type: ModelType,
pub model_path: Option<String>,
pub config: KrillConfig,
pub version: String,
pub created_at: DateTime<Utc>,
}
impl Default for KrillDescriptor {
fn default() -> Self {
Self {
id: Uuid::new_v4(),
name: String::new(),
capabilities: Vec::new(),
model_type: ModelType::Llm,
model_path: None,
config: KrillConfig::default(),
version: "0.1.0".to_string(),
created_at: Utc::now(),
}
}
}
impl KrillDescriptor {
pub fn new(name: String, model_type: ModelType) -> Self {
Self {
id: Uuid::new_v4(),
name,
capabilities: Vec::new(),
model_type,
model_path: None,
config: KrillConfig::default(),
version: "0.1.0".to_string(),
created_at: Utc::now(),
}
}
pub fn with_capabilities(mut self, capabilities: Vec<String>) -> Self {
self.capabilities = capabilities;
self
}
pub fn with_model_path(mut self, path: String) -> Self {
self.model_path = Some(path);
self
}
pub fn with_config(mut self, config: KrillConfig) -> Self {
self.config = config;
self
}
pub fn with_version(mut self, version: String) -> Self {
self.version = version;
self
}
pub fn has_capability(&self, capability: &str) -> bool {
self.capabilities
.iter()
.any(|c| c.eq_ignore_ascii_case(capability))
}
}
#[derive(Debug, Clone, Default)]
pub struct KrillConfig {
pub settings: serde_json::Value,
}
impl KrillConfig {
pub fn new() -> Self {
Self {
settings: serde_json::json!({}),
}
}
pub fn with_setting<T: serde::Serialize>(mut self, key: &str, value: T) -> Self {
if let Ok(value) = serde_json::to_value(value) {
if let Some(obj) = self.settings.as_object_mut() {
obj.insert(key.to_string(), value);
}
}
self
}
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
self.settings.get(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_krill_descriptor_new() {
let krill = KrillDescriptor::new("Test Krill".to_string(), ModelType::CodeGen);
assert!(!krill.id.is_nil());
assert_eq!(krill.name, "Test Krill");
assert_eq!(krill.model_type, ModelType::CodeGen);
}
#[test]
fn test_krill_descriptor_with_capabilities() {
let krill = KrillDescriptor::new("Test".to_string(), ModelType::Llm)
.with_capabilities(vec!["coding".to_string(), "analysis".to_string()]);
assert!(krill.has_capability("coding"));
assert!(krill.has_capability("analysis"));
assert!(!krill.has_capability("vision"));
}
#[test]
fn test_krill_config() {
let config = KrillConfig::new()
.with_setting("temperature", 0.7)
.with_setting("max_tokens", 2048);
assert_eq!(config.get("temperature").unwrap().as_f64(), Some(0.7));
assert_eq!(config.get("max_tokens").unwrap().as_u64(), Some(2048));
}
}

19
src/lib.rs Normal file
View file

@ -0,0 +1,19 @@
pub use chrono::{DateTime, Utc};
pub use stp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue};
pub use uuid::Uuid;
pub mod coral;
pub mod enums;
pub mod errors;
pub mod krill;
pub mod project;
pub mod task;
pub mod todo;
pub use coral::{Coral, CoralId};
pub use enums::*;
pub use errors::{ShoalError, ValidationError};
pub use krill::{KrillConfig, KrillDescriptor, KrillId};
pub use project::{Project, ProjectFile, ProjectId, ProjectSettings};
pub use task::{Task, TaskResult};
pub use todo::ToDo;

195
src/project.rs Normal file
View file

@ -0,0 +1,195 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub type ProjectId = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: ProjectId,
pub name: String,
pub description: Option<String>,
pub files: Vec<ProjectFile>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub owner_id: Uuid,
pub settings: ProjectSettings,
}
impl Default for Project {
fn default() -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
name: String::new(),
description: None,
files: Vec::new(),
created_at: now,
updated_at: now,
owner_id: Uuid::nil(),
settings: ProjectSettings::default(),
}
}
}
impl Project {
pub fn new(name: String, owner_id: Uuid) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
name: name.trim().to_string(),
description: None,
files: Vec::new(),
created_at: now,
updated_at: now,
owner_id,
settings: ProjectSettings::default(),
}
}
pub fn with_description(mut self, description: String) -> Self {
self.description = Some(description);
self
}
pub fn with_settings(mut self, settings: ProjectSettings) -> Self {
self.settings = settings;
self
}
pub fn add_file(&mut self, file: ProjectFile) {
self.files.push(file);
self.updated_at = Utc::now();
}
pub fn remove_file(&mut self, file_id: Uuid) -> Option<ProjectFile> {
if let Some(pos) = self.files.iter().position(|f| f.id == file_id) {
self.updated_at = Utc::now();
Some(self.files.remove(pos))
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectFile {
pub id: Uuid,
pub path: String,
pub hash: String,
pub size: u64,
pub mime_type: Option<String>,
pub created_at: DateTime<Utc>,
}
impl ProjectFile {
pub fn new(path: String, hash: String, size: u64) -> Self {
Self {
id: Uuid::new_v4(),
path,
hash,
size,
mime_type: None,
created_at: Utc::now(),
}
}
pub fn with_mime_type(mut self, mime_type: String) -> Self {
self.mime_type = Some(mime_type);
self
}
pub fn is_valid_size(&self) -> bool {
const MAX_SIZE: u64 = 1_073_741_824; // 1GB
self.size <= MAX_SIZE
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectSettings {
pub language: Option<String>,
pub build_command: Option<String>,
pub run_command: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
impl ProjectSettings {
pub fn new() -> Self {
Self {
language: None,
build_command: None,
run_command: None,
extra: serde_json::json!({}),
}
}
pub fn with_language(mut self, language: String) -> Self {
self.language = Some(language);
self
}
pub fn with_build_command(mut self, command: String) -> Self {
self.build_command = Some(command);
self
}
pub fn with_run_command(mut self, command: String) -> Self {
self.run_command = Some(command);
self
}
pub fn set_extra(&mut self, key: &str, value: serde_json::Value) {
if let Some(obj) = self.extra.as_object_mut() {
obj.insert(key.to_string(), value);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_project_new() {
let owner_id = Uuid::new_v4();
let project = Project::new("Test Project".to_string(), owner_id);
assert!(!project.id.is_nil());
assert_eq!(project.name, "Test Project");
assert_eq!(project.owner_id, owner_id);
}
#[test]
fn test_project_with_description() {
let project = Project::new("Test".to_string(), Uuid::new_v4())
.with_description("A test project".to_string());
assert_eq!(project.description, Some("A test project".to_string()));
}
#[test]
fn test_project_file() {
let file = ProjectFile::new("src/main.rs".to_string(), "abc123".to_string(), 1024);
assert!(!file.id.is_nil());
assert_eq!(file.path, "src/main.rs");
assert!(file.is_valid_size());
}
#[test]
fn test_project_file_size_limit() {
let large_file =
ProjectFile::new("large.bin".to_string(), "hash".to_string(), 2_000_000_000);
assert!(!large_file.is_valid_size());
}
#[test]
fn test_project_settings() {
let settings = ProjectSettings::new()
.with_language("rust".to_string())
.with_build_command("cargo build".to_string())
.with_run_command("cargo run".to_string());
assert_eq!(settings.language, Some("rust".to_string()));
assert_eq!(settings.build_command, Some("cargo build".to_string()));
assert_eq!(settings.run_command, Some("cargo run".to_string()));
}
}

230
src/task.rs Normal file
View file

@ -0,0 +1,230 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::enums::{TaskResultType, TaskStatus};
use crate::todo::ToDo;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: Uuid,
pub todo_id: Uuid,
pub description: String,
pub status: TaskStatus,
pub assigned_krill: Option<Uuid>,
pub assigned_pod: Option<Uuid>,
pub result: Option<TaskResult>,
pub logs: Vec<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
impl Default for Task {
fn default() -> Self {
Self {
id: Uuid::new_v4(),
todo_id: Uuid::nil(),
description: String::new(),
status: TaskStatus::default(),
assigned_krill: None,
assigned_pod: None,
result: None,
logs: Vec::new(),
started_at: None,
completed_at: None,
}
}
}
impl Task {
pub fn new(todo_id: Uuid, description: String) -> Self {
Self {
id: Uuid::new_v4(),
todo_id,
description,
status: TaskStatus::default(),
assigned_krill: None,
assigned_pod: None,
result: None,
logs: Vec::new(),
started_at: None,
completed_at: None,
}
}
pub fn start(&mut self) -> Result<(), String> {
match self.status {
TaskStatus::Pending => {
self.status = TaskStatus::Running;
self.started_at = Some(Utc::now());
Ok(())
}
_ => Err(format!("cannot start task from status: {:?}", self.status)),
}
}
pub fn complete(&mut self, result: TaskResult) -> Result<(), String> {
match self.status {
TaskStatus::Running => {
self.status = TaskStatus::Completed;
self.completed_at = Some(Utc::now());
self.result = Some(result);
Ok(())
}
_ => Err(format!(
"cannot complete task from status: {:?}",
self.status
)),
}
}
pub fn fail(&mut self, error: String) -> Result<(), String> {
match self.status {
TaskStatus::Running => {
self.status = TaskStatus::Failed;
self.completed_at = Some(Utc::now());
self.result = Some(TaskResult::Error(error));
Ok(())
}
_ => Err(format!("cannot fail task from status: {:?}", self.status)),
}
}
pub fn retry(&mut self) -> Result<(), String> {
match self.status {
TaskStatus::Failed | TaskStatus::Pending => {
self.status = TaskStatus::Pending;
self.started_at = None;
self.completed_at = None;
self.result = None;
self.logs.clear();
Ok(())
}
_ => Err(format!("cannot retry task from status: {:?}", self.status)),
}
}
pub fn duration_ms(&self) -> Option<i64> {
match (self.started_at, self.completed_at) {
(Some(start), Some(end)) => Some((end - start).num_milliseconds()),
_ => None,
}
}
pub fn add_log(&mut self, log: String) {
self.logs.push(log);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TaskResult {
Success(String),
Error(String),
Split(Vec<ToDo>),
}
impl TaskResult {
pub fn result_type(&self) -> TaskResultType {
match self {
TaskResult::Success(_) => TaskResultType::Success,
TaskResult::Error(_) => TaskResultType::Error,
TaskResult::Split(_) => TaskResultType::Split,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_new() {
let todo_id = Uuid::new_v4();
let task = Task::new(todo_id, "Test task description".to_string());
assert!(!task.id.is_nil());
assert_eq!(task.todo_id, todo_id);
assert_eq!(task.description, "Test task description");
assert_eq!(task.status, TaskStatus::Pending);
}
#[test]
fn test_task_start() {
let mut task = Task::default();
assert!(task.start().is_ok());
assert_eq!(task.status, TaskStatus::Running);
assert!(task.started_at.is_some());
}
#[test]
fn test_task_complete() {
let mut task = Task::default();
task.status = TaskStatus::Running;
let result = TaskResult::Success("output".to_string());
assert!(task.complete(result.clone()).is_ok());
assert_eq!(task.status, TaskStatus::Completed);
assert!(task.completed_at.is_some());
assert_eq!(task.result, Some(result));
}
#[test]
fn test_task_duration_ms() {
let mut task = Task::default();
task.start().expect("task should start");
std::thread::sleep(std::time::Duration::from_millis(10));
task.complete(TaskResult::Success("done".to_string()))
.expect("task should complete");
let duration = task.duration_ms();
assert!(duration.is_some());
assert!(duration.unwrap() >= 10);
}
#[test]
fn test_task_retry() {
let mut task = Task::default();
task.status = TaskStatus::Failed;
task.result = Some(TaskResult::Error("failed".to_string()));
assert!(task.retry().is_ok());
assert_eq!(task.status, TaskStatus::Pending);
assert!(task.result.is_none());
}
#[test]
fn test_task_default_values() {
let task = Task::default();
assert_eq!(task.status, TaskStatus::Pending);
assert!(task.result.is_none());
assert!(task.logs.is_empty());
assert!(task.started_at.is_none());
assert!(task.completed_at.is_none());
}
#[test]
fn test_task_serialization_roundtrip() {
let original = Task::new(Uuid::new_v4(), "Test task".to_string());
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: Task = serde_json::from_str(&serialized).unwrap();
assert_eq!(original.id, deserialized.id);
assert_eq!(original.todo_id, deserialized.todo_id);
assert_eq!(original.description, deserialized.description);
assert_eq!(original.status, deserialized.status);
}
#[test]
fn test_task_fail() {
let mut task = Task::default();
task.status = TaskStatus::Running;
assert!(task.fail("error message".to_string()).is_ok());
assert_eq!(task.status, TaskStatus::Failed);
assert!(task.result.is_some());
}
#[test]
fn test_task_invalid_transition() {
let mut task = Task::default();
// Cannot complete a pending task directly
let result = TaskResult::Success("done".to_string());
assert!(task.complete(result).is_err());
}
}

320
src/todo.rs Normal file
View file

@ -0,0 +1,320 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::enums::ToDoStatus;
use crate::errors::ValidationError;
use crate::{CommunicationType, CommunicationValue, DataTypes, DataValue};
const MAX_TITLE_LENGTH: usize = 500;
const MAX_DESCRIPTION_LENGTH: usize = 10000;
const MIN_PRIORITY: u32 = 1;
const MAX_PRIORITY: u32 = 1000;
const DEFAULT_PRIORITY: u32 = 100;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToDo {
pub id: Uuid,
pub title: String,
pub description: String,
pub status: ToDoStatus,
pub priority: u32,
pub depends_on: Vec<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_by: Uuid,
pub project_id: Option<Uuid>,
}
impl Default for ToDo {
fn default() -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
title: String::new(),
description: String::new(),
status: ToDoStatus::default(),
priority: DEFAULT_PRIORITY,
depends_on: Vec::new(),
created_at: now,
updated_at: now,
created_by: Uuid::nil(),
project_id: None,
}
}
}
impl ToDo {
pub fn new(title: String, description: String, created_by: Uuid) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
title: title.trim().to_string(),
description,
status: ToDoStatus::default(),
priority: DEFAULT_PRIORITY,
depends_on: Vec::new(),
created_at: now,
updated_at: now,
created_by,
project_id: None,
}
}
pub fn with_project(mut self, project_id: Uuid) -> Self {
self.project_id = Some(project_id);
self
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.title.is_empty() {
return Err(ValidationError::EmptyTitle);
}
if self.title.len() > MAX_TITLE_LENGTH {
return Err(ValidationError::TitleTooLong);
}
if self.description.len() > MAX_DESCRIPTION_LENGTH {
return Err(ValidationError::DescriptionTooLong);
}
if self.priority < MIN_PRIORITY || self.priority > MAX_PRIORITY {
return Err(ValidationError::InvalidPriority);
}
if self.has_circular_dependency() {
return Err(ValidationError::CircularDependency(self.id.to_string()));
}
Ok(())
}
fn has_circular_dependency(&self) -> bool {
if self.depends_on.is_empty() {
return false;
}
false
}
pub fn set_status(&mut self, status: ToDoStatus) -> Result<(), ValidationError> {
let valid_transition = match (&self.status, &status) {
(ToDoStatus::Pending, ToDoStatus::InProgress) => true,
(ToDoStatus::Pending, ToDoStatus::Blocked) => true,
(ToDoStatus::InProgress, ToDoStatus::Completed) => true,
(ToDoStatus::InProgress, ToDoStatus::Blocked) => true,
(ToDoStatus::Blocked, ToDoStatus::InProgress) => true,
(ToDoStatus::Blocked, ToDoStatus::Pending) => true,
(ToDoStatus::Completed, ToDoStatus::InProgress) => true,
_ => false,
};
if !valid_transition {
return Err(ValidationError::InvalidStatusTransition {
from: self.status.to_string(),
to: status.to_string(),
});
}
self.status = status;
self.updated_at = Utc::now();
Ok(())
}
pub fn update(
&mut self,
title: Option<String>,
description: Option<String>,
priority: Option<u32>,
) -> Result<(), ValidationError> {
if let Some(t) = title {
let trimmed = t.trim().to_string();
if trimmed.is_empty() {
return Err(ValidationError::EmptyTitle);
}
if trimmed.len() > MAX_TITLE_LENGTH {
return Err(ValidationError::TitleTooLong);
}
self.title = trimmed;
}
if let Some(d) = description {
if d.len() > MAX_DESCRIPTION_LENGTH {
return Err(ValidationError::DescriptionTooLong);
}
self.description = d;
}
if let Some(p) = priority {
if p < MIN_PRIORITY || p > MAX_PRIORITY {
return Err(ValidationError::InvalidPriority);
}
self.priority = p;
}
self.updated_at = Utc::now();
Ok(())
}
}
impl From<ToDo> for CommunicationValue {
fn from(todo: ToDo) -> Self {
let mut cv = CommunicationValue::new(CommunicationType::todo);
cv = cv.add_data(DataTypes::id, DataValue::Str(todo.id.to_string()));
cv = cv.add_data(DataTypes::title, DataValue::Str(todo.title));
cv = cv.add_data(DataTypes::description, DataValue::Str(todo.description));
cv = cv.add_data(DataTypes::status, DataValue::Str(todo.status.to_string()));
cv = cv.add_data(DataTypes::todo_id, DataValue::Number(todo.priority as i64));
let depends: Vec<DataValue> = todo
.depends_on
.iter()
.map(|u| DataValue::Str(u.to_string()))
.collect();
cv = cv.add_data(DataTypes::depends_on, DataValue::Array(depends));
cv = cv.add_data(
DataTypes::created_at,
DataValue::Str(todo.created_at.to_rfc3339()),
);
cv = cv.add_data(
DataTypes::updated_at,
DataValue::Str(todo.updated_at.to_rfc3339()),
);
cv = cv.add_data(
DataTypes::user_id,
DataValue::Str(todo.created_by.to_string()),
);
if let Some(pid) = todo.project_id {
cv = cv.add_data(DataTypes::project_id, DataValue::Str(pid.to_string()));
}
cv
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_todo_new() {
let todo = ToDo::new(
"Test Title".to_string(),
"Test Description".to_string(),
Uuid::new_v4(),
);
assert!(!todo.id.is_nil());
assert_eq!(todo.title, "Test Title");
assert_eq!(todo.description, "Test Description");
assert_eq!(todo.status, ToDoStatus::Pending);
assert_eq!(todo.priority, DEFAULT_PRIORITY);
}
#[test]
fn test_todo_validate_empty_title() {
let mut todo = ToDo::default();
todo.title = "".to_string();
assert!(matches!(todo.validate(), Err(ValidationError::EmptyTitle)));
}
#[test]
fn test_todo_validate_title_too_long() {
let mut todo = ToDo::default();
todo.title = "a".repeat(MAX_TITLE_LENGTH + 1);
assert!(matches!(
todo.validate(),
Err(ValidationError::TitleTooLong)
));
}
#[test]
fn test_todo_validate_priority() {
let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4());
todo.priority = 0;
assert!(matches!(
todo.validate(),
Err(ValidationError::InvalidPriority)
));
let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4());
todo.priority = 1001;
assert!(matches!(
todo.validate(),
Err(ValidationError::InvalidPriority)
));
let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4());
todo.priority = 500;
assert!(todo.validate().is_ok());
}
#[test]
fn test_todo_status_transition() {
let mut todo = ToDo::default();
assert!(todo.set_status(ToDoStatus::InProgress).is_ok());
assert!(matches!(
todo.set_status(ToDoStatus::Pending),
Err(ValidationError::InvalidStatusTransition { .. })
));
}
#[test]
fn test_todo_default_values() {
let todo = ToDo::default();
assert_eq!(todo.status, ToDoStatus::Pending);
assert_eq!(todo.priority, DEFAULT_PRIORITY);
assert!(todo.depends_on.is_empty());
assert!(!todo.id.is_nil());
}
#[test]
fn test_todo_serialization_roundtrip() {
let original = ToDo::new(
"Test Title".to_string(),
"Test Description".to_string(),
Uuid::new_v4(),
);
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: ToDo = serde_json::from_str(&serialized).unwrap();
assert_eq!(original.id, deserialized.id);
assert_eq!(original.title, deserialized.title);
assert_eq!(original.description, deserialized.description);
assert_eq!(original.status, deserialized.status);
assert_eq!(original.priority, deserialized.priority);
}
#[test]
fn test_todo_update_title() {
let mut todo = ToDo::default();
let result = todo.update(Some("New Title".to_string()), None, None);
assert!(result.is_ok());
assert_eq!(todo.title, "New Title");
}
#[test]
fn test_todo_update_empty_title() {
let mut todo = ToDo::default();
let result = todo.update(Some("".to_string()), None, None);
assert!(matches!(result, Err(ValidationError::EmptyTitle)));
}
#[test]
fn test_todo_update_priority() {
let mut todo = ToDo::default();
let result = todo.update(None, None, Some(50));
assert!(result.is_ok());
assert_eq!(todo.priority, 50);
}
#[test]
fn test_todo_update_invalid_priority() {
let mut todo = ToDo::default();
let result = todo.update(None, None, Some(0));
assert!(matches!(result, Err(ValidationError::InvalidPriority)));
}
}