lean_ctx/core/provenance/
store.rs1use std::fs::{self, File, OpenOptions};
2use std::io::{Read, Write};
3use std::path::{Path, PathBuf};
4
5use fs2::FileExt;
6use serde::{Deserialize, Serialize};
7
8use super::{CheckpointId, CheckpointRecord, ProvenanceId, ProvenanceRecord};
9
10const PROVENANCE_FILE: &str = "provenance.jsonl";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "record_type", content = "record", rename_all = "snake_case")]
14enum StoredRecord {
15 FileTouch(ProvenanceRecord),
16 Checkpoint(CheckpointRecord),
17}
18
19#[derive(Debug, Clone)]
21pub struct ProvenanceStore {
22 project_hash: String,
23 path: PathBuf,
24}
25
26impl ProvenanceStore {
27 pub fn new(project_hash: impl Into<String>) -> Result<Self, String> {
29 let data_dir = crate::core::data_dir::lean_ctx_data_dir()?;
30 Self::with_data_dir(data_dir, project_hash)
31 }
32
33 pub fn with_data_dir(
35 data_dir: impl AsRef<Path>,
36 project_hash: impl Into<String>,
37 ) -> Result<Self, String> {
38 let project_hash = project_hash.into();
39 validate_project_hash(&project_hash)?;
40 let path = data_dir
41 .as_ref()
42 .join("provenance")
43 .join(&project_hash)
44 .join(PROVENANCE_FILE);
45 Ok(Self { project_hash, path })
46 }
47
48 pub fn project_hash(&self) -> &str {
49 &self.project_hash
50 }
51
52 pub fn storage_path(&self) -> &Path {
53 &self.path
54 }
55
56 pub fn record_file_touch(&self, mut record: ProvenanceRecord) -> Result<ProvenanceId, String> {
58 self.mutate(|records| {
59 if !record.operation_id.is_empty() {
60 if let Some(existing) = records.iter().find_map(|entry| match entry {
61 StoredRecord::FileTouch(existing)
62 if existing.operation_id == record.operation_id =>
63 {
64 Some(existing.id.clone())
65 }
66 _ => None,
67 }) {
68 return Ok(existing);
69 }
70 }
71
72 if record.id.is_empty() {
73 record.id = generate_id("prov")?;
74 }
75 if record.project_hash.is_empty() {
76 record.project_hash.clone_from(&self.project_hash);
77 }
78 let id = record.id.clone();
79 records.push(StoredRecord::FileTouch(record));
80 Ok(id)
81 })
82 }
83
84 pub fn record_checkpoint(&self, mut record: CheckpointRecord) -> Result<CheckpointId, String> {
85 self.mutate(|records| {
86 if record.id.is_empty() {
87 record.id = generate_id("ckpt")?;
88 }
89 let id = record.id.clone();
90 records.push(StoredRecord::Checkpoint(record));
91 Ok(id)
92 })
93 }
94
95 pub fn query_by_path(&self, path: &str) -> Vec<ProvenanceRecord> {
96 self.read_records()
97 .unwrap_or_default()
98 .into_iter()
99 .filter_map(|entry| match entry {
100 StoredRecord::FileTouch(record) if record.path == path => Some(record),
101 _ => None,
102 })
103 .collect()
104 }
105
106 pub fn query_by_session(&self, session_id: &str) -> Vec<ProvenanceRecord> {
107 self.read_records()
108 .unwrap_or_default()
109 .into_iter()
110 .filter_map(|entry| match entry {
111 StoredRecord::FileTouch(record) if record.session_id == session_id => Some(record),
112 _ => None,
113 })
114 .collect()
115 }
116
117 pub fn query_recent_file_touches(&self, limit: usize) -> Vec<ProvenanceRecord> {
119 let mut records = self
120 .read_records()
121 .unwrap_or_default()
122 .into_iter()
123 .filter_map(|entry| match entry {
124 StoredRecord::FileTouch(record) => Some(record),
125 StoredRecord::Checkpoint(_) => None,
126 })
127 .collect::<Vec<_>>();
128 records.sort_by_key(|r| std::cmp::Reverse(r.observed_at));
129 records.truncate(limit);
130 records
131 }
132
133 pub fn query_checkpoints(&self, session_id: Option<&str>) -> Vec<CheckpointRecord> {
134 self.read_records()
135 .unwrap_or_default()
136 .into_iter()
137 .filter_map(|entry| match entry {
138 StoredRecord::Checkpoint(record)
139 if session_id.is_none_or(|session_id| record.session_id == session_id) =>
140 {
141 Some(record)
142 }
143 _ => None,
144 })
145 .collect()
146 }
147
148 pub(crate) fn update_file_touch(
149 &self,
150 provenance_id: &str,
151 update: impl FnOnce(&mut ProvenanceRecord),
152 ) -> Result<(), String> {
153 self.mutate(|records| {
154 let record = records.iter_mut().find_map(|entry| match entry {
155 StoredRecord::FileTouch(record) if record.id == provenance_id => Some(record),
156 _ => None,
157 });
158 let Some(record) = record else {
159 return Err(format!("provenance record not found: {provenance_id}"));
160 };
161 update(record);
162 Ok(())
163 })
164 }
165
166 pub(crate) fn update_checkpoint(
167 &self,
168 checkpoint_id: &str,
169 update: impl FnOnce(&mut CheckpointRecord),
170 ) -> Result<(), String> {
171 self.mutate(|records| {
172 let record = records.iter_mut().find_map(|entry| match entry {
173 StoredRecord::Checkpoint(record) if record.id == checkpoint_id => Some(record),
174 _ => None,
175 });
176 let Some(record) = record else {
177 return Err(format!("checkpoint record not found: {checkpoint_id}"));
178 };
179 update(record);
180 Ok(())
181 })
182 }
183
184 fn mutate<T>(
185 &self,
186 update: impl FnOnce(&mut Vec<StoredRecord>) -> Result<T, String>,
187 ) -> Result<T, String> {
188 let parent = self.parent_dir()?;
189 fs::create_dir_all(&parent)
190 .map_err(|error| format!("create provenance directory: {error}"))?;
191 let lock = acquire_file_lock(&parent)?;
192 let mut records = self.read_records()?;
193 let result = update(&mut records)?;
194 self.write_records_atomic(&records)?;
195 drop(lock);
196 Ok(result)
197 }
198
199 fn read_records(&self) -> Result<Vec<StoredRecord>, String> {
200 if !self.path.exists() {
201 return Ok(Vec::new());
202 }
203 let mut content = String::new();
204 File::open(&self.path)
205 .and_then(|mut file| file.read_to_string(&mut content))
206 .map_err(|error| format!("read provenance records: {error}"))?;
207 content
208 .lines()
209 .filter(|line| !line.trim().is_empty())
210 .map(|line| {
211 serde_json::from_str(line)
212 .map_err(|error| format!("parse provenance record: {error}"))
213 })
214 .collect()
215 }
216
217 fn write_records_atomic(&self, records: &[StoredRecord]) -> Result<(), String> {
218 let parent = self.parent_dir()?;
219 let temporary = parent.join(format!(".{PROVENANCE_FILE}.{}.tmp", random_hex()?));
220 let mut file = OpenOptions::new()
221 .write(true)
222 .create_new(true)
223 .open(&temporary)
224 .map_err(|error| format!("create temporary provenance file: {error}"))?;
225
226 let write_result = (|| {
227 for record in records {
228 let line = serde_json::to_string(record)
229 .map_err(|error| format!("serialize provenance record: {error}"))?;
230 writeln!(file, "{line}")
231 .map_err(|error| format!("write provenance record: {error}"))?;
232 }
233 file.sync_all()
234 .map_err(|error| format!("sync provenance records: {error}"))
235 })();
236 if let Err(error) = write_result {
237 let _ = fs::remove_file(&temporary);
238 return Err(error);
239 }
240
241 fs::rename(&temporary, &self.path)
242 .map_err(|error| format!("replace provenance records: {error}"))
243 }
244
245 pub(super) fn parent_dir(&self) -> Result<PathBuf, String> {
246 self.path
247 .parent()
248 .map(Path::to_path_buf)
249 .ok_or_else(|| "provenance storage path has no parent".to_owned())
250 }
251}
252
253fn validate_project_hash(project_hash: &str) -> Result<(), String> {
254 if project_hash.is_empty()
255 || !project_hash
256 .bytes()
257 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
258 {
259 return Err("project hash must contain only ASCII letters, digits, '-' or '_'".to_owned());
260 }
261 Ok(())
262}
263
264fn acquire_file_lock(dir: &Path) -> Result<File, String> {
265 let lock_path = dir.join(".provenance.lock");
266 let file = OpenOptions::new()
267 .read(true)
268 .write(true)
269 .create(true)
270 .truncate(false)
271 .open(lock_path)
272 .map_err(|error| format!("open provenance lock: {error}"))?;
273 file.lock_exclusive()
274 .map_err(|error| format!("lock provenance storage: {error}"))?;
275 Ok(file)
276}
277
278fn generate_id(prefix: &str) -> Result<String, String> {
279 Ok(format!("{prefix}-{}", random_hex()?))
280}
281
282fn random_hex() -> Result<String, String> {
283 let mut bytes = [0_u8; 16];
284 getrandom::fill(&mut bytes)
285 .map_err(|error| format!("provenance CSPRNG unavailable: {error}"))?;
286 Ok(bytes.iter().fold(String::with_capacity(32), |mut s, byte| {
287 use std::fmt::Write;
288 let _ = write!(s, "{byte:02x}");
289 s
290 }))
291}