fsmp 0.2.0

FSM Prompter — a CLI that steers AI agents through workflows by re-prompting them at each transition
Documentation
//! Data model for machine definitions and running instances.
//!
//! A **definition** is static — authored ahead of the run (by a person, or by
//! an agent working with one) and kept in version control alongside the
//! workflow it guards. The agent driving a machine never authors or mutates
//! its own definition. An **instance** is a live run: a snapshot
//! of the definition plus the current state, mutable context, and a transition
//! log. Instances live under `~/.fsmp/state/<id>/` and are never in version
//! control.

use indexmap::IndexMap;
use serde::{Deserialize, Serialize};

/// A scalar value used for params, context variables, and transition data.
///
/// `untagged` deserialization tries the variants in order, so an unquoted YAML
/// `true` becomes `Bool`, `2` becomes `Int`, and everything else `Str`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Value {
    Bool(bool),
    Int(i64),
    Str(String),
}

impl std::fmt::Display for Value {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Value::Bool(b) => write!(f, "{b}"),
            Value::Int(i) => write!(f, "{i}"),
            Value::Str(s) => write!(f, "{s}"),
        }
    }
}

impl Value {
    /// Interpret this value as an integer where possible (ints, or numeric strings).
    pub fn as_int(&self) -> Option<i64> {
        match self {
            Value::Int(i) => Some(*i),
            Value::Str(s) => s.parse().ok(),
            Value::Bool(_) => None,
        }
    }

    /// Coerce a raw `key=value` string fragment into the most specific scalar type.
    pub fn parse_scalar(s: &str) -> Value {
        match s {
            "true" => Value::Bool(true),
            "false" => Value::Bool(false),
            _ => match s.parse::<i64>() {
                Ok(i) => Value::Int(i),
                Err(_) => Value::Str(s.to_string()),
            },
        }
    }
}

/// Comparison operator for a guard.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Op {
    Eq,
    Ne,
    Lt,
    Lte,
    Gt,
    Gte,
}

/// The right-hand side of a guard comparison: exactly one of a literal
/// `value`, the name of a read-only `param`, or the name of another context
/// variable `ctx`. Modeling this as an enum (rather than three `Option`s)
/// makes "exactly one" true by construction — the previously-possible
/// zero-rhs and multi-rhs shapes are unrepresentable.
#[derive(Clone, Debug, PartialEq)]
pub enum Rhs {
    /// A literal scalar, compared directly.
    Value(Value),
    /// The name of a read-only param; resolved at evaluation time.
    Param(String),
    /// The name of another context variable (resolved context-then-param).
    Ctx(String),
}

/// A single structured comparison. No expression language — just
/// `<var> <op> <rhs>`.
///
/// In a definition the right-hand side is written with exactly one of the keys
/// `value`, `param`, or `ctx` (e.g. `{ var: count, op: gte, param: bar }`);
/// supplying none or more than one is rejected at parse time (a private raw
/// shape mediates the wire format). The public struct carries a single [`Rhs`],
/// so a constructed `Guard` can only ever name one right-hand side.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "RawGuard", into = "RawGuard")]
pub struct Guard {
    pub var: String,
    pub op: Op,
    pub rhs: Rhs,
}

/// The wire shape of a guard: three optional keys, of which exactly one must be
/// present. Kept private so the public [`Guard`] stays correct by construction;
/// [`Guard`]'s `serde(try_from/into)` routes through it. The three `Option`s
/// (with `skip_serializing_if`) also accept the pre-0.2.0 on-disk shape, where
/// the two absent alternatives were serialized as explicit `null`s.
///
/// The `expecting` string keeps a serde shape error (e.g. a guard written as a
/// scalar instead of a mapping) a usable author prompt — it describes the guard
/// shape rather than leaking this private mediating type's name.
#[derive(Serialize, Deserialize)]
#[serde(
    expecting = "a guard mapping with `var`, `op`, and exactly one of `value`, `param`, or `ctx`"
)]
struct RawGuard {
    var: String,
    op: Op,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    value: Option<Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    param: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    ctx: Option<String>,
}

impl TryFrom<RawGuard> for Guard {
    type Error = String;
    fn try_from(r: RawGuard) -> Result<Self, Self::Error> {
        let rhs = match (r.value, r.param, r.ctx) {
            (Some(v), None, None) => Rhs::Value(v),
            (None, Some(p), None) => Rhs::Param(p),
            (None, None, Some(c)) => Rhs::Ctx(c),
            // Parse errors are prompts for definition authors: name the guard
            // and say what "exactly one" means.
            (None, None, None) => {
                return Err(format!(
                    "guard on `{}` needs exactly one of `value`, `param`, or `ctx` (found none)",
                    r.var
                ))
            }
            _ => {
                return Err(format!(
                    "guard on `{}` needs exactly one of `value`, `param`, or `ctx` (found more than one)",
                    r.var
                ))
            }
        };
        Ok(Guard {
            var: r.var,
            op: r.op,
            rhs,
        })
    }
}

impl From<Guard> for RawGuard {
    fn from(g: Guard) -> RawGuard {
        let (value, param, ctx) = match g.rhs {
            Rhs::Value(v) => (Some(v), None, None),
            Rhs::Param(p) => (None, Some(p), None),
            Rhs::Ctx(c) => (None, None, Some(c)),
        };
        RawGuard {
            var: g.var,
            op: g.op,
            value,
            param,
            ctx,
        }
    }
}

/// A mutation applied to context when a transition fires. `untagged`, so the
/// shape in the definition selects the variant; `Cond` is tried first because
/// it is the only one carrying `if`/`then`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Effect {
    /// Apply `then` only when the guard holds (e.g. count a reviewer only if
    /// its initial verdict was clean).
    Cond {
        #[serde(rename = "if")]
        cond: Guard,
        then: Box<Effect>,
    },
    Set {
        set: String,
        to: Value,
    },
    Incr {
        incr: String,
    },
    Decr {
        decr: String,
    },
}

/// An edge out of a state.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Transition {
    /// Target state name.
    pub to: String,
    /// One-line "take this when …" shown in the valid-transition list.
    #[serde(default)]
    pub when: Option<String>,
    /// Why this move is blocked when its guards fail — shown in the
    /// blocked-from-here list. Interpolated. Falls back to a generic line.
    #[serde(default)]
    pub blocked_reason: Option<String>,
    /// All guards must pass for the transition to be available (implicit AND).
    #[serde(default)]
    pub guards: Vec<Guard>,
    /// `--data` keys that must be supplied when firing this transition.
    #[serde(default)]
    pub requires: Vec<String>,
    /// Context mutations applied when the transition fires.
    #[serde(default)]
    pub effects: Vec<Effect>,
}

/// A node: the prose re-injected on arrival plus the edges out.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct State {
    /// The just-in-time prompt for this state. Interpolated with `{var}`.
    #[serde(default)]
    pub guidance: String,
    #[serde(default)]
    pub terminal: bool,
    #[serde(default)]
    pub transitions: IndexMap<String, Transition>,
}

/// A static workflow, authored ahead of the run and fixed thereafter.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Definition {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    /// Read-only after `new`; set from defaults + `--set` overrides.
    #[serde(default)]
    pub params: IndexMap<String, Value>,
    /// Initial values for the mutable run context.
    #[serde(default)]
    pub context: IndexMap<String, Value>,
    pub initial: String,
    pub states: IndexMap<String, State>,
}

/// One recorded step in an instance's history.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LogEntry {
    pub seq: usize,
    pub transition: String,
    pub from: String,
    pub to: String,
    #[serde(default)]
    pub data: IndexMap<String, Value>,
    pub at: String,
}

/// A live run of a definition.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Instance {
    pub id: String,
    /// Snapshot taken at `new` — stable against later edits to the source file.
    pub definition: Definition,
    pub params: IndexMap<String, Value>,
    pub context: IndexMap<String, Value>,
    pub current: String,
    pub log: Vec<LogEntry>,
}

#[cfg(test)]
mod tests {
    use super::Value;

    #[test]
    fn parse_scalar_picks_the_most_specific_type() {
        assert_eq!(Value::parse_scalar("true"), Value::Bool(true));
        assert_eq!(Value::parse_scalar("false"), Value::Bool(false));
        assert_eq!(Value::parse_scalar("42"), Value::Int(42));
        assert_eq!(Value::parse_scalar("-3"), Value::Int(-3));
        assert_eq!(Value::parse_scalar("hello"), Value::Str("hello".into()));
        // A URL is not an int and must stay a string.
        assert_eq!(
            Value::parse_scalar("https://x/1"),
            Value::Str("https://x/1".into())
        );
    }

    #[test]
    fn as_int_coerces_numeric_strings_only() {
        assert_eq!(Value::Int(7).as_int(), Some(7));
        assert_eq!(Value::Str("7".into()).as_int(), Some(7));
        assert_eq!(Value::Str("seven".into()).as_int(), None);
        assert_eq!(Value::Bool(true).as_int(), None);
    }
}