1use omgbase_format::hash::hex;
9use rusqlite::{Connection, params};
10
11use crate::error::Result;
12
13#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct DigestRevision {
16 pub doc: String,
17 pub path: String,
18 pub content_hash: String,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct CommitDigest {
25 pub commit: String,
26 pub seq: i64,
27 pub ts: String,
28 pub origin: String,
29 pub actor: Option<String>,
30 pub summary: String,
32 pub revisions: Vec<DigestRevision>,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct ChangesPage {
38 pub digests: Vec<CommitDigest>,
39 pub cursor: i64,
41 pub truncated: bool,
43 pub head: i64,
45}
46
47impl ChangesPage {
48 #[must_use]
50 pub fn to_json(&self) -> serde_json::Value {
51 serde_json::json!({
52 "digests": self.digests.iter().map(CommitDigest::to_json).collect::<Vec<_>>(),
53 "cursor": self.cursor,
54 "truncated": self.truncated,
55 "head": self.head,
56 })
57 }
58}
59
60impl CommitDigest {
61 #[must_use]
66 pub fn to_json(&self) -> serde_json::Value {
67 serde_json::json!({
68 "commit": self.commit,
69 "seq": self.seq,
70 "ts": self.ts,
71 "origin": self.origin,
72 "actor": self.actor,
73 "summary": self.summary,
74 "revisions": self.revisions.iter().map(|r| serde_json::json!({
75 "_id": r.doc, "_path": r.path, "_hash": r.content_hash,
76 })).collect::<Vec<_>>(),
77 })
78 }
79}
80
81#[derive(Clone, Debug, Default, PartialEq, Eq)]
86struct SummaryExtra {
87 deleted: Vec<String>,
88 moved: Option<(String, String)>,
89}
90
91fn render_summary(
97 origin: &str,
98 actor: Option<&str>,
99 paths: &[&str],
100 dispositions: &[(String, i64)],
101 extra: &SummaryExtra,
102) -> String {
103 let mut paths = paths.join(", ");
104 if let Some((from, to)) = &extra.moved {
105 paths = if paths.is_empty() {
106 format!("moved {from} → {to}")
107 } else {
108 format!("moved {from} → {to}; {paths}")
109 };
110 } else if paths.is_empty() && !extra.deleted.is_empty() {
111 paths = format!("deleted {}", extra.deleted.join(", "));
112 }
113 let parts: Vec<String> = dispositions
114 .iter()
115 .filter(|(kind, _)| kind != "same")
116 .map(|(kind, n)| format!("{n} {kind}"))
117 .collect();
118 let detail = if parts.is_empty() {
119 String::new()
120 } else {
121 format!(" — {}", parts.join(", "))
122 };
123 if origin == "api" || origin == "import" {
124 format!("{origin}({}): {paths}{detail}", actor.unwrap_or("?"))
125 } else {
126 format!("observed: {paths}{detail}")
127 }
128}
129
130fn parse_move(reason: &str) -> Option<(String, String)> {
132 let rest = reason.strip_prefix("move ")?;
133 let (from, to) = rest.split_once(" -> ")?;
134 if from.is_empty() || to.is_empty() {
135 return None;
136 }
137 Some((from.to_owned(), to.to_owned()))
138}
139
140pub fn changes_since(
144 conn: &Connection,
145 repo_id: &str,
146 cursor: i64,
147 limit: usize,
148 origin: Option<&str>,
149) -> Result<ChangesPage> {
150 let head: i64 = conn.query_row(
151 "SELECT COALESCE(MAX(seq), 0) FROM commits WHERE repo_id = ?1",
152 params![repo_id],
153 |r| r.get(0),
154 )?;
155 let fetch = i64::try_from(limit).unwrap_or(i64::MAX).saturating_add(1);
156 type Row = (String, i64, String, String, Option<String>, Option<String>);
157 let mut commits: Vec<Row> = match origin {
158 Some(o) => {
159 let mut stmt = conn.prepare(
160 "SELECT commit_id, seq, ts, origin, actor, reason FROM commits
161 WHERE repo_id = ?1 AND seq > ?2 AND origin = ?3 ORDER BY seq LIMIT ?4",
162 )?;
163 let rows = stmt.query_map(params![repo_id, cursor, o, fetch], |r| {
164 Ok((
165 r.get(0)?,
166 r.get(1)?,
167 r.get(2)?,
168 r.get(3)?,
169 r.get(4)?,
170 r.get(5)?,
171 ))
172 })?;
173 rows.collect::<std::result::Result<Vec<_>, _>>()?
174 }
175 None => {
176 let mut stmt = conn.prepare(
177 "SELECT commit_id, seq, ts, origin, actor, reason FROM commits
178 WHERE repo_id = ?1 AND seq > ?2 ORDER BY seq LIMIT ?3",
179 )?;
180 let rows = stmt.query_map(params![repo_id, cursor, fetch], |r| {
181 Ok((
182 r.get(0)?,
183 r.get(1)?,
184 r.get(2)?,
185 r.get(3)?,
186 r.get(4)?,
187 r.get(5)?,
188 ))
189 })?;
190 rows.collect::<std::result::Result<Vec<_>, _>>()?
191 }
192 };
193 let truncated = commits.len() > limit;
194 commits.truncate(limit);
195
196 let mut revs_stmt = conn.prepare(
197 "SELECT doc_id, path, rendered_hash FROM revisions WHERE commit_id = ?1 ORDER BY rowid",
198 )?;
199 let mut disp_stmt =
200 conn.prepare("SELECT kind, count(*) FROM dispositions WHERE commit_id = ?1 GROUP BY kind")?;
201 let mut deleted_stmt =
205 conn.prepare("SELECT path FROM docs WHERE deleted_commit = ?1 ORDER BY path")?;
206 let mut digests = Vec::with_capacity(commits.len());
207 for (commit_id, seq, ts, origin, actor, reason) in commits {
208 let revisions: Vec<DigestRevision> = revs_stmt
209 .query_map(params![commit_id], |r| {
210 Ok(DigestRevision {
211 doc: r.get(0)?,
212 path: r.get(1)?,
213 content_hash: hex(&r.get::<_, Vec<u8>>(2)?),
214 })
215 })?
216 .collect::<std::result::Result<_, _>>()?;
217 let dispositions: Vec<(String, i64)> = disp_stmt
218 .query_map(params![commit_id], |r| Ok((r.get(0)?, r.get(1)?)))?
219 .collect::<std::result::Result<_, _>>()?;
220 let paths: Vec<&str> = revisions.iter().map(|r| r.path.as_str()).collect();
221 let extra = SummaryExtra {
222 deleted: if revisions.is_empty() {
223 deleted_stmt
224 .query_map(params![commit_id], |r| r.get::<_, String>(0))?
225 .collect::<std::result::Result<_, _>>()?
226 } else {
227 Vec::new()
228 },
229 moved: reason.as_deref().and_then(parse_move),
230 };
231 let summary = render_summary(&origin, actor.as_deref(), &paths, &dispositions, &extra);
232 digests.push(CommitDigest {
233 commit: commit_id,
234 seq,
235 ts,
236 origin,
237 actor,
238 summary,
239 revisions,
240 });
241 }
242 let next = digests.last().map_or(cursor, |d| d.seq);
243 Ok(ChangesPage {
244 digests,
245 cursor: next,
246 truncated,
247 head,
248 })
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::{BatchItem, SequentialMinter, Store};
255 use omgbase_reconcile::Config;
256
257 fn store_with_commits(n: usize) -> (Store, String) {
258 let mut store =
259 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
260 let repo = store.create_repo("r").unwrap();
261 for i in 0..n {
262 let items = [BatchItem::observed("a.md", &format!("# T\n\nv{i}\n"))];
263 store
264 .observe_batch(
265 &repo,
266 &items,
267 "2026-09-26T10:00:00.000Z",
268 &Config::default(),
269 )
270 .unwrap();
271 }
272 (store, repo)
273 }
274
275 #[test]
276 fn pages_in_seq_order_with_truncation_and_head() {
277 let (store, repo) = store_with_commits(3);
278 let page = store.changes_since(&repo, 0, 2, None).unwrap();
279 assert_eq!(page.digests.len(), 2);
280 assert!(page.truncated);
281 assert_eq!(page.cursor, 2);
282 assert_eq!(page.head, 3);
283 assert_eq!(page.digests[0].seq, 1);
284 assert_eq!(page.digests[0].commit, "c_0");
285 assert_eq!(page.digests[0].origin, "observed");
286 assert_eq!(page.digests[0].revisions.len(), 1);
287 assert_eq!(page.digests[0].revisions[0].path, "a.md");
288 assert_eq!(page.digests[0].revisions[0].doc, "d_0");
289 assert_eq!(page.digests[0].revisions[0].content_hash.len(), 64);
290 assert!(page.digests[0].summary.starts_with("observed: a.md"));
291
292 let rest = store.changes_since(&repo, page.cursor, 2, None).unwrap();
293 assert_eq!(rest.digests.len(), 1);
294 assert!(!rest.truncated);
295 assert_eq!(rest.cursor, 3);
296
297 let empty = store.changes_since(&repo, 3, 50, None).unwrap();
298 assert!(empty.digests.is_empty());
299 assert_eq!(empty.cursor, 3, "the input cursor when the page is empty");
300 assert_eq!(empty.head, 3);
301 }
302
303 #[test]
304 fn filters_by_origin() {
305 let (store, repo) = store_with_commits(2);
306 let api = store.changes_since(&repo, 0, 50, Some("api")).unwrap();
307 assert!(api.digests.is_empty());
308 assert_eq!(api.cursor, 0);
309 assert_eq!(api.head, 2);
310 let observed = store.changes_since(&repo, 0, 50, Some("observed")).unwrap();
311 assert_eq!(observed.digests.len(), 2);
312 }
313
314 #[test]
315 fn summary_follows_the_reference_shape() {
316 assert_eq!(
317 render_summary(
318 "api",
319 Some("agent"),
320 &["a.md"],
321 &[("same".into(), 3), ("edited".into(), 1)],
322 &SummaryExtra::default(),
323 ),
324 "api(agent): a.md — 1 edited"
325 );
326 assert_eq!(
327 render_summary(
328 "import",
329 None,
330 &["a.md", "b.md"],
331 &[],
332 &SummaryExtra::default()
333 ),
334 "import(?): a.md, b.md"
335 );
336 assert_eq!(
337 render_summary(
338 "observed",
339 None,
340 &[],
341 &[("inserted".into(), 2)],
342 &SummaryExtra::default()
343 ),
344 "observed: — 2 inserted"
345 );
346 let deleted = SummaryExtra {
347 deleted: vec!["a.md".into()],
348 moved: None,
349 };
350 assert_eq!(
351 render_summary("observed", None, &[], &[], &deleted),
352 "observed: deleted a.md"
353 );
354 let moved = SummaryExtra {
355 deleted: vec![],
356 moved: parse_move("move a.md -> b/a.md"),
357 };
358 assert_eq!(
359 render_summary("api", Some("human:spec"), &[], &[], &moved),
360 "api(human:spec): moved a.md → b/a.md"
361 );
362 assert_eq!(
364 render_summary(
365 "api",
366 Some("human:spec"),
367 &["index.md"],
368 &[("edited".into(), 31)],
369 &moved
370 ),
371 "api(human:spec): moved a.md → b/a.md; index.md — 31 edited"
372 );
373 assert_eq!(parse_move("nope"), None);
374 let (store, repo) = store_with_commits(1);
375 let page = store.changes_since(&repo, 0, 50, None).unwrap();
376 let json = page.to_json();
377 assert_eq!(
378 json["digests"][0]["revisions"][0]["_hash"],
379 page.digests[0].revisions[0].content_hash
380 );
381 assert_eq!(json["head"], 1);
382 }
383}