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