use crate::model::{Definition, Instance};
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub fn home_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("FSMP_HOME") {
return Ok(PathBuf::from(dir));
}
let home = std::env::var("HOME").context("HOME is not set; cannot locate ~/.fsmp")?;
Ok(PathBuf::from(home).join(".fsmp"))
}
pub fn state_dir() -> Result<PathBuf> {
Ok(home_dir()?.join("state"))
}
fn validate_id(id: &str) -> Result<()> {
let ok = !id.is_empty()
&& !id.starts_with('.')
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
anyhow::ensure!(
ok,
"invalid instance id `{id}`: use ASCII letters, digits, `-`, `_`, or `.` (no leading `.`)"
);
Ok(())
}
pub fn instance_dir(id: &str) -> Result<PathBuf> {
validate_id(id)?;
Ok(state_dir()?.join(id))
}
fn instance_path(id: &str) -> Result<PathBuf> {
Ok(instance_dir(id)?.join("instance.json"))
}
pub fn parse_definition(path: &Path) -> Result<Definition> {
enum Format {
Yaml,
Json,
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let format = match ext.as_deref() {
Some("json") => Format::Json,
Some("yaml") | Some("yml") => Format::Yaml,
_ => anyhow::bail!(
"unsupported definition extension for {path:?} (expected .yaml, .yml, or .json)"
),
};
let text =
std::fs::read_to_string(path).with_context(|| format!("reading definition {path:?}"))?;
let def: Definition = match format {
Format::Json => serde_json::from_str(&text)
.with_context(|| format!("parsing JSON definition {path:?}"))?,
Format::Yaml => serde_yaml::from_str(&text)
.with_context(|| format!("parsing YAML definition {path:?}"))?,
};
Ok(def)
}
pub fn load_definition(path: &Path) -> Result<Definition> {
let def = parse_definition(path)?;
validate(&def).with_context(|| format!("invalid definition {path:?}"))?;
Ok(def)
}
fn validate(def: &Definition) -> Result<()> {
anyhow::ensure!(
def.states.contains_key(&def.initial),
"initial state `{}` is not defined",
def.initial
);
for (sname, state) in &def.states {
for (tname, t) in &state.transitions {
anyhow::ensure!(
def.states.contains_key(&t.to),
"state `{sname}` transition `{tname}` targets unknown state `{}`",
t.to
);
}
}
Ok(())
}
pub fn instance_exists(id: &str) -> Result<bool> {
Ok(instance_path(id)?.exists())
}
pub fn save_instance(inst: &Instance) -> Result<()> {
let dir = instance_dir(&inst.id)?;
std::fs::create_dir_all(&dir).with_context(|| format!("creating {dir:?}"))?;
let json = serde_json::to_string_pretty(inst)?;
let tmp = dir.join("instance.json.tmp");
std::fs::write(&tmp, json).with_context(|| format!("writing {tmp:?}"))?;
std::fs::rename(&tmp, instance_path(&inst.id)?).context("committing instance.json")?;
Ok(())
}
pub fn load_instance(id: &str) -> Result<Instance> {
let path = instance_path(id)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("no instance `{id}` at {path:?} (did you run `fsmp new`?)"))?;
let inst: Instance = serde_json::from_str(&text).context("parsing instance.json")?;
anyhow::ensure!(
inst.definition.states.contains_key(&inst.current),
"instance `{id}` is corrupt: current state `{}` is not in its definition snapshot",
inst.current
);
Ok(inst)
}
#[cfg(test)]
mod tests {
use super::load_definition;
use std::path::PathBuf;
fn temp_def(name: &str, ext: &str, contents: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("fsmp-store-{name}.{ext}"));
std::fs::write(&path, contents).unwrap();
path
}
const VALID: &str = "\
name: t
initial: a
states:
a:
transitions:
go: { to: b }
b:
terminal: true
";
#[test]
fn loads_valid_yaml_and_defaults_empty_params_and_context() {
let p = temp_def("valid", "yaml", VALID);
let def = load_definition(&p).expect("valid yaml should load");
assert_eq!(def.initial, "a");
assert!(def.params.is_empty());
assert!(def.context.is_empty());
assert!(def.states["b"].terminal);
}
#[test]
fn loads_json_by_extension() {
let json = r#"{"name":"t","initial":"a","states":{"a":{"transitions":{}}}}"#;
let p = temp_def("json", "json", json);
let def = load_definition(&p).expect("valid json should load");
assert_eq!(def.initial, "a");
}
#[test]
fn rejects_unknown_initial_state() {
let bad = VALID.replace("initial: a", "initial: nope");
let p = temp_def("badinitial", "yaml", &bad);
let err = load_definition(&p).unwrap_err().to_string();
assert!(err.contains("initial"), "unexpected error: {err}");
}
#[test]
fn rejects_transition_to_unknown_state() {
let bad = VALID.replace("go: { to: b }", "go: { to: ghost }");
let p = temp_def("badtarget", "yaml", &bad);
let err = format!("{:#}", load_definition(&p).unwrap_err());
assert!(err.contains("ghost"), "unexpected error: {err}");
}
#[test]
fn surfaces_a_parse_error_for_malformed_yaml() {
let p = temp_def("malformed", "yaml", "name: [unterminated\n");
assert!(load_definition(&p).is_err());
}
#[test]
fn accepts_yml_extension() {
let p = temp_def("yml", "yml", VALID);
let def = load_definition(&p).expect("valid .yml should load");
assert_eq!(def.initial, "a");
}
#[test]
fn accepts_uppercase_extensions_case_insensitively() {
let py = temp_def("upperyaml", "YAML", VALID);
assert_eq!(
load_definition(&py).expect("`.YAML` should load").initial,
"a"
);
let json = r#"{"name":"t","initial":"a","states":{"a":{"transitions":{}}}}"#;
let pj = temp_def("upperjson", "JSON", json);
assert_eq!(
load_definition(&pj).expect("`.JSON` should load").initial,
"a"
);
}
#[test]
fn rejects_unsupported_extension_naming_the_accepted_set() {
let p = temp_def("txt", "txt", VALID);
let err = load_definition(&p).unwrap_err().to_string();
assert!(err.contains(".yaml"), "should name .yaml: {err}");
assert!(err.contains(".yml"), "should name .yml: {err}");
assert!(err.contains(".json"), "should name .json: {err}");
}
#[test]
fn unsupported_extension_wins_over_a_missing_file() {
let missing = std::env::temp_dir().join("fsmp-store-does-not-exist.txt");
let _ = std::fs::remove_file(&missing); let err = load_definition(&missing).unwrap_err().to_string();
assert!(
err.contains("unsupported definition extension"),
"extension error should win over the read error: {err}"
);
assert!(
!err.contains("reading definition"),
"should not have read: {err}"
);
}
#[test]
fn rejects_a_file_with_no_extension() {
let path = std::env::temp_dir().join("fsmp-store-noext");
std::fs::write(&path, VALID).unwrap();
let err = load_definition(&path).unwrap_err().to_string();
assert!(
err.contains(".yaml"),
"should name accepted extensions: {err}"
);
}
}