qualia_client_core/wellfair/
journal.rs1use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8use wellfare_core::conditions::journal_kind_for_record_id;
9use wellfare_core::record::RecordEnvelope;
10
11pub const JOURNAL_FILE: &str = "wellfair/journal.jsonl";
12pub const MAX_LIST: usize = 256;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct JournalEntry {
16 pub id: String,
17 pub kind: String,
18 pub asserted_time_unix: u32,
19 pub evidence_type: String,
20 pub sensitivity: String,
21 pub blob_hash: Option<String>,
22 pub source: String,
23 pub committed_unix: u32,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub summary: Option<String>,
27}
28
29impl JournalEntry {
30 pub fn from_envelope(
31 envelope: &RecordEnvelope,
32 source: &str,
33 committed_unix: u32,
34 summary: Option<String>,
35 ) -> Self {
36 let kind = infer_kind(&envelope.id);
37 Self {
38 id: envelope.id.clone(),
39 kind,
40 asserted_time_unix: envelope.asserted_time_unix,
41 evidence_type: format!("{:?}", envelope.evidence_type),
42 sensitivity: format!("{:?}", envelope.sensitivity),
43 blob_hash: envelope.blob_hash.clone(),
44 source: source.to_string(),
45 committed_unix,
46 summary,
47 }
48 }
49}
50
51fn infer_kind(record_id: &str) -> String {
52 journal_kind_for_record_id(record_id).to_string()
53}
54
55pub struct WellfairJournal {
56 path: PathBuf,
57}
58
59impl WellfairJournal {
60 pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
61 let path = storage_root.as_ref().join(JOURNAL_FILE);
62 if let Some(parent) = path.parent() {
63 fs::create_dir_all(parent)?;
64 }
65 if !path.exists() {
66 OpenOptions::new().create(true).write(true).open(&path)?;
67 }
68 Ok(Self { path })
69 }
70
71 pub fn append(&self, entry: &JournalEntry) -> std::io::Result<()> {
72 let line =
73 serde_json::to_string(entry).map_err(|e| std::io::Error::other(e.to_string()))?;
74 let mut file = OpenOptions::new().append(true).open(&self.path)?;
75 writeln!(file, "{line}")?;
76 file.sync_all()?;
77 Ok(())
78 }
79
80 pub fn list_recent(&self, limit: usize) -> std::io::Result<Vec<JournalEntry>> {
81 let file = fs::File::open(&self.path)?;
82 let reader = BufReader::new(file);
83 let mut entries = Vec::new();
84 for line in reader.lines() {
85 let line = line?;
86 if line.trim().is_empty() {
87 continue;
88 }
89 if let Ok(entry) = serde_json::from_str::<JournalEntry>(&line) {
90 entries.push(entry);
91 }
92 }
93 let keep = limit.min(MAX_LIST);
94 if entries.len() > keep {
95 entries.drain(0..entries.len() - keep);
96 }
97 entries.reverse();
98 Ok(entries)
99 }
100
101 pub fn count(&self) -> std::io::Result<usize> {
102 let file = fs::File::open(&self.path)?;
103 Ok(BufReader::new(file)
104 .lines()
105 .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false))
106 .count())
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use wellfare_core::record::{EpistemicStatus, EvidenceType, RecordEnvelope, SensitivityClass};
114
115 #[test]
116 fn journal_round_trip() {
117 let dir = tempfile::tempdir().unwrap();
118 let journal = WellfairJournal::open(dir.path()).unwrap();
119 let envelope = RecordEnvelope {
120 id: "urn:wellfair:weight:abc".into(),
121 owner_did: "did:wf:owner".into(),
122 author_did: "did:wf:owner".into(),
123 proxy_did: None,
124 epistemic_status: EpistemicStatus::Asserted,
125 evidence_type: EvidenceType::DeviceMeasured,
126 sensitivity: SensitivityClass::Restricted,
127 asserted_time_unix: 1_700_000_000,
128 valid_time_start_unix: None,
129 valid_time_end_unix: None,
130 predecessor_id: None,
131 blob_hash: Some("deadbeef".into()),
132 tombstone: false,
133 };
134 let entry =
135 JournalEntry::from_envelope(&envelope, "companion:phone-1", 1_700_000_100, None);
136 journal.append(&entry).unwrap();
137 let listed = journal.list_recent(10).unwrap();
138 assert_eq!(listed.len(), 1);
139 assert_eq!(listed[0].kind, "weight");
140 }
141}