ready_set_sdk/
change_log.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum ChangeOp {
22 Create,
24 Modify,
26 Delete,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ChangeRecord {
33 pub op: ChangeOp,
35 pub path: PathBuf,
37 pub before_sha256: Option<String>,
39 pub after_sha256: Option<String>,
41 #[serde(with = "rfc3339_utc")]
43 pub ts: OffsetDateTime,
44}
45
46pub struct ChangeLog {
48 project_root: PathBuf,
49 plugin: String,
50 file_path: PathBuf,
51 writer: BufWriter<File>,
52}
53
54impl ChangeLog {
55 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 #[must_use]
87 pub fn file_path(&self) -> &Path {
88 &self.file_path
89 }
90
91 #[must_use]
93 pub fn plugin(&self) -> &str {
94 &self.plugin
95 }
96
97 #[must_use]
99 pub fn project_root(&self) -> &Path {
100 &self.project_root
101 }
102
103 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 pub fn flush(&mut self) -> Result<()> {
124 self.writer.flush()?;
125 self.writer.get_ref().sync_all()?;
126 Ok(())
127 }
128}
129
130pub 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
171pub 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 let without_subsec = match formatted.split_once('.') {
197 Some((head, tail)) => {
198 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}