1use super::private_storage::{create_private_directory_all, write_private_file_unsynced};
16use serde_json::{Map, Value};
17use std::path::{Path, PathBuf};
18use std::time::{Duration, SystemTime};
19
20pub const VERSION: u64 = 1;
22
23pub const WORK_DIRECTORY: &str = "cli-git/work";
25
26pub const PENDING: &str = "pending";
28pub const CLAIMED: &str = "claimed";
30pub const DONE: &str = "done";
32pub const FAILED: &str = "failed";
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum WorkKind {
46 Review,
48 Push,
50 Maintenance,
52}
53
54impl WorkKind {
56 pub fn name(self) -> &'static str {
58 return match self {
59 WorkKind::Review => "review",
60 WorkKind::Push => "push",
61 WorkKind::Maintenance => "maintenance",
62 };
63 }
64
65 pub fn from_name(name: &str) -> Option<WorkKind> {
67 return [WorkKind::Review, WorkKind::Push, WorkKind::Maintenance].into_iter().find(|kind| return kind.name() == name);
68 }
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct WorkItem {
82 pub kind: WorkKind,
84 pub repository: String,
86 pub git_dir: String,
88 pub branch_ref: String,
90 pub oid: String,
92 pub parent: String,
94 pub created: String,
96 pub tries: u32,
98}
99
100impl WorkItem {
102 pub fn id(&self) -> String {
104 return format!("{}-{}", self.kind.name(), self.oid);
105 }
106
107 pub fn encode(&self) -> String {
109 let mut fields: Map<String, Value> = Map::new();
110 fields.insert(String::from("v"), Value::from(VERSION));
111 fields.insert(String::from("tries"), Value::from(self.tries));
112 for (name, text) in [
113 ("kind", self.kind.name()),
114 ("id", self.id().as_str()),
115 ("repository", self.repository.as_str()),
116 ("gitDir", self.git_dir.as_str()),
117 ("ref", self.branch_ref.as_str()),
118 ("oid", self.oid.as_str()),
119 ("parent", self.parent.as_str()),
120 ("created", self.created.as_str()),
121 ] {
122 fields.insert(String::from(name), Value::from(text));
123 }
124 return format!("{}\n", Value::Object(fields));
125 }
126
127 pub fn decode(text: &str) -> Result<WorkItem, String> {
130 let value: Value = serde_json::from_str(text).map_err(|error| return format!("the record is not JSON: {error}"))?;
131 let field = |name: &str| -> Result<String, String> {
132 return value.get(name).and_then(Value::as_str).map(String::from).ok_or_else(|| return format!("the record has no text field {name}"));
133 };
134 if value.get("v").and_then(Value::as_u64) != Some(VERSION) {
135 return Err(String::from("the record has an unsupported version"));
136 }
137 let kind: WorkKind = WorkKind::from_name(field("kind")?.as_str()).ok_or_else(|| return String::from("the record has an unknown kind"))?;
138 let tries: u32 = value.get("tries").and_then(Value::as_u64).and_then(|count| return u32::try_from(count).ok()).ok_or_else(|| return String::from("the record has no tries"))?;
139 let item: WorkItem = WorkItem {
140 kind,
141 repository: field("repository")?,
142 git_dir: field("gitDir")?,
143 branch_ref: field("ref")?,
144 oid: field("oid")?,
145 parent: field("parent")?,
146 created: field("created")?,
147 tries,
148 };
149 if field("id")? != item.id() {
150 return Err(String::from("the record's id is not derived from its kind and object"));
151 }
152 return Ok(item);
153 }
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
166pub enum Enqueued {
167 Added,
169 Known,
171}
172
173pub fn state_directory(work_dir: &Path, state: &str) -> PathBuf {
175 return work_dir.join(state);
176}
177
178pub fn enqueue(work_dir: &Path, item: &WorkItem) -> Result<Enqueued, String> {
189 for state in [PENDING, CLAIMED, DONE, FAILED] {
190 create_private_directory_all(state_directory(work_dir, state).as_path()).map_err(|error| return format!("creating the {state} directory failed: {error}"))?;
191 }
192 let id: String = item.id();
193 for state in [CLAIMED, DONE, FAILED] {
194 if state_directory(work_dir, state).join(id.as_str()).exists() {
195 return Ok(Enqueued::Known);
196 }
197 }
198 return match write_private_file_unsynced(state_directory(work_dir, PENDING).join(id.as_str()).as_path(), item.encode().as_bytes()) {
199 Ok(()) => Ok(Enqueued::Added),
200 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(Enqueued::Known),
201 Err(error) => Err(format!("writing the work item {id} failed: {error}")),
202 };
203}
204
205#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct Claimed {
216 pub id: String,
218 pub item: WorkItem,
220}
221
222fn entries_oldest_first(work_dir: &Path, state: &str) -> Vec<(String, SystemTime)> {
224 let mut found: Vec<(String, SystemTime)> = Vec::new();
225 if let Ok(listing) = std::fs::read_dir(state_directory(work_dir, state)) {
226 for entry in listing.flatten() {
227 let modified: SystemTime = entry.metadata().and_then(|meta| return meta.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
228 found.push((entry.file_name().to_string_lossy().into_owned(), modified));
229 }
230 }
231 found.sort_by(|left, right| return left.1.cmp(&right.1).then_with(|| return left.0.cmp(&right.0)));
232 return found;
233}
234
235pub fn claim_next(work_dir: &Path, now: SystemTime) -> Result<Option<Claimed>, String> {
246 return claim_next_of(work_dir, now, &[WorkKind::Review, WorkKind::Push, WorkKind::Maintenance]);
247}
248
249pub fn claim_next_of(work_dir: &Path, now: SystemTime, kinds: &[WorkKind]) -> Result<Option<Claimed>, String> {
260 for (id, _) in entries_oldest_first(work_dir, PENDING) {
261 if !kinds.iter().any(|kind| return id.starts_with(format!("{}-", kind.name()).as_str())) {
262 continue;
263 }
264 let claimed_path: PathBuf = state_directory(work_dir, CLAIMED).join(id.as_str());
265 if std::fs::rename(state_directory(work_dir, PENDING).join(id.as_str()), claimed_path.as_path()).is_err() {
266 continue;
267 }
268 let stamped: std::io::Result<()> = std::fs::File::options().append(true).open(claimed_path.as_path()).and_then(|file| return file.set_modified(now));
269 stamped.map_err(|error| return format!("stamping the claim of {id} failed: {error}"))?;
270 let text: String = std::fs::read_to_string(claimed_path.as_path()).map_err(|error| return format!("reading the claim of {id} failed: {error}"))?;
271 match WorkItem::decode(text.as_str()) {
272 Ok(item) => return Ok(Some(Claimed { id, item })),
273 Err(_) => {
274 let _ = std::fs::rename(claimed_path.as_path(), state_directory(work_dir, FAILED).join(id.as_str()));
275 }
276 }
277 }
278 return Ok(None);
279}
280
281pub fn settle(work_dir: &Path, claimed: &Claimed, state: &str) -> Result<(), String> {
292 return std::fs::rename(state_directory(work_dir, CLAIMED).join(claimed.id.as_str()), state_directory(work_dir, state).join(claimed.id.as_str()))
293 .map_err(|error| return format!("moving {} to {state} failed: {error}", claimed.id));
294}
295
296pub fn requeue_expired(work_dir: &Path, now: SystemTime, lease: Duration, max_tries: u32) -> usize {
308 return requeue_where(work_dir, max_tries, &|started| { return now.duration_since(started).unwrap_or(Duration::ZERO) > lease; });
309}
310
311pub fn requeue_claimed(work_dir: &Path, max_tries: u32) -> usize {
323 return requeue_where(work_dir, max_tries, &|_started| { return true; });
324}
325
326fn requeue_where(work_dir: &Path, max_tries: u32, due: &dyn Fn(SystemTime) -> bool) -> usize {
328 let mut moved: usize = 0;
329 for (id, started) in entries_oldest_first(work_dir, CLAIMED) {
330 if !due(started) {
331 continue;
332 }
333 let from: PathBuf = state_directory(work_dir, CLAIMED).join(id.as_str());
334 let Ok(text) = std::fs::read_to_string(from.as_path()) else {
335 continue;
336 };
337 let target: &str = match WorkItem::decode(text.as_str()) {
338 Ok(mut item) => {
339 item.tries += 1;
340 let rewritten: PathBuf = from.with_extension("retry");
341 let state: &str = if item.tries >= max_tries { FAILED } else { PENDING };
342 if std::fs::write(rewritten.as_path(), item.encode()).is_err() || std::fs::rename(rewritten.as_path(), from.as_path()).is_err() {
343 continue;
344 }
345 state
346 }
347 Err(_) => FAILED,
348 };
349 if std::fs::rename(from.as_path(), state_directory(work_dir, target).join(id.as_str())).is_ok() {
350 moved += 1;
351 }
352 }
353 return moved;
354}
355
356pub fn oldest_pending_age(work_dir: &Path, now: SystemTime) -> Option<Duration> {
367 return entries_oldest_first(work_dir, PENDING).first().map(|(_, created)| return now.duration_since(*created).unwrap_or(Duration::ZERO));
368}
369
370#[cfg(test)]
372#[path = "work_item_tests.rs"]
373mod tests;