use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MachineState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_ipv4: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostkey_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_adopted_at: Option<String>,
}
impl MachineState {
pub fn path(workspace_root: &Path, name: &str) -> PathBuf {
workspace_root
.join(".yah")
.join("infra")
.join("state")
.join("machines")
.join(format!("{name}.json"))
}
pub fn load(workspace_root: &Path, name: &str) -> Result<Self> {
let p = Self::path(workspace_root, name);
match std::fs::read(&p) {
Ok(bytes) => {
serde_json::from_slice(&bytes).with_context(|| format!("parsing {}", p.display()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", p.display())),
}
}
pub fn save(&self, workspace_root: &Path, name: &str) -> Result<()> {
let p = Self::path(workspace_root, name);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let json = serde_json::to_vec_pretty(self)
.with_context(|| format!("serializing state for {name}"))?;
std::fs::write(&p, json).with_context(|| format!("writing {}", p.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn load_missing_returns_default() {
let tmp = TempDir::new().unwrap();
let s = MachineState::load(tmp.path(), "nonesuch").unwrap();
assert_eq!(s, MachineState::default());
}
#[test]
fn round_trip_through_disk() {
let tmp = TempDir::new().unwrap();
let s = MachineState {
provider_id: Some("134855726".into()),
public_ipv4: Some("5.78.210.121".into()),
hostkey_fingerprint: Some("SHA256:abc".into()),
last_adopted_at: Some("2026-06-09T02:30:00Z".into()),
};
s.save(tmp.path(), "us-west-001").unwrap();
let loaded = MachineState::load(tmp.path(), "us-west-001").unwrap();
assert_eq!(loaded, s);
}
#[test]
fn partial_state_skips_none_fields_in_json() {
let tmp = TempDir::new().unwrap();
let s = MachineState {
provider_id: Some("9".into()),
..Default::default()
};
s.save(tmp.path(), "m").unwrap();
let bytes = std::fs::read(MachineState::path(tmp.path(), "m")).unwrap();
let json = String::from_utf8(bytes).unwrap();
assert!(json.contains("provider_id"));
assert!(!json.contains("public_ipv4"));
assert!(!json.contains("hostkey_fingerprint"));
}
}