summaryrefslogtreecommitdiff
path: root/src/state.rs
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-02-15 21:27:00 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-02-15 21:27:00 +0100
commitce0dbf6b249956700c6a1705bf4ad85a09d53e8c (patch)
treed7c3236807cfbf75d7f3a355eb5df5a5e2cc4ad7 /src/state.rs
parent064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (diff)
feat: witryna 0.2.0HEADv0.2.0main
Switch, cleanup, and status CLI commands. Persistent build state via state.json. Post-deploy hooks on success and failure with WITRYNA_BUILD_STATUS. Dependency diet (axum→tiny_http, clap→argh, tracing→log). Drop built-in rate limiting. Nix flake with NixOS module. Arch Linux PKGBUILD. Centralized version management. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src/state.rs')
-rw-r--r--src/state.rs311
1 files changed, 311 insertions, 0 deletions
diff --git a/src/state.rs b/src/state.rs
new file mode 100644
index 0000000..be4e981
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,311 @@
+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<BuildEntry>,
+}
+
+/// 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;
+ }
+}