Skip to main content

lean_ctx/core/provenance/
store.rs

1use 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/// Project-scoped JSONL persistence for edit provenance and checkpoints.
20#[derive(Debug, Clone)]
21pub struct ProvenanceStore {
22    project_hash: String,
23    path: PathBuf,
24}
25
26impl ProvenanceStore {
27    /// Opens the default `<data>/provenance/<project-hash>/provenance.jsonl` store.
28    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    /// Opens a store below an explicit data directory, primarily for isolated callers and tests.
34    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    /// Persists a file observation, returning an existing record ID for a duplicate operation.
57    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    /// Returns the most recently observed file touches, newest first.
118    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}