use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepoConfig { pub repo_root: PathBuf, pub worktree_root: Option, } impl RepoConfig { #[must_use] pub fn dispatch_root(&self) -> &Path { self.worktree_root .as_deref() .unwrap_or(self.repo_root.as_path()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TaskScope { SingleFile { path: PathBuf }, Module { crate_name: String }, Workspace, Custom { paths: Vec }, } impl TaskScope { #[must_use] pub fn resolve_paths(&self, repo_config: &RepoConfig) -> Vec { let dispatch_root = repo_config.dispatch_root(); match self { Self::SingleFile { path } => vec![resolve_path(dispatch_root, path)], Self::Module { crate_name } => vec![dispatch_root.join("crates").join(crate_name)], Self::Workspace => vec![dispatch_root.to_path_buf()], Self::Custom { paths } => paths .iter() .map(|path| resolve_path(dispatch_root, path)) .collect(), } } } impl Display for TaskScope { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::SingleFile { .. } => write!(f, "single_file"), Self::Module { .. } => write!(f, "module"), Self::Workspace => write!(f, "workspace"), Self::Custom { .. } => write!(f, "custom"), } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BranchPolicy { CreateNew { prefix: String }, UseExisting { name: String }, WorktreeIsolated, } impl Display for BranchPolicy { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::CreateNew { .. } => write!(f, "create_new"), Self::UseExisting { .. } => write!(f, "use_existing"), Self::WorktreeIsolated => write!(f, "worktree_isolated"), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CommitPolicy { CommitPerTask, SquashOnMerge, NoAutoCommit, } impl Display for CommitPolicy { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::CommitPerTask => write!(f, "commit_per_task"), Self::SquashOnMerge => write!(f, "squash_on_merge"), Self::NoAutoCommit => write!(f, "no_auto_commit"), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GreenLevel { Package, Workspace, MergeReady, } impl Display for GreenLevel { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::Package => write!(f, "package"), Self::Workspace => write!(f, "workspace"), Self::MergeReady => write!(f, "merge_ready"), } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AcceptanceTest { CargoTest { filter: Option }, CustomCommand { cmd: String }, GreenLevel { level: GreenLevel }, } impl Display for AcceptanceTest { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::CargoTest { .. } => write!(f, "cargo_test"), Self::CustomCommand { .. } => write!(f, "custom_command"), Self::GreenLevel { .. } => write!(f, "green_level"), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReportingContract { EventStream, Summary, Silent, } impl Display for ReportingContract { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::EventStream => write!(f, "event_stream"), Self::Summary => write!(f, "summary"), Self::Silent => write!(f, "silent"), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum EscalationPolicy { RetryThenEscalate { max_retries: u32 }, AutoEscalate, NeverEscalate, } impl Display for EscalationPolicy { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::RetryThenEscalate { .. } => write!(f, "retry_then_escalate"), Self::AutoEscalate => write!(f, "auto_escalate"), Self::NeverEscalate => write!(f, "never_escalate"), } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TaskPacket { pub id: String, pub objective: String, pub scope: TaskScope, pub repo_config: RepoConfig, pub branch_policy: BranchPolicy, pub acceptance_tests: Vec, pub commit_policy: CommitPolicy, pub reporting: ReportingContract, pub escalation: EscalationPolicy, pub created_at: u64, pub metadata: BTreeMap, } impl TaskPacket { #[must_use] pub fn resolve_scope_paths(&self) -> Vec { self.scope.resolve_paths(&self.repo_config) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaskPacketValidationError { errors: Vec, } impl TaskPacketValidationError { #[must_use] pub fn new(errors: Vec) -> Self { Self { errors } } #[must_use] pub fn errors(&self) -> &[String] { &self.errors } } impl Display for TaskPacketValidationError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.errors.join("; ")) } } impl std::error::Error for TaskPacketValidationError {} #[derive(Debug, Clone, PartialEq)] pub struct ValidatedPacket(TaskPacket); impl ValidatedPacket { #[must_use] pub fn packet(&self) -> &TaskPacket { &self.0 } #[must_use] pub fn into_inner(self) -> TaskPacket { self.0 } #[must_use] pub fn resolve_scope_paths(&self) -> Vec { self.0.resolve_scope_paths() } } pub fn validate_packet(packet: TaskPacket) -> Result { let mut errors = Vec::new(); if packet.id.trim().is_empty() { errors.push("packet id must not be empty".to_string()); } if packet.objective.trim().is_empty() { errors.push("packet objective must not be empty".to_string()); } if packet.repo_config.repo_root.as_os_str().is_empty() { errors.push("repo_config repo_root must not be empty".to_string()); } if packet .repo_config .worktree_root .as_ref() .is_some_and(|path| path.as_os_str().is_empty()) { errors.push("repo_config worktree_root must not be empty when present".to_string()); } validate_scope(&packet.scope, &mut errors); validate_branch_policy(&packet.branch_policy, &mut errors); validate_acceptance_tests(&packet.acceptance_tests, &mut errors); validate_escalation_policy(packet.escalation, &mut errors); if errors.is_empty() { Ok(ValidatedPacket(packet)) } else { Err(TaskPacketValidationError::new(errors)) } } fn validate_scope(scope: &TaskScope, errors: &mut Vec) { match scope { TaskScope::SingleFile { path } if path.as_os_str().is_empty() => { errors.push("single_file scope path must not be empty".to_string()); } TaskScope::Module { crate_name } if crate_name.trim().is_empty() => { errors.push("module scope crate_name must not be empty".to_string()); } TaskScope::Custom { paths } if paths.is_empty() => { errors.push("custom scope paths must not be empty".to_string()); } TaskScope::Custom { paths } => { for (index, path) in paths.iter().enumerate() { if path.as_os_str().is_empty() { errors.push(format!("custom scope contains empty path at index {index}")); } } } TaskScope::SingleFile { .. } | TaskScope::Module { .. } | TaskScope::Workspace => {} } } fn validate_branch_policy(branch_policy: &BranchPolicy, errors: &mut Vec) { match branch_policy { BranchPolicy::CreateNew { prefix } if prefix.trim().is_empty() => { errors.push("create_new branch prefix must not be empty".to_string()); } BranchPolicy::UseExisting { name } if name.trim().is_empty() => { errors.push("use_existing branch name must not be empty".to_string()); } BranchPolicy::CreateNew { .. } | BranchPolicy::UseExisting { .. } | BranchPolicy::WorktreeIsolated => {} } } fn validate_acceptance_tests(tests: &[AcceptanceTest], errors: &mut Vec) { for test in tests { match test { AcceptanceTest::CargoTest { filter } => { if filter .as_deref() .is_some_and(|value| value.trim().is_empty()) { errors.push("cargo_test filter must not be empty when present".to_string()); } } AcceptanceTest::CustomCommand { cmd } if cmd.trim().is_empty() => { errors.push("custom_command cmd must not be empty".to_string()); } AcceptanceTest::CustomCommand { .. } | AcceptanceTest::GreenLevel { .. } => {} } } } fn validate_escalation_policy(escalation: EscalationPolicy, errors: &mut Vec) { if matches!( escalation, EscalationPolicy::RetryThenEscalate { max_retries: 0 } ) { errors.push("retry_then_escalate max_retries must be greater than zero".to_string()); } } fn resolve_path(dispatch_root: &Path, path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() } else { dispatch_root.join(path) } } #[cfg(test)] mod tests { use super::*; use serde_json::json; use std::time::{SystemTime, UNIX_EPOCH}; fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn sample_repo_config() -> RepoConfig { RepoConfig { repo_root: PathBuf::from("/repo"), worktree_root: Some(PathBuf::from("/repo/.worktrees/task-1")), } } fn sample_packet() -> TaskPacket { let mut metadata = BTreeMap::new(); metadata.insert("attempt".to_string(), json!(1)); metadata.insert("lane".to_string(), json!("runtime")); TaskPacket { id: "packet_001".to_string(), objective: "Implement typed task packet format".to_string(), scope: TaskScope::Module { crate_name: "runtime".to_string(), }, repo_config: sample_repo_config(), branch_policy: BranchPolicy::CreateNew { prefix: "ultraclaw".to_string(), }, acceptance_tests: vec![ AcceptanceTest::CargoTest { filter: Some("task_packet".to_string()), }, AcceptanceTest::GreenLevel { level: GreenLevel::Workspace, }, ], commit_policy: CommitPolicy::CommitPerTask, reporting: ReportingContract::EventStream, escalation: EscalationPolicy::RetryThenEscalate { max_retries: 2 }, created_at: now_secs(), metadata, } } #[test] fn valid_packet_passes_validation() { // given let packet = sample_packet(); // when let validated = validate_packet(packet); // then assert!(validated.is_ok()); } #[test] fn invalid_packet_accumulates_errors() { // given let packet = TaskPacket { id: " ".to_string(), objective: " ".to_string(), scope: TaskScope::Custom { paths: vec![PathBuf::new()], }, repo_config: RepoConfig { repo_root: PathBuf::new(), worktree_root: Some(PathBuf::new()), }, branch_policy: BranchPolicy::CreateNew { prefix: " ".to_string(), }, acceptance_tests: vec![ AcceptanceTest::CargoTest { filter: Some(" ".to_string()), }, AcceptanceTest::CustomCommand { cmd: " ".to_string(), }, ], commit_policy: CommitPolicy::NoAutoCommit, reporting: ReportingContract::Silent, escalation: EscalationPolicy::RetryThenEscalate { max_retries: 0 }, created_at: 0, metadata: BTreeMap::new(), }; // when let error = validate_packet(packet).expect_err("packet should be rejected"); // then assert!(error.errors().len() >= 8); assert!(error .errors() .contains(&"packet id must not be empty".to_string())); assert!(error .errors() .contains(&"packet objective must not be empty".to_string())); assert!(error .errors() .contains(&"repo_config repo_root must not be empty".to_string())); assert!(error .errors() .contains(&"create_new branch prefix must not be empty".to_string())); } #[test] fn single_file_scope_resolves_against_worktree_root() { // given let repo_config = sample_repo_config(); let scope = TaskScope::SingleFile { path: PathBuf::from("crates/runtime/src/task_packet.rs"), }; // when let paths = scope.resolve_paths(&repo_config); // then assert_eq!( paths, vec![PathBuf::from( "/repo/.worktrees/task-1/crates/runtime/src/task_packet.rs" )] ); } #[test] fn workspace_scope_resolves_to_dispatch_root() { // given let repo_config = sample_repo_config(); let scope = TaskScope::Workspace; // when let paths = scope.resolve_paths(&repo_config); // then assert_eq!(paths, vec![PathBuf::from("/repo/.worktrees/task-1")]); } #[test] fn module_scope_resolves_to_crate_directory() { // given let repo_config = sample_repo_config(); let scope = TaskScope::Module { crate_name: "runtime".to_string(), }; // when let paths = scope.resolve_paths(&repo_config); // then assert_eq!( paths, vec![PathBuf::from("/repo/.worktrees/task-1/crates/runtime")] ); } #[test] fn custom_scope_preserves_absolute_paths_and_resolves_relative_paths() { // given let repo_config = sample_repo_config(); let scope = TaskScope::Custom { paths: vec![ PathBuf::from("Cargo.toml"), PathBuf::from("/tmp/shared/script.sh"), ], }; // when let paths = scope.resolve_paths(&repo_config); // then assert_eq!( paths, vec![ PathBuf::from("/repo/.worktrees/task-1/Cargo.toml"), PathBuf::from("/tmp/shared/script.sh"), ] ); } #[test] fn serialization_roundtrip_preserves_packet() { // given let packet = sample_packet(); // when let serialized = serde_json::to_string(&packet).expect("packet should serialize"); let deserialized: TaskPacket = serde_json::from_str(&serialized).expect("packet should deserialize"); // then assert_eq!(deserialized, packet); } #[test] fn validated_packet_exposes_inner_packet_and_scope_paths() { // given let packet = sample_packet(); // when let validated = validate_packet(packet.clone()).expect("packet should validate"); let resolved_paths = validated.resolve_scope_paths(); let inner = validated.into_inner(); // then assert_eq!( resolved_paths, vec![PathBuf::from("/repo/.worktrees/task-1/crates/runtime")] ); assert_eq!(inner, packet); } #[test] fn display_impls_render_snake_case_variants() { // given let rendered = vec![ TaskScope::Workspace.to_string(), BranchPolicy::WorktreeIsolated.to_string(), CommitPolicy::SquashOnMerge.to_string(), GreenLevel::MergeReady.to_string(), AcceptanceTest::GreenLevel { level: GreenLevel::Package, } .to_string(), ReportingContract::EventStream.to_string(), EscalationPolicy::AutoEscalate.to_string(), ]; // when let expected = vec![ "workspace", "worktree_isolated", "squash_on_merge", "merge_ready", "green_level", "event_stream", "auto_escalate", ]; // then assert_eq!(rendered, expected); } }