1use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result};
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
31pub struct MachineState {
32 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub provider_id: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub public_ipv4: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub hostkey_fingerprint: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub last_adopted_at: Option<String>,
48}
49
50impl MachineState {
51 pub fn path(workspace_root: &Path, name: &str) -> PathBuf {
53 workspace_root
54 .join(".yah")
55 .join("infra")
56 .join("state")
57 .join("machines")
58 .join(format!("{name}.json"))
59 }
60
61 pub fn load(workspace_root: &Path, name: &str) -> Result<Self> {
65 let p = Self::path(workspace_root, name);
66 match std::fs::read(&p) {
67 Ok(bytes) => {
68 serde_json::from_slice(&bytes).with_context(|| format!("parsing {}", p.display()))
69 }
70 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
71 Err(e) => Err(e).with_context(|| format!("reading {}", p.display())),
72 }
73 }
74
75 pub fn save(&self, workspace_root: &Path, name: &str) -> Result<()> {
77 let p = Self::path(workspace_root, name);
78 if let Some(parent) = p.parent() {
79 std::fs::create_dir_all(parent)
80 .with_context(|| format!("creating {}", parent.display()))?;
81 }
82 let json = serde_json::to_vec_pretty(self)
83 .with_context(|| format!("serializing state for {name}"))?;
84 std::fs::write(&p, json).with_context(|| format!("writing {}", p.display()))
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use tempfile::TempDir;
92
93 #[test]
94 fn load_missing_returns_default() {
95 let tmp = TempDir::new().unwrap();
96 let s = MachineState::load(tmp.path(), "nonesuch").unwrap();
97 assert_eq!(s, MachineState::default());
98 }
99
100 #[test]
101 fn round_trip_through_disk() {
102 let tmp = TempDir::new().unwrap();
103 let s = MachineState {
104 provider_id: Some("134855726".into()),
105 public_ipv4: Some("5.78.210.121".into()),
106 hostkey_fingerprint: Some("SHA256:abc".into()),
107 last_adopted_at: Some("2026-06-09T02:30:00Z".into()),
108 };
109 s.save(tmp.path(), "us-west-001").unwrap();
110 let loaded = MachineState::load(tmp.path(), "us-west-001").unwrap();
111 assert_eq!(loaded, s);
112 }
113
114 #[test]
115 fn partial_state_skips_none_fields_in_json() {
116 let tmp = TempDir::new().unwrap();
117 let s = MachineState {
118 provider_id: Some("9".into()),
119 ..Default::default()
120 };
121 s.save(tmp.path(), "m").unwrap();
122 let bytes = std::fs::read(MachineState::path(tmp.path(), "m")).unwrap();
123 let json = String::from_utf8(bytes).unwrap();
124 assert!(json.contains("provider_id"));
125 assert!(!json.contains("public_ipv4"));
126 assert!(!json.contains("hostkey_fingerprint"));
127 }
128}