//! In-memory registries for Team and Cron lifecycle management. //! //! Provides TeamCreate/Delete and CronCreate/Delete/List runtime backing //! to replace the stub implementations in the tools crate. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Team { pub team_id: String, pub name: String, pub task_ids: Vec, pub status: TeamStatus, pub created_at: u64, pub updated_at: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TeamStatus { Created, Running, Completed, Deleted, } impl std::fmt::Display for TeamStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Created => write!(f, "created"), Self::Running => write!(f, "running"), Self::Completed => write!(f, "completed"), Self::Deleted => write!(f, "deleted"), } } } #[derive(Debug, Clone, Default)] pub struct TeamRegistry { inner: Arc>, } #[derive(Debug, Default)] struct TeamInner { teams: HashMap, counter: u64, } impl TeamRegistry { #[must_use] pub fn new() -> Self { Self::default() } pub fn create(&self, name: &str, task_ids: Vec) -> Team { let mut inner = self.inner.lock().expect("team registry lock poisoned"); inner.counter += 1; let ts = now_secs(); let team_id = format!("team_{:08x}_{}", ts, inner.counter); let team = Team { team_id: team_id.clone(), name: name.to_owned(), task_ids, status: TeamStatus::Created, created_at: ts, updated_at: ts, }; inner.teams.insert(team_id, team.clone()); team } pub fn get(&self, team_id: &str) -> Option { let inner = self.inner.lock().expect("team registry lock poisoned"); inner.teams.get(team_id).cloned() } pub fn list(&self) -> Vec { let inner = self.inner.lock().expect("team registry lock poisoned"); inner.teams.values().cloned().collect() } pub fn delete(&self, team_id: &str) -> Result { let mut inner = self.inner.lock().expect("team registry lock poisoned"); let team = inner .teams .get_mut(team_id) .ok_or_else(|| format!("team not found: {team_id}"))?; team.status = TeamStatus::Deleted; team.updated_at = now_secs(); Ok(team.clone()) } pub fn remove(&self, team_id: &str) -> Option { let mut inner = self.inner.lock().expect("team registry lock poisoned"); inner.teams.remove(team_id) } #[must_use] pub fn len(&self) -> usize { let inner = self.inner.lock().expect("team registry lock poisoned"); inner.teams.len() } #[must_use] pub fn is_empty(&self) -> bool { self.len() == 0 } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CronEntry { pub cron_id: String, pub schedule: String, pub prompt: String, pub description: Option, pub enabled: bool, pub created_at: u64, pub updated_at: u64, pub last_run_at: Option, pub run_count: u64, } #[derive(Debug, Clone, Default)] pub struct CronRegistry { inner: Arc>, } #[derive(Debug, Default)] struct CronInner { entries: HashMap, counter: u64, } impl CronRegistry { #[must_use] pub fn new() -> Self { Self::default() } pub fn create(&self, schedule: &str, prompt: &str, description: Option<&str>) -> CronEntry { let mut inner = self.inner.lock().expect("cron registry lock poisoned"); inner.counter += 1; let ts = now_secs(); let cron_id = format!("cron_{:08x}_{}", ts, inner.counter); let entry = CronEntry { cron_id: cron_id.clone(), schedule: schedule.to_owned(), prompt: prompt.to_owned(), description: description.map(str::to_owned), enabled: true, created_at: ts, updated_at: ts, last_run_at: None, run_count: 0, }; inner.entries.insert(cron_id, entry.clone()); entry } pub fn get(&self, cron_id: &str) -> Option { let inner = self.inner.lock().expect("cron registry lock poisoned"); inner.entries.get(cron_id).cloned() } pub fn list(&self, enabled_only: bool) -> Vec { let inner = self.inner.lock().expect("cron registry lock poisoned"); inner .entries .values() .filter(|e| !enabled_only || e.enabled) .cloned() .collect() } pub fn delete(&self, cron_id: &str) -> Result { let mut inner = self.inner.lock().expect("cron registry lock poisoned"); inner .entries .remove(cron_id) .ok_or_else(|| format!("cron not found: {cron_id}")) } /// Disable a cron entry without removing it. pub fn disable(&self, cron_id: &str) -> Result<(), String> { let mut inner = self.inner.lock().expect("cron registry lock poisoned"); let entry = inner .entries .get_mut(cron_id) .ok_or_else(|| format!("cron not found: {cron_id}"))?; entry.enabled = false; entry.updated_at = now_secs(); Ok(()) } /// Record a cron run. pub fn record_run(&self, cron_id: &str) -> Result<(), String> { let mut inner = self.inner.lock().expect("cron registry lock poisoned"); let entry = inner .entries .get_mut(cron_id) .ok_or_else(|| format!("cron not found: {cron_id}"))?; entry.last_run_at = Some(now_secs()); entry.run_count += 1; entry.updated_at = now_secs(); Ok(()) } #[must_use] pub fn len(&self) -> usize { let inner = self.inner.lock().expect("cron registry lock poisoned"); inner.entries.len() } #[must_use] pub fn is_empty(&self) -> bool { self.len() == 0 } } #[cfg(test)] mod tests { use super::*; // ── Team tests ────────────────────────────────────── #[test] fn creates_and_retrieves_team() { let registry = TeamRegistry::new(); let team = registry.create("Alpha Squad", vec!["task_001".into(), "task_002".into()]); assert_eq!(team.name, "Alpha Squad"); assert_eq!(team.task_ids.len(), 2); assert_eq!(team.status, TeamStatus::Created); let fetched = registry.get(&team.team_id).expect("team should exist"); assert_eq!(fetched.team_id, team.team_id); } #[test] fn lists_and_deletes_teams() { let registry = TeamRegistry::new(); let t1 = registry.create("Team A", vec![]); let t2 = registry.create("Team B", vec![]); let all = registry.list(); assert_eq!(all.len(), 2); let deleted = registry.delete(&t1.team_id).expect("delete should succeed"); assert_eq!(deleted.status, TeamStatus::Deleted); // Team is still listable (soft delete) let still_there = registry.get(&t1.team_id).unwrap(); assert_eq!(still_there.status, TeamStatus::Deleted); // Hard remove registry.remove(&t2.team_id); assert_eq!(registry.len(), 1); } #[test] fn rejects_missing_team_operations() { let registry = TeamRegistry::new(); assert!(registry.delete("nonexistent").is_err()); assert!(registry.get("nonexistent").is_none()); } // ── Cron tests ────────────────────────────────────── #[test] fn creates_and_retrieves_cron() { let registry = CronRegistry::new(); let entry = registry.create("0 * * * *", "Check status", Some("hourly check")); assert_eq!(entry.schedule, "0 * * * *"); assert_eq!(entry.prompt, "Check status"); assert!(entry.enabled); assert_eq!(entry.run_count, 0); assert!(entry.last_run_at.is_none()); let fetched = registry.get(&entry.cron_id).expect("cron should exist"); assert_eq!(fetched.cron_id, entry.cron_id); } #[test] fn lists_with_enabled_filter() { let registry = CronRegistry::new(); let c1 = registry.create("* * * * *", "Task 1", None); let c2 = registry.create("0 * * * *", "Task 2", None); registry .disable(&c1.cron_id) .expect("disable should succeed"); let all = registry.list(false); assert_eq!(all.len(), 2); let enabled_only = registry.list(true); assert_eq!(enabled_only.len(), 1); assert_eq!(enabled_only[0].cron_id, c2.cron_id); } #[test] fn deletes_cron_entry() { let registry = CronRegistry::new(); let entry = registry.create("* * * * *", "To delete", None); let deleted = registry .delete(&entry.cron_id) .expect("delete should succeed"); assert_eq!(deleted.cron_id, entry.cron_id); assert!(registry.get(&entry.cron_id).is_none()); assert!(registry.is_empty()); } #[test] fn records_cron_runs() { let registry = CronRegistry::new(); let entry = registry.create("*/5 * * * *", "Recurring", None); registry.record_run(&entry.cron_id).unwrap(); registry.record_run(&entry.cron_id).unwrap(); let fetched = registry.get(&entry.cron_id).unwrap(); assert_eq!(fetched.run_count, 2); assert!(fetched.last_run_at.is_some()); } #[test] fn rejects_missing_cron_operations() { let registry = CronRegistry::new(); assert!(registry.delete("nonexistent").is_err()); assert!(registry.disable("nonexistent").is_err()); assert!(registry.record_run("nonexistent").is_err()); assert!(registry.get("nonexistent").is_none()); } #[test] fn team_status_display_all_variants() { // given let cases = [ (TeamStatus::Created, "created"), (TeamStatus::Running, "running"), (TeamStatus::Completed, "completed"), (TeamStatus::Deleted, "deleted"), ]; // when let rendered: Vec<_> = cases .into_iter() .map(|(status, expected)| (status.to_string(), expected)) .collect(); // then assert_eq!( rendered, vec![ ("created".to_string(), "created"), ("running".to_string(), "running"), ("completed".to_string(), "completed"), ("deleted".to_string(), "deleted"), ] ); } #[test] fn new_team_registry_is_empty() { // given let registry = TeamRegistry::new(); // when let teams = registry.list(); // then assert!(registry.is_empty()); assert_eq!(registry.len(), 0); assert!(teams.is_empty()); } #[test] fn team_remove_nonexistent_returns_none() { // given let registry = TeamRegistry::new(); // when let removed = registry.remove("missing"); // then assert!(removed.is_none()); } #[test] fn team_len_transitions() { // given let registry = TeamRegistry::new(); // when let alpha = registry.create("Alpha", vec![]); let beta = registry.create("Beta", vec![]); let after_create = registry.len(); registry.remove(&alpha.team_id); let after_first_remove = registry.len(); registry.remove(&beta.team_id); // then assert_eq!(after_create, 2); assert_eq!(after_first_remove, 1); assert_eq!(registry.len(), 0); assert!(registry.is_empty()); } #[test] fn cron_list_all_disabled_returns_empty_for_enabled_only() { // given let registry = CronRegistry::new(); let first = registry.create("* * * * *", "Task 1", None); let second = registry.create("0 * * * *", "Task 2", None); registry .disable(&first.cron_id) .expect("disable should succeed"); registry .disable(&second.cron_id) .expect("disable should succeed"); // when let enabled_only = registry.list(true); let all_entries = registry.list(false); // then assert!(enabled_only.is_empty()); assert_eq!(all_entries.len(), 2); } #[test] fn cron_create_without_description() { // given let registry = CronRegistry::new(); // when let entry = registry.create("*/15 * * * *", "Check health", None); // then assert!(entry.cron_id.starts_with("cron_")); assert_eq!(entry.description, None); assert!(entry.enabled); assert_eq!(entry.run_count, 0); assert_eq!(entry.last_run_at, None); } #[test] fn new_cron_registry_is_empty() { // given let registry = CronRegistry::new(); // when let enabled_only = registry.list(true); let all_entries = registry.list(false); // then assert!(registry.is_empty()); assert_eq!(registry.len(), 0); assert!(enabled_only.is_empty()); assert!(all_entries.is_empty()); } #[test] fn cron_record_run_updates_timestamp_and_counter() { // given let registry = CronRegistry::new(); let entry = registry.create("*/5 * * * *", "Recurring", None); // when registry .record_run(&entry.cron_id) .expect("first run should succeed"); registry .record_run(&entry.cron_id) .expect("second run should succeed"); let fetched = registry.get(&entry.cron_id).expect("entry should exist"); // then assert_eq!(fetched.run_count, 2); assert!(fetched.last_run_at.is_some()); assert!(fetched.updated_at >= entry.updated_at); } #[test] fn cron_disable_updates_timestamp() { // given let registry = CronRegistry::new(); let entry = registry.create("0 0 * * *", "Nightly", None); // when registry .disable(&entry.cron_id) .expect("disable should succeed"); let fetched = registry.get(&entry.cron_id).expect("entry should exist"); // then assert!(!fetched.enabled); assert!(fetched.updated_at >= entry.updated_at); } }