diff --git a/src/domain.rs b/src/domain.rs index c07d685..e3991b2 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1 +1,217 @@ -// Placeholder for upcoming domain module. +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub const POLICY_SCHEMA_VERSION: u16 = 1; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CommitmentSource { + Manual, + Planner, + Recurring, + Recovery, + Template, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CommitmentState { + Draft, + Active, + Paused, + Completed, + Abandoned, + Violated, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeState { + Locked, + Planning, + Active, + Transition, + Review, + AdminOverride, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnforcementLevel { + Observe, + Warn, + Block, + Locked, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EvidenceHealth { + Available, + Degraded(String), + Unavailable(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AllowedContext { + pub window_classes: Vec, + pub window_title_substrings: Vec, + pub domains: Vec, + pub repos: Vec, + pub commands: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Commitment { + pub id: String, + pub created_at_unix_secs: u64, + pub source: CommitmentSource, + pub project_id: Option, + pub template_id: Option, + pub next_action: String, + pub success_condition: String, + pub timebox_secs: u64, + pub transition_policy_id: Option, + pub state: CommitmentState, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PolicySnapshot { + pub id: String, + pub commitment_id: String, + pub schema_version: u16, + pub created_at_unix_secs: u64, + pub runtime_state: RuntimeState, + pub enforcement_level: EnforcementLevel, + pub allowed_context: AllowedContext, + pub required_monitors: Vec, + pub violation_actions: Vec, + pub expires_at_unix_secs: Option, + pub generated_by_agent_version: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommitmentValidationError { + MissingNextAction, + MissingSuccessCondition, + MissingTimebox, +} + +impl fmt::Display for CommitmentValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CommitmentValidationError::MissingNextAction => write!(f, "next action is required"), + CommitmentValidationError::MissingSuccessCondition => { + write!(f, "success condition is required") + } + CommitmentValidationError::MissingTimebox => write!(f, "timebox must be nonzero"), + } + } +} + +impl Commitment { + pub fn new_manual( + next_action: impl Into, + success_condition: impl Into, + timebox: Duration, + ) -> Result { + let next_action = next_action.into().trim().to_string(); + let success_condition = success_condition.into().trim().to_string(); + + if next_action.is_empty() { + return Err(CommitmentValidationError::MissingNextAction); + } + if success_condition.is_empty() { + return Err(CommitmentValidationError::MissingSuccessCondition); + } + if timebox.is_zero() { + return Err(CommitmentValidationError::MissingTimebox); + } + + let created_at_unix_secs = unix_secs_now(); + Ok(Self { + id: format!("commitment-{created_at_unix_secs}"), + created_at_unix_secs, + source: CommitmentSource::Manual, + project_id: None, + template_id: None, + next_action, + success_condition, + timebox_secs: timebox.as_secs(), + transition_policy_id: None, + state: CommitmentState::Draft, + }) + } +} + +impl PolicySnapshot { + pub fn for_runtime( + commitment_id: String, + runtime_state: RuntimeState, + enforcement_level: EnforcementLevel, + allowed_context: AllowedContext, + ) -> Self { + let created_at_unix_secs = unix_secs_now(); + Self { + id: format!("policy-{created_at_unix_secs}"), + commitment_id, + schema_version: POLICY_SCHEMA_VERSION, + created_at_unix_secs, + runtime_state, + enforcement_level, + allowed_context, + required_monitors: Vec::new(), + violation_actions: Vec::new(), + expires_at_unix_secs: None, + generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(), + } + } +} + +pub fn unix_secs_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn commitment_requires_concrete_action_and_success_condition() { + let err = Commitment::new_manual("", "tests pass", Duration::from_secs(1500)) + .expect_err("empty next action must be rejected"); + assert_eq!(err, CommitmentValidationError::MissingNextAction); + + let err = Commitment::new_manual("Refactor state", "", Duration::from_secs(1500)) + .expect_err("empty success condition must be rejected"); + assert_eq!(err, CommitmentValidationError::MissingSuccessCondition); + } + + #[test] + fn commitment_requires_nonzero_timebox() { + let err = Commitment::new_manual("Refactor state", "tests pass", Duration::ZERO) + .expect_err("zero timebox must be rejected"); + assert_eq!(err, CommitmentValidationError::MissingTimebox); + } + + #[test] + fn policy_snapshot_carries_runtime_contract() { + let commitment = Commitment::new_manual( + "Implement domain types", + "domain tests pass", + Duration::from_secs(1500), + ) + .unwrap(); + let policy = PolicySnapshot::for_runtime( + commitment.id.clone(), + RuntimeState::Active, + EnforcementLevel::Warn, + AllowedContext::default(), + ); + + assert_eq!(policy.commitment_id, commitment.id); + assert_eq!(policy.runtime_state, RuntimeState::Active); + assert_eq!(policy.enforcement_level, EnforcementLevel::Warn); + assert_eq!(policy.schema_version, 1); + } +}