From 958cf8829a84e94691bc31343ddd3ef336a8abf5 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:20:17 +0200 Subject: [PATCH] Commons & STP --- types/Cargo.toml | 17 +++ types/src/coral.rs | 138 +++++++++++++++++++++++ types/src/enums.rs | 189 +++++++++++++++++++++++++++++++ types/src/errors.rs | 45 ++++++++ types/src/krill.rs | 133 ++++++++++++++++++++++ types/src/lib.rs | 19 ++++ types/src/project.rs | 195 ++++++++++++++++++++++++++++++++ types/src/task.rs | 190 +++++++++++++++++++++++++++++++ types/src/todo.rs | 262 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1188 insertions(+) create mode 100644 types/Cargo.toml create mode 100644 types/src/coral.rs create mode 100644 types/src/enums.rs create mode 100644 types/src/errors.rs create mode 100644 types/src/krill.rs create mode 100644 types/src/lib.rs create mode 100644 types/src/project.rs create mode 100644 types/src/task.rs create mode 100644 types/src/todo.rs diff --git a/types/Cargo.toml b/types/Cargo.toml new file mode 100644 index 0000000..ead24ec --- /dev/null +++ b/types/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "shoal-types" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +uuid = { workspace = true } +chrono = { workspace = true } +thiserror.workspace = true +serde = { workspace = true } +serde_json = { workspace = true } +stp-core = { path = "../../stp/core" } + +[dev-dependencies] +tokio.workspace = true diff --git a/types/src/coral.rs b/types/src/coral.rs new file mode 100644 index 0000000..a9f9716 --- /dev/null +++ b/types/src/coral.rs @@ -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, + pub capabilities: Vec, + 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()])); + } +} diff --git a/types/src/enums.rs b/types/src/enums.rs new file mode 100644 index 0000000..da92a20 --- /dev/null +++ b/types/src/enums.rs @@ -0,0 +1,189 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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 { + 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)] +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 { + 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)] +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 { + 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)] +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)] +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)] +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)] +pub enum TaskResultType { + Success, + Error, + Split, +} diff --git a/types/src/errors.rs b/types/src/errors.rs new file mode 100644 index 0000000..769c629 --- /dev/null +++ b/types/src/errors.rs @@ -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 for ShoalError { + fn from(e: ValidationError) -> Self { + ShoalError::ValidationError(e.to_string()) + } +} diff --git a/types/src/krill.rs b/types/src/krill.rs new file mode 100644 index 0000000..3a8294e --- /dev/null +++ b/types/src/krill.rs @@ -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, + pub model_type: ModelType, + pub model_path: Option, + pub config: KrillConfig, + pub version: String, + pub created_at: DateTime, +} + +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) -> 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(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)); + } +} diff --git a/types/src/lib.rs b/types/src/lib.rs new file mode 100644 index 0000000..a96af27 --- /dev/null +++ b/types/src/lib.rs @@ -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; diff --git a/types/src/project.rs b/types/src/project.rs new file mode 100644 index 0000000..b04d747 --- /dev/null +++ b/types/src/project.rs @@ -0,0 +1,195 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub type ProjectId = Uuid; + +#[derive(Debug, Clone)] +pub struct Project { + pub id: ProjectId, + pub name: String, + pub description: Option, + pub files: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, + 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 { + 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)] +pub struct ProjectFile { + pub id: Uuid, + pub path: String, + pub hash: String, + pub size: u64, + pub mime_type: Option, + pub created_at: DateTime, +} + +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, + pub build_command: Option, + pub run_command: Option, + #[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())); + } +} diff --git a/types/src/task.rs b/types/src/task.rs new file mode 100644 index 0000000..548ecd2 --- /dev/null +++ b/types/src/task.rs @@ -0,0 +1,190 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::enums::{TaskResultType, TaskStatus}; +use crate::todo::ToDo; + +#[derive(Debug, Clone)] +pub struct Task { + pub id: Uuid, + pub todo_id: Uuid, + pub description: String, + pub status: TaskStatus, + pub assigned_krill: Option, + pub assigned_pod: Option, + pub result: Option, + pub logs: Vec, + pub started_at: Option>, + pub completed_at: Option>, +} + +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 { + 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)] +pub enum TaskResult { + Success(String), + Error(String), + Split(Vec), +} + +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()); + } +} diff --git a/types/src/todo.rs b/types/src/todo.rs new file mode 100644 index 0000000..4b7b2cf --- /dev/null +++ b/types/src/todo.rs @@ -0,0 +1,262 @@ +use chrono::{DateTime, Utc}; +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)] +pub struct ToDo { + pub id: Uuid, + pub title: String, + pub description: String, + pub status: ToDoStatus, + pub priority: u32, + pub depends_on: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, + pub created_by: Uuid, + pub project_id: Option, +} + +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, + description: Option, + priority: Option, + ) -> 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 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 = 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 { .. }) + )); + } +}