1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
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;
}
}
|