use anyhow::Result; use log::warn; use std::path::Path; /// A single build record within the site state. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BuildEntry { /// Build phase: "building", "success", "failed", "hook failed". pub status: String, /// Build timestamp (YYYYMMDD-HHMMSS-microseconds). pub timestamp: String, /// ISO 8601 UTC when the build started (for elapsed time calculation). pub started_at: String, /// Short git commit hash, or empty string if unknown. pub git_commit: String, /// Human-readable duration ("45s", "2m 30s"), empty while building. pub duration: String, /// Path to the build log file. pub log: String, } /// Persistent per-site build state, written to `{base_dir}/builds/{site}/state.json`. /// /// Contains the full build history and the currently active build timestamp. /// The CLI `status` command reads only this file — no log parsing needed. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct SiteState { /// Timestamp of the currently active build (empty if none). pub current: String, /// All builds, newest first. pub builds: Vec, } /// Atomically write site state to `{base_dir}/builds/{site_name}/state.json`. /// /// Uses temp-file + rename for atomic writes. Creates parent directories /// if they don't exist. Errors are non-fatal — callers should log and continue. /// /// # Errors /// /// Returns an error if directory creation, JSON serialization, or the atomic write/rename fails. pub async fn save_state(base_dir: &Path, site_name: &str, state: &SiteState) -> Result<()> { let builds_dir = base_dir.join("builds").join(site_name); tokio::fs::create_dir_all(&builds_dir).await?; let state_path = builds_dir.join("state.json"); let tmp_path = builds_dir.join("state.json.tmp"); let json = serde_json::to_string_pretty(state)?; tokio::fs::write(&tmp_path, json.as_bytes()).await?; tokio::fs::rename(&tmp_path, &state_path).await?; Ok(()) } /// Load site state from `{base_dir}/builds/{site_name}/state.json`. /// /// Returns the default empty state if the file is missing or cannot be parsed. pub async fn load_state(base_dir: &Path, site_name: &str) -> SiteState { let state_path = base_dir.join("builds").join(site_name).join("state.json"); let Ok(content) = tokio::fs::read_to_string(&state_path).await else { return SiteState::default(); }; match serde_json::from_str(&content) { Ok(state) => state, Err(e) => { warn!( "[{site_name}] malformed state.json: {e} (path={})", state_path.display() ); SiteState::default() } } } /// Add a new build entry to the front of the builds list. Best-effort. pub async fn push_build(base_dir: &Path, site_name: &str, entry: BuildEntry) { let mut state = load_state(base_dir, site_name).await; state.builds.insert(0, entry); if let Err(e) = save_state(base_dir, site_name, &state).await { warn!("[{site_name}] failed to write state after push_build: {e}"); } } /// Update the most recent build entry in-place. Best-effort. /// /// Does nothing if the builds list is empty. pub async fn update_latest_build( base_dir: &Path, site_name: &str, updater: impl FnOnce(&mut BuildEntry), ) { let mut state = load_state(base_dir, site_name).await; if let Some(entry) = state.builds.first_mut() { updater(entry); if let Err(e) = save_state(base_dir, site_name, &state).await { warn!("[{site_name}] failed to write state after update_latest_build: {e}"); } } } /// Set the currently active build timestamp. Best-effort. pub async fn set_current(base_dir: &Path, site_name: &str, timestamp: &str) { let mut state = load_state(base_dir, site_name).await; state.current = timestamp.to_owned(); if let Err(e) = save_state(base_dir, site_name, &state).await { warn!("[{site_name}] failed to write state after set_current: {e}"); } } /// Remove build entries whose timestamps match any in `timestamps`. Best-effort. pub async fn remove_builds(base_dir: &Path, site_name: &str, timestamps: &[String]) { if timestamps.is_empty() { return; } let mut state = load_state(base_dir, site_name).await; let before = state.builds.len(); state.builds.retain(|b| !timestamps.contains(&b.timestamp)); if state.builds.len() == before { return; // nothing changed } if let Err(e) = save_state(base_dir, site_name, &state).await { warn!("[{site_name}] failed to write state after remove_builds: {e}"); } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::test_support::{cleanup, temp_dir}; fn test_entry() -> BuildEntry { BuildEntry { status: "building".to_owned(), timestamp: "20260210-120000-000000".to_owned(), started_at: "2026-02-10T12:00:00Z".to_owned(), git_commit: "abc123d".to_owned(), duration: String::new(), log: "/var/log/witryna/my-site/20260210-120000-000000.log".to_owned(), } } fn test_state() -> SiteState { SiteState { current: String::new(), builds: vec![test_entry()], } } #[tokio::test] async fn save_and_load_roundtrip() { let base_dir = temp_dir("state-test").await; let state = test_state(); save_state(&base_dir, "my-site", &state).await.unwrap(); let loaded = load_state(&base_dir, "my-site").await; assert_eq!(loaded.builds.len(), 1); let b = &loaded.builds[0]; assert_eq!(b.status, "building"); assert_eq!(b.timestamp, "20260210-120000-000000"); assert_eq!(b.started_at, "2026-02-10T12:00:00Z"); assert_eq!(b.git_commit, "abc123d"); assert_eq!(b.duration, ""); assert!(b.log.contains("20260210-120000-000000.log")); assert_eq!(loaded.current, ""); cleanup(&base_dir).await; } #[tokio::test] async fn load_state_missing_file_returns_default() { let base_dir = temp_dir("state-test").await; let loaded = load_state(&base_dir, "nonexistent").await; assert!(loaded.builds.is_empty()); assert_eq!(loaded.current, ""); cleanup(&base_dir).await; } #[tokio::test] async fn load_state_malformed_json_returns_default() { let base_dir = temp_dir("state-test").await; let state_dir = base_dir.join("builds").join("bad-site"); tokio::fs::create_dir_all(&state_dir).await.unwrap(); tokio::fs::write(state_dir.join("state.json"), "not valid json{{{") .await .unwrap(); let loaded = load_state(&base_dir, "bad-site").await; assert!(loaded.builds.is_empty()); cleanup(&base_dir).await; } #[tokio::test] async fn save_state_atomic_no_tmp_left() { let base_dir = temp_dir("state-test").await; let state = test_state(); save_state(&base_dir, "my-site", &state).await.unwrap(); let tmp_path = base_dir .join("builds") .join("my-site") .join("state.json.tmp"); assert!(!tmp_path.exists(), "temp file should not remain"); cleanup(&base_dir).await; } #[tokio::test] async fn push_build_prepends() { let base_dir = temp_dir("state-test").await; let entry1 = test_entry(); push_build(&base_dir, "my-site", entry1).await; let mut entry2 = test_entry(); entry2.timestamp = "20260210-130000-000000".to_owned(); push_build(&base_dir, "my-site", entry2).await; let loaded = load_state(&base_dir, "my-site").await; assert_eq!(loaded.builds.len(), 2); assert_eq!(loaded.builds[0].timestamp, "20260210-130000-000000"); assert_eq!(loaded.builds[1].timestamp, "20260210-120000-000000"); cleanup(&base_dir).await; } #[tokio::test] async fn update_latest_build_modifies_first() { let base_dir = temp_dir("state-test").await; push_build(&base_dir, "my-site", test_entry()).await; update_latest_build(&base_dir, "my-site", |e| { e.status = "success".to_owned(); e.duration = "30s".to_owned(); }) .await; let loaded = load_state(&base_dir, "my-site").await; assert_eq!(loaded.builds[0].status, "success"); assert_eq!(loaded.builds[0].duration, "30s"); cleanup(&base_dir).await; } #[tokio::test] async fn set_current_updates_field() { let base_dir = temp_dir("state-test").await; push_build(&base_dir, "my-site", test_entry()).await; set_current(&base_dir, "my-site", "20260210-120000-000000").await; let loaded = load_state(&base_dir, "my-site").await; assert_eq!(loaded.current, "20260210-120000-000000"); cleanup(&base_dir).await; } #[tokio::test] async fn remove_builds_prunes_entries() { let base_dir = temp_dir("state-test").await; let mut e1 = test_entry(); e1.timestamp = "20260210-100000-000000".to_owned(); let mut e2 = test_entry(); e2.timestamp = "20260210-110000-000000".to_owned(); let mut e3 = test_entry(); e3.timestamp = "20260210-120000-000000".to_owned(); push_build(&base_dir, "my-site", e3).await; push_build(&base_dir, "my-site", e2).await; push_build(&base_dir, "my-site", e1).await; remove_builds( &base_dir, "my-site", &[ "20260210-100000-000000".to_owned(), "20260210-120000-000000".to_owned(), ], ) .await; let loaded = load_state(&base_dir, "my-site").await; assert_eq!(loaded.builds.len(), 1); assert_eq!(loaded.builds[0].timestamp, "20260210-110000-000000"); cleanup(&base_dir).await; } #[tokio::test] async fn remove_builds_empty_list_is_noop() { let base_dir = temp_dir("state-test").await; push_build(&base_dir, "my-site", test_entry()).await; remove_builds(&base_dir, "my-site", &[]).await; let loaded = load_state(&base_dir, "my-site").await; assert_eq!(loaded.builds.len(), 1); cleanup(&base_dir).await; } }