Skip to main content

ready_set_sdk/
change_log.rs

1//! Change-log records for reversibility.
2//!
3//! See
4//! [`docs/contracts/change-log.md`](https://github.com/pulsearc-ai/ready-set/blob/main/docs/contracts/change-log.md)
5//! for the source of truth.
6
7use std::fs::{self, File, OpenOptions};
8use std::io::{BufRead, BufReader, BufWriter, Write};
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use time::OffsetDateTime;
13use time::format_description::well_known::Rfc3339;
14
15use crate::error::{Error, Result};
16use crate::fs as sdk_fs;
17
18/// Operation recorded in a change log.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum ChangeOp {
22    /// File was created.
23    Create,
24    /// File was modified.
25    Modify,
26    /// File was deleted.
27    Delete,
28}
29
30/// One change-log record.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ChangeRecord {
33    /// Operation type.
34    pub op: ChangeOp,
35    /// Project-relative, forward-slash path.
36    pub path: PathBuf,
37    /// SHA-256 of the file before the change. `None` for `create`.
38    pub before_sha256: Option<String>,
39    /// SHA-256 of the file after the change. `None` for `delete`.
40    pub after_sha256: Option<String>,
41    /// RFC3339 UTC timestamp.
42    #[serde(with = "rfc3339_utc")]
43    pub ts: OffsetDateTime,
44}
45
46/// Append-only writer for one plugin invocation's records.
47pub struct ChangeLog {
48    project_root: PathBuf,
49    plugin: String,
50    file_path: PathBuf,
51    writer: BufWriter<File>,
52}
53
54impl ChangeLog {
55    /// Open (creating) the JSONL file for this plugin invocation.
56    ///
57    /// File path: `<project_root>/.ready-set/changes/<plugin>-<rfc3339>-<rand4>.jsonl`.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::Io`] if the directory cannot be created or the file
62    /// cannot be opened, or [`Error::Other`] if randomness collection fails.
63    pub fn open(project_root: &Path, plugin: &str) -> Result<Self> {
64        let dir = project_root.join(".ready-set/changes");
65        fs::create_dir_all(&dir)?;
66
67        let now = OffsetDateTime::now_utc();
68        let stamp = filename_stamp(now)?;
69        let rand = rand_suffix()?;
70        let file_path = dir.join(format!("{plugin}-{stamp}-{rand}.jsonl"));
71
72        let file = OpenOptions::new()
73            .create(true)
74            .append(true)
75            .open(&file_path)?;
76
77        Ok(Self {
78            project_root: project_root.to_path_buf(),
79            plugin: plugin.to_string(),
80            file_path,
81            writer: BufWriter::new(file),
82        })
83    }
84
85    /// Path to the JSONL file this `ChangeLog` writes to.
86    #[must_use]
87    pub fn file_path(&self) -> &Path {
88        &self.file_path
89    }
90
91    /// Plugin name associated with this log.
92    #[must_use]
93    pub fn plugin(&self) -> &str {
94        &self.plugin
95    }
96
97    /// Project root associated with this log.
98    #[must_use]
99    pub fn project_root(&self) -> &Path {
100        &self.project_root
101    }
102
103    /// Append a record. Each record is fsynced before the call returns.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`Error::Io`] on a write or fsync failure or
108    /// [`Error::JsonParse`] if the record cannot be serialized.
109    pub fn record(&mut self, record: &ChangeRecord) -> Result<()> {
110        let line = serde_json::to_string(record)?;
111        self.writer.write_all(line.as_bytes())?;
112        self.writer.write_all(b"\n")?;
113        self.writer.flush()?;
114        self.writer.get_ref().sync_all()?;
115        Ok(())
116    }
117
118    /// Flush buffered records.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`Error::Io`] on a write or fsync failure.
123    pub fn flush(&mut self) -> Result<()> {
124        self.writer.flush()?;
125        self.writer.get_ref().sync_all()?;
126        Ok(())
127    }
128}
129
130/// Read every record from `<project_root>/.ready-set/changes/`, sorted in
131/// **reverse chronological order**.
132///
133/// Returns `(file_path, record)` tuples so callers can later remove
134/// successfully reversed entries from their source file.
135///
136/// # Errors
137///
138/// Returns [`Error::Io`] if the changes directory cannot be read.
139/// Malformed JSONL lines are skipped silently to keep `undo` resilient.
140pub fn reverse_dir(project_root: &Path) -> Result<Vec<(PathBuf, ChangeRecord)>> {
141    let dir = project_root.join(".ready-set/changes");
142    if !dir.exists() {
143        return Ok(Vec::new());
144    }
145    let mut all: Vec<(PathBuf, ChangeRecord)> = Vec::new();
146    for entry in fs::read_dir(&dir)? {
147        let entry = entry?;
148        let path = entry.path();
149        if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
150            continue;
151        }
152        let Ok(file) = File::open(&path) else {
153            continue;
154        };
155        let reader = BufReader::new(file);
156        for line in reader.lines() {
157            let Ok(line) = line else { continue };
158            if line.trim().is_empty() {
159                continue;
160            }
161            let Ok(record) = serde_json::from_str::<ChangeRecord>(&line) else {
162                continue;
163            };
164            all.push((path.clone(), record));
165        }
166    }
167    all.sort_by_key(|entry| std::cmp::Reverse(entry.1.ts));
168    Ok(all)
169}
170
171/// Copy `source` into `<project_root>/.ready-set/backups/<sha>` (content
172/// addressed). Returns the SHA used as the backup filename.
173///
174/// # Errors
175///
176/// Returns [`Error::Io`] if the source cannot be hashed or the backup cannot
177/// be written.
178pub fn backup_file(project_root: &Path, source: &Path) -> Result<String> {
179    let sha = sdk_fs::sha256_file(source)?;
180    let backups = project_root.join(".ready-set/backups");
181    fs::create_dir_all(&backups)?;
182    let dest = backups.join(&sha);
183    if !dest.exists() {
184        let bytes = fs::read(source)?;
185        sdk_fs::atomic_write(&dest, &bytes)?;
186    }
187    Ok(sha)
188}
189
190fn filename_stamp(ts: OffsetDateTime) -> Result<String> {
191    let formatted = ts
192        .format(&Rfc3339)
193        .map_err(|e| Error::Other(format!("rfc3339 format: {e}")))?;
194    // Replace `:` with `-` for cross-platform filename safety, then trim
195    // any sub-second precision so filenames stay compact.
196    let without_subsec = match formatted.split_once('.') {
197        Some((head, tail)) => {
198            // tail looks like "123456789Z" — keep just the `Z` suffix.
199            let z = if tail.contains('Z') { "Z" } else { "" };
200            format!("{head}{z}")
201        },
202        None => formatted,
203    };
204    Ok(without_subsec.replace(':', "-"))
205}
206
207fn rand_suffix() -> Result<String> {
208    let mut buf = [0_u8; 2];
209    getrandom::fill(&mut buf).map_err(|e| Error::Other(format!("getrandom: {e}")))?;
210    Ok(crate::fs::encode_hex_lower(&buf))
211}
212
213mod rfc3339_utc {
214    use serde::{Deserialize, Deserializer, Serialize, Serializer};
215    use time::OffsetDateTime;
216    use time::format_description::well_known::Rfc3339;
217
218    pub fn serialize<S: Serializer>(ts: &OffsetDateTime, ser: S) -> Result<S::Ok, S::Error> {
219        ts.format(&Rfc3339)
220            .map_err(serde::ser::Error::custom)?
221            .serialize(ser)
222    }
223
224    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<OffsetDateTime, D::Error> {
225        let raw = String::deserialize(de)?;
226        OffsetDateTime::parse(&raw, &Rfc3339).map_err(serde::de::Error::custom)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::fs as stdfs;
234
235    #[test]
236    fn writes_and_reads_records_in_reverse_order() {
237        let dir = tempfile::tempdir().unwrap();
238        let root = dir.path();
239
240        let mut log = ChangeLog::open(root, "go").unwrap();
241        let earlier = ChangeRecord {
242            op: ChangeOp::Create,
243            path: PathBuf::from("a.txt"),
244            before_sha256: None,
245            after_sha256: Some("a".repeat(64)),
246            ts: OffsetDateTime::from_unix_timestamp(1_000).unwrap(),
247        };
248        let later = ChangeRecord {
249            op: ChangeOp::Modify,
250            path: PathBuf::from("b.txt"),
251            before_sha256: Some("b".repeat(64)),
252            after_sha256: Some("c".repeat(64)),
253            ts: OffsetDateTime::from_unix_timestamp(2_000).unwrap(),
254        };
255        log.record(&earlier).unwrap();
256        log.record(&later).unwrap();
257        drop(log);
258
259        let all = reverse_dir(root).unwrap();
260        assert_eq!(all.len(), 2);
261        assert_eq!(all[0].1.path, PathBuf::from("b.txt"));
262        assert_eq!(all[1].1.path, PathBuf::from("a.txt"));
263    }
264
265    #[test]
266    fn backup_is_content_addressed_and_deduped() {
267        let dir = tempfile::tempdir().unwrap();
268        let root = dir.path();
269
270        let src = root.join("src.txt");
271        stdfs::write(&src, b"hello").unwrap();
272        let sha1 = backup_file(root, &src).unwrap();
273        let sha2 = backup_file(root, &src).unwrap();
274        assert_eq!(sha1, sha2);
275
276        let backup = root.join(".ready-set/backups").join(&sha1);
277        assert!(backup.exists());
278    }
279
280    #[test]
281    fn empty_changes_dir_returns_empty_vec() {
282        let dir = tempfile::tempdir().unwrap();
283        let all = reverse_dir(dir.path()).unwrap();
284        assert!(all.is_empty());
285    }
286
287    #[test]
288    fn malformed_lines_are_skipped() {
289        let dir = tempfile::tempdir().unwrap();
290        let changes = dir.path().join(".ready-set/changes");
291        stdfs::create_dir_all(&changes).unwrap();
292        let path = changes.join("bad-2026-01-01T00-00-00Z-aaaa.jsonl");
293        stdfs::write(&path, b"this is not json\n{also not}\n").unwrap();
294        let all = reverse_dir(dir.path()).unwrap();
295        assert!(all.is_empty());
296    }
297}