use anyhow::{bail, Context, Result};
use chrono::Utc;
use clap::{Parser, Subcommand};
use fsmp::model::{Instance, LogEntry, Value};
use fsmp::{guide, lint, render, store};
use indexmap::IndexMap;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[derive(Parser)]
#[command(
name = "fsmp",
version,
about = "FSM Prompter — steer agents through workflows by re-prompting at each transition"
)]
struct Cli {
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
New {
#[arg(long)]
def: PathBuf,
#[arg(long)]
id: Option<String>,
#[arg(long = "set", value_name = "KEY=VALUE")]
set: Vec<String>,
},
Show {
#[arg(long)]
id: String,
},
Do {
transition: String,
#[arg(long)]
id: String,
#[arg(long = "data", value_name = "KEY=VALUE")]
data: Vec<String>,
},
Log {
#[arg(long)]
id: String,
},
Lint {
#[arg(long)]
def: PathBuf,
},
Guide {
topic: Option<String>,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(code) => code,
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::FAILURE
}
}
}
fn run(cli: &Cli) -> Result<ExitCode> {
match &cli.cmd {
Cmd::New { def, id, set } => cmd_new(cli.json, def, id.as_deref(), set),
Cmd::Show { id } => cmd_show(cli.json, id),
Cmd::Do {
transition,
id,
data,
} => cmd_do(cli.json, transition, id, data),
Cmd::Log { id } => cmd_log(cli.json, id),
Cmd::Lint { def } => cmd_lint(cli.json, def),
Cmd::Guide { topic } => cmd_guide(topic.as_deref()),
}
}
fn parse_kv(pairs: &[String]) -> Result<IndexMap<String, Value>> {
let mut map = IndexMap::new();
for p in pairs {
let (k, v) = p
.split_once('=')
.with_context(|| format!("expected key=value, got `{p}`"))?;
map.insert(k.to_string(), Value::parse_scalar(v));
}
Ok(map)
}
fn cmd_new(json: bool, def_path: &Path, id: Option<&str>, set: &[String]) -> Result<ExitCode> {
let definition = store::load_definition(def_path)?;
let id = id
.map(str::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
if store::instance_exists(&id)? {
bail!(
"instance `{id}` already exists at {:?}",
store::instance_dir(&id)?
);
}
let mut params = definition.params.clone();
for (k, v) in parse_kv(set)? {
params.insert(k, v);
}
let initial = definition.initial.clone();
let inst = Instance {
id: id.clone(),
context: definition.context.clone(),
params,
current: initial.clone(),
log: vec![LogEntry {
seq: 0,
transition: "new".to_string(),
from: String::new(),
to: initial,
data: IndexMap::new(),
at: Utc::now().to_rfc3339(),
}],
definition,
};
store::save_instance(&inst)?;
if json {
print_json(&render::render_json(&inst));
} else {
let header = format!(
"● created machine `{}` → state: {}",
inst.id, inst.current
);
print!("{}", render::render(&inst, &header));
}
Ok(ExitCode::SUCCESS)
}
fn cmd_show(json: bool, id: &str) -> Result<ExitCode> {
let inst = store::load_instance(id)?;
if json {
print_json(&render::render_json(&inst));
} else {
let header = format!("● state: {}", inst.current);
print!("{}", render::render(&inst, &header));
}
Ok(ExitCode::SUCCESS)
}
fn cmd_do(json: bool, name: &str, id: &str, data: &[String]) -> Result<ExitCode> {
let mut inst = store::load_instance(id)?;
let state = inst
.definition
.states
.get(&inst.current)
.cloned()
.with_context(|| format!("current state `{}` missing from snapshot", inst.current))?;
if state.terminal {
return reject(
json,
&inst,
&format!(
"`{}` is a terminal state — the machine is complete; no transitions remain.",
inst.current
),
);
}
let t = match state.transitions.get(name) {
Some(t) => t.clone(),
None => {
return reject(
json,
&inst,
&format!(
"`{name}` is not a valid transition from `{}`. Pick one of the valid transitions below.",
inst.current
),
);
}
};
let provided = parse_kv(data)?;
let missing: Vec<&String> = t
.requires
.iter()
.filter(|r| !provided.contains_key(*r))
.collect();
if !missing.is_empty() {
return reject(
json,
&inst,
&format!(
"transition `{name}` requires data: {}. Re-run with {}.",
t.requires.join(", "),
missing
.iter()
.map(|r| format!("--data {r}=<value>"))
.collect::<Vec<_>>()
.join(" ")
),
);
}
if !inst.guards_pass(&t) {
let reason = t
.blocked_reason
.as_deref()
.map(|r| inst.interpolate(r))
.unwrap_or_else(|| "a precondition is not yet met".to_string());
return reject(
json,
&inst,
&format!("transition `{name}` is blocked: {reason}"),
);
}
let from = inst.current.clone();
for (k, v) in &provided {
inst.context.insert(k.clone(), v.clone());
}
for e in &t.effects {
inst.apply_effect(e);
}
inst.current = t.to.clone();
inst.log.push(LogEntry {
seq: inst.log.len(),
transition: name.to_string(),
from,
to: t.to.clone(),
data: provided,
at: Utc::now().to_rfc3339(),
});
store::save_instance(&inst)?;
if json {
print_json(&render::render_json(&inst));
} else {
let header = format!("✔ {name} → state: {}", inst.current);
print!("{}", render::render(&inst, &header));
}
Ok(ExitCode::SUCCESS)
}
fn cmd_log(json: bool, id: &str) -> Result<ExitCode> {
let inst = store::load_instance(id)?;
if json {
print_json(&serde_json::to_value(&inst.log)?);
} else {
println!("history for `{}`:", inst.id);
for e in &inst.log {
let data = if e.data.is_empty() {
String::new()
} else {
let kvs: Vec<String> = e.data.iter().map(|(k, v)| format!("{k}={v}")).collect();
format!(" [{}]", kvs.join(", "))
};
let arrow = if e.from.is_empty() {
e.to.clone()
} else {
format!("{} → {}", e.from, e.to)
};
println!(
" {:>3}. {:<20} {arrow}{data} ({})",
e.seq, e.transition, e.at
);
}
}
Ok(ExitCode::SUCCESS)
}
fn cmd_lint(json: bool, def_path: &Path) -> Result<ExitCode> {
let def = store::parse_definition(def_path)?;
let findings = lint::lint(&def);
if json {
print_json(&lint::to_json(&findings));
} else {
print!("{}", lint::render_prose(&findings));
}
Ok(if findings.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
})
}
fn cmd_guide(topic: Option<&str>) -> Result<ExitCode> {
match topic {
None => {
print!("{}", guide::list_topics());
Ok(ExitCode::SUCCESS)
}
Some(t) => match guide::topic_text(t) {
Some(text) => {
print!("{text}");
Ok(ExitCode::SUCCESS)
}
None => {
bail!(
"unknown guide topic `{t}` (valid topics: {})",
guide::topic_names()
);
}
},
}
}
fn reject(json: bool, inst: &Instance, why: &str) -> Result<ExitCode> {
if json {
let mut v = render::render_json(inst);
v.as_object_mut().unwrap().insert(
"rejected".into(),
serde_json::Value::String(why.to_string()),
);
print_json(&v);
} else {
let header = format!("✗ rejected: {why}\n\n● state: {}", inst.current);
print!("{}", render::render(inst, &header));
}
Ok(ExitCode::FAILURE)
}
fn print_json(v: &serde_json::Value) {
println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
}
#[cfg(test)]
mod tests {
use super::parse_kv;
use fsmp::model::Value;
#[test]
fn parses_and_coerces_pairs_preserving_order() {
let m = parse_kv(&[
"bar=2".into(),
"pr_url=https://x/1".into(),
"on=true".into(),
])
.unwrap();
assert_eq!(m["bar"], Value::Int(2));
assert_eq!(m["pr_url"], Value::Str("https://x/1".into()));
assert_eq!(m["on"], Value::Bool(true));
assert_eq!(m.keys().collect::<Vec<_>>(), vec!["bar", "pr_url", "on"]);
}
#[test]
fn splits_only_on_the_first_equals() {
let m = parse_kv(&["url=https://x/1?a=b".into()]).unwrap();
assert_eq!(m["url"], Value::Str("https://x/1?a=b".into()));
}
#[test]
fn rejects_a_fragment_without_equals() {
assert!(parse_kv(&["novalue".into()]).is_err());
}
}