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!("key locked during tick {}: {key}", tick.number)));
96 }
97 }
98 if let Some(tick) = &mut self.active_tick {
99 tick.staging.insert(
100 key.to_string(),
101 StagingValue::Present(value.to_string()),
102 );
103 return Ok(());
104 }
105 self.keys.insert(
106 key.to_string(),
107 KeyState::Present {
108 value: value.to_string(),
109 dirty: true,
110 },
111 );
112 Ok(())
113 }
114
115 pub fn get_key(&self, key: &str) -> Result<Option<String>> {
116 validate_key(key)?;
117 if let Some(tick) = &self.active_tick {
118 if let Some(staged) = tick.staging.get(key) {
119 return Ok(match staged {
120 StagingValue::Present(v) => Some(v.clone()),
121 StagingValue::Deleted => None,
122 });
123 }
124 return Ok(tick.read_snapshot.get(key).cloned());
125 }
126 Ok(match self.keys.get(key) {
127 Some(KeyState::Present { value, .. }) => Some(value.clone()),
128 Some(KeyState::Deleted { .. }) | None => None,
129 })
130 }
131
132 pub fn delete_key(&mut self, key: &str) -> Result<bool> {
133 validate_key(key)?;
134 if let Some(tick) = &self.active_tick {
135 if tick.locks.contains(key) {
136 return Err(Error::msg(format!("key locked during tick {}: {key}", tick.number)));
137 }
138 }
139 if let Some(tick) = &mut self.active_tick {
140 let existed = tick.staging.contains_key(key)
141 || tick.read_snapshot.contains_key(key);
142 if !existed {
143 return Ok(false);
144 }
145 tick.staging.insert(key.to_string(), StagingValue::Deleted);
146 return Ok(true);
147 }
148 let existed = matches!(self.keys.get(key), Some(KeyState::Present { .. }));
149 if !existed {
150 return Ok(false);
151 }
152 self.keys
153 .insert(key.to_string(), KeyState::Deleted { dirty: true });
154 Ok(true)
155 }
156
157 pub fn send(&mut self, recipient: &str, body: &str) -> Result<Uuid> {
158 self.send_from(crate::envelope::DEFAULT_SENDER, recipient, body)
159 }
160
161 pub fn send_from(&mut self, from: &str, recipient: &str, body: &str) -> Result<Uuid> {
162 validate_segment(from, "from")?;
163 validate_segment(recipient, "recipient")?;
164 let env = crate::envelope::Envelope::new(
165 from,
166 recipient,
167 crate::envelope::Envelope::parse_payload(body),
168 );
169 let id = env.id;
170 self.mailboxes
171 .entry(recipient.to_string())
172 .or_default()
173 .insert(
174 id,
175 MsgState {
176 body: env.to_json()?,
177 dirty: true,
178 },
179 );
180 Ok(id)
181 }
182
183 pub fn post_cron(&mut self, schedule: &str, body: &str) -> Result<Uuid> {
184 CronSchedule::parse(schedule)?;
185 let id = Uuid::new_v4();
186 self.cron
187 .entry(schedule.to_string())
188 .or_default()
189 .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 => {
229 self.poll_mailbox(&p2.to_string_lossy())
230 }
231 [p1, p2] if p1.to_string_lossy() == CRON => {
232 let schedule = p2.to_string_lossy();
233 Ok(self.collect_messages(
234 MessageKind::Cron,
235 &schedule,
236 self.cron.get(schedule.as_ref()),
237 ))
238 }
239 _ => Ok(Vec::new()),
240 }
241 }
242
243 pub fn ack(&mut self, home: &UnifierHome, id_or_path: &str) -> Result<bool> {
244 if id_or_path.contains('/') || Path::new(id_or_path).is_absolute() {
245 let path = if Path::new(id_or_path).is_absolute() {
246 PathBuf::from(id_or_path)
247 } else {
248 resolve_under_root(home.path(), id_or_path)?
249 };
250 return self.ack_path(home, &path);
251 }
252 let id = Uuid::parse_str(id_or_path)
253 .map_err(|_| Error::msg(format!("invalid message id or path: {id_or_path}")))?;
254 self.ack_id(home, &id)
255 }
256
257 pub fn flush(&mut self, home: &UnifierHome) -> Result<()> {
258 if self.active_tick.is_some() {
259 return Err(Error::msg("cannot flush while a tick is active; run tick end first"));
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.iter().map(|p| p.to_string_lossy().into_owned()).collect();
358 if parts.len() != 3 {
359 return Ok(false);
360 }
361 let Some(id) = parse_message_id(&parts[2]) else {
362 return Ok(false);
363 };
364 let existed = match parts[0].as_str() {
365 MAILBOX => {
366 let had = self
367 .mailboxes
368 .get(&parts[1])
369 .is_some_and(|m| m.contains_key(&id))
370 || path.is_file();
371 if had {
372 self.removed_messages
373 .insert((MessageKind::Mailbox, parts[1].clone(), id));
374 if let Some(msgs) = self.mailboxes.get_mut(&parts[1]) {
375 msgs.remove(&id);
376 }
377 }
378 had
379 }
380 CRON => {
381 let had = self
382 .cron
383 .get(&parts[1])
384 .is_some_and(|m| m.contains_key(&id))
385 || path.is_file();
386 if had {
387 self.removed_messages
388 .insert((MessageKind::Cron, parts[1].clone(), id));
389 if let Some(msgs) = self.cron.get_mut(&parts[1]) {
390 msgs.remove(&id);
391 }
392 }
393 had
394 }
395 _ => false,
396 };
397 Ok(existed)
398 }
399
400 fn ack_id(&mut self, home: &UnifierHome, id: &Uuid) -> Result<bool> {
401 if let Some(recipient) = self
402 .mailboxes
403 .iter()
404 .find_map(|(name, msgs)| msgs.contains_key(id).then(|| name.clone()))
405 {
406 self.removed_messages
407 .insert((MessageKind::Mailbox, recipient.clone(), *id));
408 if let Some(m) = self.mailboxes.get_mut(&recipient) {
409 m.remove(id);
410 }
411 return Ok(true);
412 }
413 if let Some(schedule) = self
414 .cron
415 .iter()
416 .find_map(|(name, msgs)| msgs.contains_key(id).then(|| name.clone()))
417 {
418 self.removed_messages
419 .insert((MessageKind::Cron, schedule.clone(), *id));
420 if let Some(m) = self.cron.get_mut(&schedule) {
421 m.remove(id);
422 }
423 return Ok(true);
424 }
425 let filename = format!("{}.txt", id.hyphenated());
427 for sub in [MAILBOX, CRON] {
428 let base = home.path().join(sub);
429 if !base.is_dir() {
430 continue;
431 }
432 for entry in fs::read_dir(&base)? {
433 let entry = entry?;
434 if entry.file_type()?.is_dir() {
435 let candidate = entry.path().join(&filename);
436 if candidate.is_file() {
437 let bucket = entry.file_name().to_string_lossy().into_owned();
438 let kind = if sub == MAILBOX {
439 MessageKind::Mailbox
440 } else {
441 MessageKind::Cron
442 };
443 self.removed_messages
444 .insert((kind, bucket, *id));
445 return Ok(true);
446 }
447 }
448 }
449 }
450 Ok(false)
451 }
452
453 fn collect_messages(
454 &self,
455 kind: MessageKind,
456 bucket: &str,
457 msgs: Option<&BTreeMap<Uuid, MsgState>>,
458 ) -> Vec<Message> {
459 let Some(msgs) = msgs else {
460 return Vec::new();
461 };
462 let base = match kind {
463 MessageKind::Mailbox => PathBuf::from(MAILBOX).join(bucket),
464 MessageKind::Cron => PathBuf::from(CRON).join(bucket),
465 };
466 let mut out = Vec::new();
467 for (id, msg) in msgs {
468 out.push(Message {
469 id: *id,
470 path: base.join(format!("{}.txt", id.hyphenated())),
471 body: msg.body.clone(),
472 });
473 }
474 out.sort_by_key(|m| m.id);
475 out
476 }
477
478 fn has_dirty_messages(&self, buckets: &BTreeMap<String, BTreeMap<Uuid, MsgState>>) -> bool {
479 buckets
480 .values()
481 .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}