#[cfg(feature = "alloc")]
use alloc::{collections::BTreeMap, string::String, vec::Vec};
#[cfg(feature = "std")]
mod atomic;
mod canonical;
mod lane;
#[cfg(test)]
mod tests {
use super::RunState;
fn minimal_state() -> RunState {
serde_json::from_value(serde_json::json!({"run": "v1"})).expect("minimal doc parses")
}
#[cfg(feature = "std")]
mod atomic_io {
use super::minimal_state;
use crate::run::atomic::write_and_rename;
fn unique_temp_dir(label: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock is after the epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("shepherd-core-run-{label}-{nanos:x}"));
std::fs::create_dir_all(&dir).expect("create temp test dir");
dir
}
#[test]
fn store_then_load_round_trips_with_exact_bytes() {
let dir = unique_temp_dir("roundtrip");
let path = dir.join("run.json");
let mut state = minimal_state();
state.branch = "v9.0.0-dev.0".into();
state.updated_at = 1_786_621_458;
state.store(&path).expect("store succeeds");
let on_disk = std::fs::read_to_string(&path).expect("file exists");
let mut expected = state.to_canonical_json();
expected.push('\n');
assert_eq!(on_disk, expected);
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.expect("dir readable")
.filter_map(Result::ok)
.filter(|entry| entry.file_name() != "run.json")
.collect();
assert!(
leftovers.is_empty(),
"no tempfile should survive a successful store: {leftovers:?}"
);
let loaded = super::RunState::load(&path).expect("load succeeds");
assert_eq!(loaded, state);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn store_overwrites_an_existing_file_cleanly() {
let dir = unique_temp_dir("overwrite");
let path = dir.join("run.json");
let mut first = minimal_state();
first.status = "planted".into();
first.store(&path).expect("first store succeeds");
let mut second = minimal_state();
second.status = "closed".into();
second.store(&path).expect("second store succeeds");
let loaded = super::RunState::load(&path).expect("load succeeds");
assert_eq!(loaded.status, "closed");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_file_errors() {
let dir = unique_temp_dir("missing");
let path = dir.join("does-not-exist.json");
assert!(super::RunState::load(&path).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn store_recovers_from_a_temp_name_collision() {
let dir = unique_temp_dir("collision");
let target = dir.join("run.json");
let colliding = dir.join(".run.json-collision-1.tmp");
std::fs::write(&colliding, b"stale bytes from another writer's tempfile\n")
.expect("seed the colliding candidate");
let free = dir.join(".run.json-collision-2.tmp");
let mut candidates = vec![colliding.clone(), free.clone()].into_iter();
let contents = "{\"run\":\"collision-probe\"}";
write_and_rename(&target, contents, || {
candidates
.next()
.expect("only two candidates are needed to prove the retry")
})
.expect("write_and_rename must retry past the collision and still succeed");
let on_disk = std::fs::read_to_string(&target).expect("target exists");
assert_eq!(on_disk, format!("{contents}\n"));
let untouched =
std::fs::read_to_string(&colliding).expect("the colliding file must still exist");
assert_eq!(untouched, "stale bytes from another writer's tempfile\n");
assert!(
!free.exists(),
"the successful tempfile must be renamed away, not left behind"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
}
pub use self::lane::LaneState;
#[cfg(feature = "std")]
use crate::error::Result;
fn default_schema_version() -> u32 {
1
}
fn default_kind() -> String {
String::from("sprint")
}
fn default_status() -> String {
String::from("planted")
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct RunState {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
pub run: String,
#[serde(default = "default_kind")]
pub kind: String,
#[serde(default)]
pub branch: String,
#[serde(default)]
pub base: String,
#[serde(default)]
pub seed: String,
#[serde(default)]
pub plan: String,
#[serde(default = "default_status")]
pub status: String,
#[serde(default)]
pub lanes: Vec<LaneState>,
#[serde(default)]
pub updated_at: i64,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl RunState {
#[must_use]
pub fn to_canonical_json(&self) -> String {
canonical::to_canonical_string(self)
.expect("RunState never carries a NaN/Infinity float: see this method's doc comment")
}
#[cfg(feature = "std")]
pub fn load(path: &std::path::Path) -> Result<Self> {
let bytes = std::fs::read(path)
.map_err(|error| crate::Error::unknown(format!("read {}: {error}", path.display())))?;
serde_json::from_slice(&bytes)
.map_err(|error| crate::Error::Serialization(format!("{}: {error}", path.display())))
}
#[cfg(feature = "std")]
pub fn store(&self, path: &std::path::Path) -> Result<()> {
self::atomic::atomic_write(path, &self.to_canonical_json())
}
}