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