use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use super::demand::validate_slug;
pub const STATE_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EngineStatus {
Queued,
Orchestrating,
AwaitingApproval,
ReadyForExec,
Paused,
Error,
Manual(String),
}
impl EngineStatus {
pub fn to_repr(&self) -> String {
match self {
Self::Queued => "queued".to_string(),
Self::Orchestrating => "orchestrating".to_string(),
Self::AwaitingApproval => "awaiting_approval".to_string(),
Self::ReadyForExec => "ready_for_exec".to_string(),
Self::Paused => "paused".to_string(),
Self::Error => "error".to_string(),
Self::Manual(stage) => format!("manual:{stage}"),
}
}
pub fn from_repr(value: &str) -> std::result::Result<Self, String> {
if let Some(stage) = value.strip_prefix("manual:") {
if stage.is_empty() {
return Err("status manual sem etapa".to_string());
}
return Ok(Self::Manual(stage.to_string()));
}
match value {
"queued" => Ok(Self::Queued),
"orchestrating" => Ok(Self::Orchestrating),
"awaiting_approval" => Ok(Self::AwaitingApproval),
"ready_for_exec" => Ok(Self::ReadyForExec),
"paused" => Ok(Self::Paused),
"error" => Ok(Self::Error),
other => Err(format!("status de motor inválido: {other}")),
}
}
}
impl From<EngineStatus> for String {
fn from(value: EngineStatus) -> Self {
value.to_repr()
}
}
impl TryFrom<String> for EngineStatus {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, String> {
EngineStatus::from_repr(&value)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecisionKind {
Approve,
Reject,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Channel {
Cli,
Slack,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Approval {
pub slug: String,
pub gate: String,
pub decision: DecisionKind,
pub author: String,
pub channel: Channel,
pub ts: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lock {
pub owner: String,
pub acquired_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineState {
pub schema_version: u32,
pub slug: String,
pub cursor: String,
#[serde(with = "engine_status_serde")]
pub status: EngineStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lock: Option<Lock>,
#[serde(default)]
pub approvals: Vec<Approval>,
#[serde(default)]
pub attempts: BTreeMap<String, u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_tick: Option<String>,
}
mod engine_status_serde {
use super::EngineStatus;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(value: &EngineStatus, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&value.to_repr())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<EngineStatus, D::Error> {
let raw = String::deserialize(d)?;
EngineStatus::from_repr(&raw).map_err(serde::de::Error::custom)
}
}
impl EngineState {
pub fn new(slug: impl Into<String>) -> Result<Self> {
let slug = slug.into();
validate_slug(&slug)?;
Ok(Self {
schema_version: STATE_SCHEMA_VERSION,
slug,
cursor: "idea".to_string(),
status: EngineStatus::Queued,
lock: None,
approvals: Vec::new(),
attempts: BTreeMap::new(),
last_tick: None,
})
}
}
pub fn state_dir(root: &Path) -> PathBuf {
root.join(".sdd").join("state")
}
fn state_path(root: &Path, slug: &str) -> Result<PathBuf> {
validate_slug(slug)?;
Ok(state_dir(root).join(format!("{slug}.json")))
}
pub fn save(root: &Path, state: &EngineState) -> Result<()> {
if state.schema_version != STATE_SCHEMA_VERSION {
bail!(
"schema_version de estado não suportado ao salvar: {} (esperado {})",
state.schema_version,
STATE_SCHEMA_VERSION
);
}
let path = state_path(root, &state.slug)?;
let body = serde_json::to_string_pretty(state).context("serializando estado do motor")?;
super::write_atomic(&path, body.as_bytes())
}
pub fn load(root: &Path, slug: &str) -> Result<Option<EngineState>> {
let path = state_path(root, slug)?;
if !path.exists() {
return Ok(None);
}
let text = fs_read(&path)?;
let state: EngineState = serde_json::from_str(&text)
.with_context(|| format!("desserializando estado {}", path.display()))?;
if state.schema_version != STATE_SCHEMA_VERSION {
bail!(
"schema_version de estado não suportado: {} (esperado {})",
state.schema_version,
STATE_SCHEMA_VERSION
);
}
Ok(Some(state))
}
fn fs_read(path: &Path) -> Result<String> {
std::fs::read_to_string(path).with_context(|| format!("lendo estado {}", path.display()))
}
pub fn acquire_lock(
root: &Path,
state: &mut EngineState,
owner: impl Into<String>,
now: impl Into<String>,
) -> Result<bool> {
let owner = owner.into();
if let Some(lock) = &state.lock {
if lock.owner != owner {
return Ok(false);
}
}
state.lock = Some(Lock {
owner,
acquired_at: now.into(),
});
save(root, state)?;
Ok(true)
}
pub fn release_lock(root: &Path, state: &mut EngineState) -> Result<()> {
state.lock = None;
save(root, state)
}
#[allow(dead_code)]
pub fn artifact_state_for_status(status: &EngineStatus) -> &'static str {
match status {
EngineStatus::ReadyForExec => "approved",
EngineStatus::Error => "recorded",
_ => "in_progress",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh(root: &Path) -> EngineState {
let mut st = EngineState::new("dem-1").unwrap();
st.last_tick = Some("2026-06-08T00:00:00Z".to_string());
save(root, &st).unwrap();
st
}
#[test]
fn new_state_defaults() {
let st = EngineState::new("dem-1").unwrap();
assert_eq!(st.schema_version, STATE_SCHEMA_VERSION);
assert_eq!(st.cursor, "idea");
assert_eq!(st.status, EngineStatus::Queued);
assert!(st.lock.is_none());
assert!(EngineState::new("../escape").is_err());
}
#[test]
fn save_and_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut st = fresh(root);
st.status = EngineStatus::AwaitingApproval;
st.cursor = "prd".to_string();
st.approvals.push(Approval {
slug: "dem-1".to_string(),
gate: "prd".to_string(),
decision: DecisionKind::Approve,
author: "alan@x".to_string(),
channel: Channel::Cli,
ts: "2026-06-08T01:00:00Z".to_string(),
reason: None,
});
*st.attempts.entry("techspec".to_string()).or_insert(0) += 1;
save(root, &st).unwrap();
let back = load(root, "dem-1").unwrap().unwrap();
assert_eq!(st, back);
assert!(state_dir(root).join("dem-1.json").is_file());
}
#[test]
fn load_missing_is_none() {
let dir = tempfile::tempdir().unwrap();
assert!(load(dir.path(), "nope").unwrap().is_none());
}
#[test]
fn divergent_schema_version_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(state_dir(root)).unwrap();
let bad = r#"{ "schema_version": 99, "slug": "dem-1", "cursor": "idea",
"status": "queued", "approvals": [], "attempts": {} }"#;
std::fs::write(state_dir(root).join("dem-1.json"), bad).unwrap();
assert!(load(root, "dem-1")
.unwrap_err()
.to_string()
.contains("schema_version"));
}
#[test]
fn lock_is_exclusive_per_owner() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut st = fresh(root);
assert!(acquire_lock(root, &mut st, "tick-1", "t0").unwrap());
let mut other = load(root, "dem-1").unwrap().unwrap();
assert!(!acquire_lock(root, &mut other, "tick-2", "t1").unwrap());
assert!(acquire_lock(root, &mut st, "tick-1", "t2").unwrap());
release_lock(root, &mut st).unwrap();
let mut third = load(root, "dem-1").unwrap().unwrap();
assert!(acquire_lock(root, &mut third, "tick-2", "t3").unwrap());
}
#[test]
fn status_repr_round_trip_including_manual() {
for s in [
EngineStatus::Queued,
EngineStatus::AwaitingApproval,
EngineStatus::ReadyForExec,
EngineStatus::Manual("techspec".to_string()),
] {
let repr = s.to_repr();
assert_eq!(EngineStatus::from_repr(&repr).unwrap(), s);
}
assert_eq!(
EngineStatus::Manual("techspec".to_string()).to_repr(),
"manual:techspec"
);
assert!(EngineStatus::from_repr("bogus").is_err());
}
#[test]
fn option_a_never_emits_rich_artifact_state() {
let safe = ["recorded", "in_progress", "approved", "skipped"];
for s in [
EngineStatus::Queued,
EngineStatus::Orchestrating,
EngineStatus::AwaitingApproval,
EngineStatus::ReadyForExec,
EngineStatus::Paused,
EngineStatus::Error,
EngineStatus::Manual("prd".to_string()),
] {
assert!(
safe.contains(&artifact_state_for_status(&s)),
"status {s:?} vazou estado rico para o map"
);
}
}
#[test]
fn status_serializes_as_string_in_json() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut st = fresh(root);
st.status = EngineStatus::Manual("techspec".to_string());
save(root, &st).unwrap();
let raw = std::fs::read_to_string(state_dir(root).join("dem-1.json")).unwrap();
assert!(raw.contains("\"status\": \"manual:techspec\""));
}
}