1use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use uuid::Uuid;
8
9use crate::constants::{CRON, KEYS, MAILBOX};
10use crate::cron::CronSchedule;
11use crate::error::{Error, Result};
12use crate::fs_text::{read_text, write_text, write_text_atomic};
13use crate::home::UnifierHome;
14use crate::paths::{cron_dir, key_path, mailbox_dir, message_path, parse_message_id};
15use crate::postbox::Message;
16use crate::scope::resolve_under_root;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub(crate) enum KeyState {
20 Present { value: String, dirty: bool },
21 Deleted { dirty: bool },
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25struct MsgState {
26 body: String,
27 dirty: bool,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31enum MessageKind {
32 Mailbox,
33 Cron,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub(crate) struct EventState {
38 pub(crate) body: String,
39 pub(crate) created_at: String,
40 pub(crate) expires_at: Option<String>,
42 pub(crate) dirty: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub(crate) enum StagingValue {
47 Present(String),
48 Deleted,
49}
50
51#[derive(Debug, Clone)]
53pub(crate) struct ActiveTick {
54 pub number: u64,
55 pub(crate) read_snapshot: BTreeMap<String, String>,
56 pub(crate) staging: BTreeMap<String, StagingValue>,
57 pub(crate) locks: BTreeSet<String>,
58}
59
60#[derive(Debug, Default)]
62pub struct HotStore {
63 pub(crate) keys: BTreeMap<String, KeyState>,
64 mailboxes: BTreeMap<String, BTreeMap<Uuid, MsgState>>,
65 cron: BTreeMap<String, BTreeMap<Uuid, MsgState>>,
66 pub(crate) events: BTreeMap<Uuid, EventState>,
67 removed_messages: BTreeSet<(MessageKind, String, Uuid)>,
68 pub(crate) committed_tick: u64,
69 pub(crate) active_tick: Option<ActiveTick>,
70 pub(crate) tick_queue: VecDeque<String>,
71}
72
73impl HotStore {
74 pub fn load(home: &UnifierHome) -> Result<Self> {
75 let mut store = Self::default();
76 store.load_keys(home)?;
77 store.mailboxes = load_message_buckets(home, MAILBOX)?;
78 store.cron = load_message_buckets(home, CRON)?;
79 store.events = crate::tick::load_events(home)?;
80 store.committed_tick = crate::tick::load_committed_tick(home)?;
81 Ok(store)
82 }
83
84 pub fn is_dirty(&self) -> bool {
85 self.keys.values().any(|k| match k {
86 KeyState::Present { dirty, .. } | KeyState::Deleted { dirty } => *dirty,
87 }) || self.has_dirty_messages(&self.mailboxes)
88 || self.has_dirty_messages(&self.cron)
89 || self.events.values().any(|e| e.dirty)
90 || !self.removed_messages.is_empty()
91 || self.active_tick.is_some()
92 }
93
94 pub fn put_key(&mut self, key: &str, value: &str) -> Result<()> {
95 validate_key(key)?;
96 if let Some(tick) = &self.active_tick {
97 if tick.locks.contains(key) {
98 return Err(Error::msg(format!(
99 "key locked during tick {}: {key}",
100 tick.number
101 )));
102 }
103 }
104 if let Some(tick) = &mut self.active_tick {
105 tick.staging
106 .insert(key.to_string(), StagingValue::Present(value.to_string()));
107 return Ok(());
108 }
109 self.keys.insert(
110 key.to_string(),
111 KeyState::Present {
112 value: value.to_string(),
113 dirty: true,
114 },
115 );
116 Ok(())
117 }
118
119 pub fn get_key(&self, key: &str) -> Result<Option<String>> {
120 validate_key(key)?;
121 if let Some(tick) = &self.active_tick {
122 if let Some(staged) = tick.staging.get(key) {
123 return Ok(match staged {
124 StagingValue::Present(v) => Some(v.clone()),
125 StagingValue::Deleted => None,
126 });
127 }
128 return Ok(tick.read_snapshot.get(key).cloned());
129 }
130 Ok(match self.keys.get(key) {
131 Some(KeyState::Present { value, .. }) => Some(value.clone()),
132 Some(KeyState::Deleted { .. }) | None => None,
133 })
134 }
135
136 pub fn delete_key(&mut self, key: &str) -> Result<bool> {
137 validate_key(key)?;
138 if let Some(tick) = &self.active_tick {
139 if tick.locks.contains(key) {
140 return Err(Error::msg(format!(
141 "key locked during tick {}: {key}",
142 tick.number
143 )));
144 }
145 }
146 if let Some(tick) = &mut self.active_tick {
147 let existed = tick.staging.contains_key(key) || tick.read_snapshot.contains_key(key);
148 if !existed {
149 return Ok(false);
150 }
151 tick.staging.insert(key.to_string(), StagingValue::Deleted);
152 return Ok(true);
153 }
154 let existed = matches!(self.keys.get(key), Some(KeyState::Present { .. }));
155 if !existed {
156 return Ok(false);
157 }
158 self.keys
159 .insert(key.to_string(), KeyState::Deleted { dirty: true });
160 Ok(true)
161 }
162
163 pub fn send(&mut self, recipient: &str, body: &str) -> Result<Uuid> {
164 self.send_from(crate::envelope::DEFAULT_SENDER, recipient, body)
165 }
166
167 pub fn send_from(&mut self, from: &str, recipient: &str, body: &str) -> Result<Uuid> {
168 validate_segment(from, "from")?;
169 validate_segment(recipient, "recipient")?;
170 let env = crate::envelope::Envelope::new(
171 from,
172 recipient,
173 crate::envelope::Envelope::parse_payload(body),
174 );
175 let id = env.id;
176 self.mailboxes
177 .entry(recipient.to_string())
178 .or_default()
179 .insert(
180 id,
181 MsgState {
182 body: env.to_json()?,
183 dirty: true,
184 },
185 );
186 Ok(id)
187 }
188
189 pub fn post_cron(&mut self, schedule: &str, body: &str) -> Result<Uuid> {
190 CronSchedule::parse(schedule)?;
191 let id = Uuid::new_v4();
192 self.cron.entry(schedule.to_string()).or_default().insert(
193 id,
194 MsgState {
195 body: body.to_string(),
196 dirty: true,
197 },
198 );
199 Ok(id)
200 }
201
202 pub fn poll_mailbox(&self, recipient: &str) -> Result<Vec<Message>> {
203 validate_segment(recipient, "recipient")?;
204 Ok(self.collect_messages(
205 MessageKind::Mailbox,
206 recipient,
207 self.mailboxes.get(recipient),
208 ))
209 }
210
211 pub fn poll_cron(&self) -> Result<Vec<Message>> {
212 let mut out = Vec::new();
213 for (schedule, msgs) in &self.cron {
214 let parsed = CronSchedule::parse(schedule)?;
215 if !parsed.matches_now() {
216 continue;
217 }
218 out.extend(self.collect_messages(MessageKind::Cron, schedule, Some(msgs)));
219 }
220 out.sort_by(|a, b| a.path.cmp(&b.path));
221 Ok(out)
222 }
223
224 pub fn list_dir(&self, home: &UnifierHome, subpath: &str) -> Result<Vec<Message>> {
225 let dir = resolve_under_root(home.path(), subpath)?;
226 let rel = dir
227 .strip_prefix(home.path())
228 .map_err(|_| Error::msg("path escapes store root"))?;
229 let parts: Vec<_> = rel.iter().collect();
230 match parts.as_slice() {
231 [p1, p2] if p1.to_string_lossy() == MAILBOX => self.poll_mailbox(&p2.to_string_lossy()),
232 [p1, p2] if p1.to_string_lossy() == CRON => {
233 let schedule = p2.to_string_lossy();
234 Ok(self.collect_messages(
235 MessageKind::Cron,
236 &schedule,
237 self.cron.get(schedule.as_ref()),
238 ))
239 }
240 _ => Ok(Vec::new()),
241 }
242 }
243
244 pub fn ack(&mut self, home: &UnifierHome, id_or_path: &str) -> Result<bool> {
245 if id_or_path.contains('/') || Path::new(id_or_path).is_absolute() {
246 let path = if Path::new(id_or_path).is_absolute() {
247 PathBuf::from(id_or_path)
248 } else {
249 resolve_under_root(home.path(), id_or_path)?
250 };
251 return self.ack_path(home, &path);
252 }
253 let id = Uuid::parse_str(id_or_path)
254 .map_err(|_| Error::msg(format!("invalid message id or path: {id_or_path}")))?;
255 self.ack_id(home, &id)
256 }
257
258 pub fn flush(&mut self, home: &UnifierHome) -> Result<()> {
259 if self.active_tick.is_some() {
260 return Err(Error::msg(
261 "cannot flush while a tick is active; run tick end first",
262 ));
263 }
264 home.ensure()?;
265 self.flush_keys(home)?;
266 self.flush_mailboxes(home)?;
267 self.flush_cron(home)?;
268 self.flush_events(home)?;
269 self.removed_messages.clear();
270 Ok(())
271 }
272
273 fn load_keys(&mut self, home: &UnifierHome) -> Result<()> {
274 let root = home.path().join(KEYS);
275 if !root.is_dir() {
276 return Ok(());
277 }
278 let mut prefix = Vec::new();
279 self.walk_key_files(&root, &mut prefix)?;
280 Ok(())
281 }
282
283 fn walk_key_files(&mut self, dir: &Path, prefix: &mut Vec<String>) -> Result<()> {
284 for entry in fs::read_dir(dir)? {
285 let entry = entry?;
286 let name = entry.file_name().to_string_lossy().into_owned();
287 if entry.file_type()?.is_dir() {
288 prefix.push(name);
289 self.walk_key_files(&entry.path(), prefix)?;
290 prefix.pop();
291 } else if entry.file_type()?.is_file() {
292 prefix.push(name);
293 let key = prefix.join("/");
294 prefix.pop();
295 let value = read_text(&entry.path())?;
296 self.keys.insert(
297 key,
298 KeyState::Present {
299 value,
300 dirty: false,
301 },
302 );
303 }
304 }
305 Ok(())
306 }
307
308 fn flush_keys(&mut self, home: &UnifierHome) -> Result<()> {
309 let mut to_remove = Vec::new();
310 for (key, state) in &mut self.keys {
311 match state {
312 KeyState::Present { value, dirty: true } => {
313 write_text_atomic(&key_path(home, key), value)?;
314 *state = KeyState::Present {
315 value: value.clone(),
316 dirty: false,
317 };
318 }
319 KeyState::Deleted { dirty: true } => {
320 let path = key_path(home, key);
321 if path.is_file() {
322 fs::remove_file(&path)?;
323 }
324 to_remove.push(key.clone());
325 }
326 _ => {}
327 }
328 }
329 for key in to_remove {
330 self.keys.remove(&key);
331 }
332 Ok(())
333 }
334
335 fn flush_mailboxes(&mut self, home: &UnifierHome) -> Result<()> {
336 flush_message_buckets(
337 home,
338 MessageKind::Mailbox,
339 &mut self.mailboxes,
340 &mut self.removed_messages,
341 mailbox_dir,
342 )
343 }
344
345 fn flush_cron(&mut self, home: &UnifierHome) -> Result<()> {
346 flush_message_buckets(
347 home,
348 MessageKind::Cron,
349 &mut self.cron,
350 &mut self.removed_messages,
351 cron_dir,
352 )
353 }
354
355 fn ack_path(&mut self, home: &UnifierHome, path: &Path) -> Result<bool> {
356 let rel = path.strip_prefix(home.path()).ok();
357 let Some(rel) = rel else {
358 return Ok(false);
359 };
360 let parts: Vec<_> = rel
361 .iter()
362 .map(|p| p.to_string_lossy().into_owned())
363 .collect();
364 if parts.len() != 3 {
365 return Ok(false);
366 }
367 let Some(id) = parse_message_id(&parts[2]) else {
368 return Ok(false);
369 };
370 let existed = match parts[0].as_str() {
371 MAILBOX => {
372 let had = self
373 .mailboxes
374 .get(&parts[1])
375 .is_some_and(|m| m.contains_key(&id))
376 || path.is_file();
377 if had {
378 self.removed_messages
379 .insert((MessageKind::Mailbox, parts[1].clone(), id));
380 if let Some(msgs) = self.mailboxes.get_mut(&parts[1]) {
381 msgs.remove(&id);
382 }
383 }
384 had
385 }
386 CRON => {
387 let had = self
388 .cron
389 .get(&parts[1])
390 .is_some_and(|m| m.contains_key(&id))
391 || path.is_file();
392 if had {
393 self.removed_messages
394 .insert((MessageKind::Cron, parts[1].clone(), id));
395 if let Some(msgs) = self.cron.get_mut(&parts[1]) {
396 msgs.remove(&id);
397 }
398 }
399 had
400 }
401 _ => false,
402 };
403 Ok(existed)
404 }
405
406 fn ack_id(&mut self, home: &UnifierHome, id: &Uuid) -> Result<bool> {
407 if let Some(recipient) = self
408 .mailboxes
409 .iter()
410 .find_map(|(name, msgs)| msgs.contains_key(id).then(|| name.clone()))
411 {
412 self.removed_messages
413 .insert((MessageKind::Mailbox, recipient.clone(), *id));
414 if let Some(m) = self.mailboxes.get_mut(&recipient) {
415 m.remove(id);
416 }
417 return Ok(true);
418 }
419 if let Some(schedule) = self
420 .cron
421 .iter()
422 .find_map(|(name, msgs)| msgs.contains_key(id).then(|| name.clone()))
423 {
424 self.removed_messages
425 .insert((MessageKind::Cron, schedule.clone(), *id));
426 if let Some(m) = self.cron.get_mut(&schedule) {
427 m.remove(id);
428 }
429 return Ok(true);
430 }
431 let filename = format!("{}.txt", id.hyphenated());
433 for sub in [MAILBOX, CRON] {
434 let base = home.path().join(sub);
435 if !base.is_dir() {
436 continue;
437 }
438 for entry in fs::read_dir(&base)? {
439 let entry = entry?;
440 if entry.file_type()?.is_dir() {
441 let candidate = entry.path().join(&filename);
442 if candidate.is_file() {
443 let bucket = entry.file_name().to_string_lossy().into_owned();
444 let kind = if sub == MAILBOX {
445 MessageKind::Mailbox
446 } else {
447 MessageKind::Cron
448 };
449 self.removed_messages.insert((kind, bucket, *id));
450 return Ok(true);
451 }
452 }
453 }
454 }
455 Ok(false)
456 }
457
458 fn collect_messages(
459 &self,
460 kind: MessageKind,
461 bucket: &str,
462 msgs: Option<&BTreeMap<Uuid, MsgState>>,
463 ) -> Vec<Message> {
464 let Some(msgs) = msgs else {
465 return Vec::new();
466 };
467 let base = match kind {
468 MessageKind::Mailbox => PathBuf::from(MAILBOX).join(bucket),
469 MessageKind::Cron => PathBuf::from(CRON).join(bucket),
470 };
471 let mut out = Vec::new();
472 for (id, msg) in msgs {
473 out.push(Message {
474 id: *id,
475 path: base.join(format!("{}.txt", id.hyphenated())),
476 body: msg.body.clone(),
477 });
478 }
479 out.sort_by_key(|m| m.id);
480 out
481 }
482
483 fn has_dirty_messages(&self, buckets: &BTreeMap<String, BTreeMap<Uuid, MsgState>>) -> bool {
484 buckets.values().any(|msgs| msgs.values().any(|m| m.dirty))
485 }
486}
487
488fn load_message_buckets(
489 home: &UnifierHome,
490 top: &str,
491) -> Result<BTreeMap<String, BTreeMap<Uuid, MsgState>>> {
492 let mut target = BTreeMap::new();
493 let base = home.path().join(top);
494 if !base.is_dir() {
495 return Ok(target);
496 }
497 for entry in fs::read_dir(&base)? {
498 let entry = entry?;
499 if !entry.file_type()?.is_dir() {
500 continue;
501 }
502 let bucket = entry.file_name().to_string_lossy().into_owned();
503 let mut msgs = BTreeMap::new();
504 for msg_entry in fs::read_dir(entry.path())? {
505 let msg_entry = msg_entry?;
506 if !msg_entry.file_type()?.is_file() {
507 continue;
508 }
509 let name = msg_entry.file_name().to_string_lossy().into_owned();
510 let Some(id) = parse_message_id(&name) else {
511 continue;
512 };
513 msgs.insert(
514 id,
515 MsgState {
516 body: read_text(&msg_entry.path())?,
517 dirty: false,
518 },
519 );
520 }
521 if !msgs.is_empty() {
522 target.insert(bucket, msgs);
523 }
524 }
525 Ok(target)
526}
527
528fn flush_message_buckets<F>(
529 home: &UnifierHome,
530 kind: MessageKind,
531 buckets: &mut BTreeMap<String, BTreeMap<Uuid, MsgState>>,
532 removed_messages: &mut BTreeSet<(MessageKind, String, Uuid)>,
533 dir_for: F,
534) -> Result<()>
535where
536 F: Fn(&UnifierHome, &str) -> PathBuf,
537{
538 for (bucket, msgs) in buckets.iter_mut() {
539 for (id, msg) in msgs.iter_mut() {
540 if msg.dirty {
541 write_text(&message_path(&dir_for(home, bucket), id), &msg.body)?;
542 msg.dirty = false;
543 }
544 }
545 }
546 let removed: Vec<_> = removed_messages
547 .iter()
548 .filter(|(k, _, _)| *k == kind)
549 .cloned()
550 .collect();
551 for (_, bucket, id) in removed {
552 let path = message_path(&dir_for(home, &bucket), &id);
553 if path.is_file() {
554 fs::remove_file(path)?;
555 }
556 if let Some(msgs) = buckets.get_mut(&bucket) {
557 msgs.remove(&id);
558 }
559 removed_messages.remove(&(kind, bucket.clone(), id));
560 }
561 buckets.retain(|_, msgs| !msgs.is_empty());
562 Ok(())
563}
564
565pub(crate) fn validate_key(key: &str) -> Result<()> {
566 if key.is_empty() {
567 return Err(Error::msg("key must not be empty"));
568 }
569 if key.contains("..") {
570 return Err(Error::msg("key must not contain '..'"));
571 }
572 Ok(())
573}
574
575pub(crate) fn validate_segment(segment: &str, label: &str) -> Result<()> {
576 if segment.is_empty() || segment.contains('/') || segment.contains("..") {
577 return Err(Error::msg(format!("invalid {label}: {segment}")));
578 }
579 Ok(())
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use crate::home::UnifierHome;
586 use tempfile::tempdir;
587
588 #[test]
589 fn put_get_without_flush_leaves_disk_clean() {
590 let tmp = tempdir().unwrap();
591 let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
592 let mut store = HotStore::load(&home).unwrap();
593
594 store.put_key("app/theme", "dark").unwrap();
595 assert_eq!(store.get_key("app/theme").unwrap(), Some("dark".into()));
596 assert!(store.is_dirty());
597 assert!(!home.path().join("keys/app/theme").exists());
598
599 store.flush(&home).unwrap();
600 assert!(!store.is_dirty());
601 assert!(home.path().join("keys/app/theme").is_file());
602 }
603
604 #[test]
605 fn send_without_flush_then_flush_persists() {
606 let tmp = tempdir().unwrap();
607 let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
608 let mut store = HotStore::load(&home).unwrap();
609
610 store.send("worker", "hello").unwrap();
611 assert!(store.poll_mailbox("worker").unwrap().len() == 1);
612 assert!(fs::read_dir(home.path().join("mailbox")).is_err());
613
614 store.flush(&home).unwrap();
615 assert!(home.path().join("mailbox/worker").is_dir());
616 }
617
618 #[test]
619 fn load_existing_keys_from_disk() {
620 let tmp = tempdir().unwrap();
621 let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
622 put_key_fs(&home, "k", "v").unwrap();
623
624 let store = HotStore::load(&home).unwrap();
625 assert_eq!(store.get_key("k").unwrap(), Some("v".into()));
626 }
627
628 fn put_key_fs(home: &UnifierHome, key: &str, value: &str) -> Result<()> {
629 write_text_atomic(&key_path(home, key), value)
630 }
631}