use forge_models::{ TaskOwnershipContext, TaskOwnershipMutationResult, TaskRecord, TaskRewardContext, }; use forge_repositories::TaskRepository; use serde_json::Value; pub struct TaskStateService { repository: R, } impl TaskStateService { pub fn new(repository: R) -> Self { Self { repository } } pub fn reset(&self) -> Result { self.repository.reset()?; Ok(true) } pub fn upsert_catalog_entry( &self, entry_id: String, json_data: String, ) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let mut entry = Self::parse_record(&json_data)?; Self::normalize_catalog_entry(&mut entry, &entry_id); self.repository .save_catalog_entry(entry_id, entry.clone())?; Ok(entry) } pub fn get_catalog_entry(&self, entry_id: String) -> Result, String> { let entry_id = Self::validate_entry_id(entry_id)?; self.repository .get_catalog_entry(&entry_id) .map(|entry| entry.map(TaskRecord::into_value)) } pub fn delete_catalog_entry(&self, entry_id: String) -> Result<(), String> { let entry_id = Self::validate_entry_id(entry_id)?; self.repository.delete_catalog_entry(&entry_id) } pub fn list_active_catalog(&self) -> Result, String> { let catalog = self.repository.list_catalog()?; let active_statuses = self.repository.list_active_statuses()?; let mut active_entries = Vec::new(); for (task_id, status) in active_statuses { if status != "active" { continue; } let Some(entry) = catalog.get(&task_id) else { continue; }; let mut entry = entry.fields.clone(); entry.insert("taskId".to_string(), Value::String(task_id.clone())); entry.insert("taskID".to_string(), Value::String(task_id)); entry.insert("status".to_string(), Value::String(status)); active_entries.push(Value::Object(entry)); } Ok(active_entries) } pub fn bind_ownership( &self, entry_id: String, json_data: String, ) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let mut ownership = Self::parse_ownership_context(&json_data)?; if ownership.org_id.trim().is_empty() { ownership.org_id = "default".to_string(); } self.repository .save_ownership(entry_id.clone(), ownership.clone())?; let entry = self.patch_catalog_ownership( &entry_id, true, &ownership.requester_uid, &ownership.org_id, )?; Ok(TaskOwnershipMutationResult { task_id: entry_id, requester_uid: ownership.requester_uid, org_id: ownership.org_id, entry, message: "Task ownership updated.".to_string(), }) } pub fn release_ownership( &self, entry_id: String, ) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let ownership = self .repository .get_ownership(&entry_id)? .unwrap_or_default(); self.repository.delete_ownership(&entry_id)?; let entry = self.patch_catalog_ownership(&entry_id, false, "", "default")?; Ok(TaskOwnershipMutationResult { task_id: entry_id, requester_uid: ownership.requester_uid, org_id: ownership.org_id, entry, message: "Task ownership released.".to_string(), }) } pub fn accept_task( &self, entry_id: String, json_data: String, ) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let ownership = Self::parse_ownership_context(&json_data)?; if ownership.requester_uid.trim().is_empty() { return Err("Missing task ID or requester UID.".to_string()); } if self.get_status(entry_id.clone())? != "active" { return Err("Task is no longer active.".to_string()); } if let Some(existing) = self.repository.get_ownership(&entry_id)? && !existing.requester_uid.trim().is_empty() && existing.requester_uid != ownership.requester_uid { return Err("Task has already been accepted.".to_string()); } let mut result = self.bind_ownership( entry_id, serde_json::to_string(&ownership) .map_err(|error| format!("Failed to serialize task ownership: {error}"))?, )?; result.message = "Task accepted.".to_string(); Ok(result) } pub fn set_status(&self, entry_id: String, status: String) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let final_status = Self::validate_status(status)?; self.repository .set_active_status(entry_id.clone(), final_status.clone())?; if matches!(final_status.as_str(), "succeeded" | "failed") { self.repository .set_completed_status(entry_id, final_status)?; } else { self.repository.delete_completed_status(&entry_id)?; } Ok(true) } pub fn get_status(&self, entry_id: String) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; if let Some(status) = self.repository.get_active_status(&entry_id)? { return Ok(status); } Ok(self .repository .get_completed_status(&entry_id)? .unwrap_or_default()) } pub fn clear_status(&self, entry_id: String) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; self.repository.delete_active_status(&entry_id)?; self.repository.delete_completed_status(&entry_id)?; Ok(true) } pub fn get_reward_context(&self, entry_id: String) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let ownership = self .repository .get_ownership(&entry_id)? .unwrap_or_default(); Ok(TaskRewardContext { requester_uid: ownership.requester_uid, org_id: ownership.org_id, }) } pub fn increment_defuse_count(&self, entry_id: String) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; self.repository.increment_defuse_count(&entry_id) } pub fn get_defuse_count(&self, entry_id: String) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; self.repository.get_defuse_count(&entry_id) } pub fn clear_task(&self, entry_id: String) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; self.repository.delete_catalog_entry(&entry_id)?; self.repository.delete_ownership(&entry_id)?; self.repository.delete_active_status(&entry_id)?; self.repository.delete_completed_status(&entry_id)?; self.repository.clear_defuse_count(&entry_id)?; Ok(true) } fn patch_catalog_ownership( &self, entry_id: &str, accepted: bool, requester_uid: &str, org_id: &str, ) -> Result { let Some(mut entry) = self.repository.get_catalog_entry(entry_id)? else { return Ok(Value::Null); }; entry .fields .insert("accepted".to_string(), Value::Bool(accepted)); entry.fields.insert( "requesterUid".to_string(), Value::String(requester_uid.to_string()), ); entry .fields .insert("orgID".to_string(), Value::String(org_id.to_string())); Self::normalize_catalog_entry(&mut entry, entry_id); self.repository .save_catalog_entry(entry_id.to_string(), entry.clone())?; Ok(entry.into_value()) } fn normalize_catalog_entry(entry: &mut TaskRecord, entry_id: &str) { let fields = &mut entry.fields; fields .entry("accepted".to_string()) .or_insert(Value::Bool(false)); fields .entry("requesterUid".to_string()) .or_insert(Value::String(String::new())); fields .entry("orgID".to_string()) .or_insert(Value::String("default".to_string())); fields .entry("taskId".to_string()) .or_insert(Value::String(entry_id.to_string())); fields .entry("taskID".to_string()) .or_insert(Value::String(entry_id.to_string())); } fn validate_entry_id(entry_id: String) -> Result { if entry_id.trim().is_empty() { return Err("Task ID is required.".to_string()); } Ok(entry_id) } fn validate_status(status: String) -> Result { if status.trim().is_empty() { return Err("Task status is required.".to_string()); } Ok(status) } fn parse_record(json_data: &str) -> Result { serde_json::from_str::(json_data) .map_err(|error| format!("Invalid task JSON: {error}")) } fn parse_ownership_context(json_data: &str) -> Result { serde_json::from_str::(json_data) .map_err(|error| format!("Invalid task ownership JSON: {error}")) } } #[cfg(test)] mod tests { use super::TaskStateService; use forge_repositories::{InMemoryTaskRepository, TaskRepository}; use serde_json::Value; #[test] fn bind_ownership_updates_catalog_entry() { let repository = InMemoryTaskRepository::new(); let service = TaskStateService::new(repository.clone()); service .upsert_catalog_entry("task-1".to_string(), r#"{"title":"Attack"}"#.to_string()) .expect("catalog upsert should succeed"); let result = service .bind_ownership( "task-1".to_string(), r#"{"requesterUid":"uid-1","orgId":"org-1"}"#.to_string(), ) .expect("bind should succeed"); assert_eq!(result.requester_uid, "uid-1"); assert_eq!(result.org_id, "org-1"); assert_eq!( result.entry.get("accepted").and_then(Value::as_bool), Some(true) ); let stored = repository .get_catalog_entry("task-1") .expect("catalog lookup should succeed") .expect("catalog entry should exist"); assert_eq!( stored.fields.get("requesterUid").and_then(Value::as_str), Some("uid-1") ); } #[test] fn get_status_falls_back_to_completed_status() { let repository = InMemoryTaskRepository::new(); let service = TaskStateService::new(repository.clone()); service .set_status("task-1".to_string(), "failed".to_string()) .expect("status update should succeed"); repository .delete_active_status("task-1") .expect("active status delete should succeed"); assert_eq!( service .get_status("task-1".to_string()) .expect("status lookup should succeed"), "failed" ); } #[test] fn list_active_catalog_only_returns_active_entries() { let service = TaskStateService::new(InMemoryTaskRepository::new()); service .upsert_catalog_entry( "task-active".to_string(), r#"{"title":"Active"}"#.to_string(), ) .expect("active catalog upsert should succeed"); service .upsert_catalog_entry("task-done".to_string(), r#"{"title":"Done"}"#.to_string()) .expect("done catalog upsert should succeed"); service .set_status("task-active".to_string(), "active".to_string()) .expect("active status update should succeed"); service .set_status("task-done".to_string(), "succeeded".to_string()) .expect("done status update should succeed"); let active_catalog = service .list_active_catalog() .expect("active catalog should build"); assert_eq!(active_catalog.len(), 1); assert_eq!( active_catalog[0].get("taskId").and_then(Value::as_str), Some("task-active") ); } }