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 "doc": r.doc, "path": r.path, "contentHash": r.content_hash,
73 })).collect::<Vec<_>>(),
74 })
75 }
76}
77
78fn render_summary(
82 origin: &str,
83 actor: Option<&str>,
84 paths: &[&str],
85 dispositions: &[(String, i64)],
86) -> String {
87 let paths = paths.join(", ");
88 let parts: Vec<String> = dispositions
89 .iter()
90 .filter(|(kind, _)| kind != "same")
91 .map(|(kind, n)| format!("{n} {kind}"))
92 .collect();
93 let detail = if parts.is_empty() {
94 String::new()
95 } else {
96 format!(" — {}", parts.join(", "))
97 };
98 if origin == "api" || origin == "import" {
99 format!("{origin}({}): {paths}{detail}", actor.unwrap_or("?"))
100 } else {
101 format!("observed: {paths}{detail}")
102 }
103}
104
105pub fn changes_since(
109 conn: &Connection,
110 repo_id: &str,
111 cursor: i64,
112 limit: usize,
113 origin: Option<&str>,
114) -> Result<ChangesPage> {
115 let head: i64 = conn.query_row(
116 "SELECT COALESCE(MAX(seq), 0) FROM commits WHERE repo_id = ?1",
117 params![repo_id],
118 |r| r.get(0),
119 )?;
120 let fetch = i64::try_from(limit).unwrap_or(i64::MAX).saturating_add(1);
121 type Row = (String, i64, String, String, Option<String>);
122 let mut commits: Vec<Row> = match origin {
123 Some(o) => {
124 let mut stmt = conn.prepare(
125 "SELECT commit_id, seq, ts, origin, actor FROM commits
126 WHERE repo_id = ?1 AND seq > ?2 AND origin = ?3 ORDER BY seq LIMIT ?4",
127 )?;
128 let rows = stmt.query_map(params![repo_id, cursor, o, fetch], |r| {
129 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
130 })?;
131 rows.collect::<std::result::Result<Vec<_>, _>>()?
132 }
133 None => {
134 let mut stmt = conn.prepare(
135 "SELECT commit_id, seq, ts, origin, actor FROM commits
136 WHERE repo_id = ?1 AND seq > ?2 ORDER BY seq LIMIT ?3",
137 )?;
138 let rows = stmt.query_map(params![repo_id, cursor, fetch], |r| {
139 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
140 })?;
141 rows.collect::<std::result::Result<Vec<_>, _>>()?
142 }
143 };
144 let truncated = commits.len() > limit;
145 commits.truncate(limit);
146
147 let mut revs_stmt = conn.prepare(
148 "SELECT doc_id, path, rendered_hash FROM revisions WHERE commit_id = ?1 ORDER BY rowid",
149 )?;
150 let mut disp_stmt =
151 conn.prepare("SELECT kind, count(*) FROM dispositions WHERE commit_id = ?1 GROUP BY kind")?;
152 let mut digests = Vec::with_capacity(commits.len());
153 for (commit_id, seq, ts, origin, actor) in commits {
154 let revisions: Vec<DigestRevision> = revs_stmt
155 .query_map(params![commit_id], |r| {
156 Ok(DigestRevision {
157 doc: r.get(0)?,
158 path: r.get(1)?,
159 content_hash: hex(&r.get::<_, Vec<u8>>(2)?),
160 })
161 })?
162 .collect::<std::result::Result<_, _>>()?;
163 let dispositions: Vec<(String, i64)> = disp_stmt
164 .query_map(params![commit_id], |r| Ok((r.get(0)?, r.get(1)?)))?
165 .collect::<std::result::Result<_, _>>()?;
166 let paths: Vec<&str> = revisions.iter().map(|r| r.path.as_str()).collect();
167 let summary = render_summary(&origin, actor.as_deref(), &paths, &dispositions);
168 digests.push(CommitDigest {
169 commit: commit_id,
170 seq,
171 ts,
172 origin,
173 actor,
174 summary,
175 revisions,
176 });
177 }
178 let next = digests.last().map_or(cursor, |d| d.seq);
179 Ok(ChangesPage {
180 digests,
181 cursor: next,
182 truncated,
183 head,
184 })
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::{BatchItem, SequentialMinter, Store};
191 use omgbase_reconcile::Config;
192
193 fn store_with_commits(n: usize) -> (Store, String) {
194 let mut store =
195 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
196 let repo = store.create_repo("r").unwrap();
197 for i in 0..n {
198 let items = [BatchItem::observed("a.md", &format!("# T\n\nv{i}\n"))];
199 store
200 .observe_batch(
201 &repo,
202 &items,
203 "2026-09-26T10:00:00.000Z",
204 &Config::default(),
205 )
206 .unwrap();
207 }
208 (store, repo)
209 }
210
211 #[test]
212 fn pages_in_seq_order_with_truncation_and_head() {
213 let (store, repo) = store_with_commits(3);
214 let page = store.changes_since(&repo, 0, 2, None).unwrap();
215 assert_eq!(page.digests.len(), 2);
216 assert!(page.truncated);
217 assert_eq!(page.cursor, 2);
218 assert_eq!(page.head, 3);
219 assert_eq!(page.digests[0].seq, 1);
220 assert_eq!(page.digests[0].commit, "c_0");
221 assert_eq!(page.digests[0].origin, "observed");
222 assert_eq!(page.digests[0].revisions.len(), 1);
223 assert_eq!(page.digests[0].revisions[0].path, "a.md");
224 assert_eq!(page.digests[0].revisions[0].doc, "d_0");
225 assert_eq!(page.digests[0].revisions[0].content_hash.len(), 64);
226 assert!(page.digests[0].summary.starts_with("observed: a.md"));
227
228 let rest = store.changes_since(&repo, page.cursor, 2, None).unwrap();
229 assert_eq!(rest.digests.len(), 1);
230 assert!(!rest.truncated);
231 assert_eq!(rest.cursor, 3);
232
233 let empty = store.changes_since(&repo, 3, 50, None).unwrap();
234 assert!(empty.digests.is_empty());
235 assert_eq!(empty.cursor, 3, "the input cursor when the page is empty");
236 assert_eq!(empty.head, 3);
237 }
238
239 #[test]
240 fn filters_by_origin() {
241 let (store, repo) = store_with_commits(2);
242 let api = store.changes_since(&repo, 0, 50, Some("api")).unwrap();
243 assert!(api.digests.is_empty());
244 assert_eq!(api.cursor, 0);
245 assert_eq!(api.head, 2);
246 let observed = store.changes_since(&repo, 0, 50, Some("observed")).unwrap();
247 assert_eq!(observed.digests.len(), 2);
248 }
249
250 #[test]
251 fn summary_follows_the_reference_shape() {
252 assert_eq!(
253 render_summary(
254 "api",
255 Some("agent"),
256 &["a.md"],
257 &[("same".into(), 3), ("edited".into(), 1)]
258 ),
259 "api(agent): a.md — 1 edited"
260 );
261 assert_eq!(
262 render_summary("import", None, &["a.md", "b.md"], &[]),
263 "import(?): a.md, b.md"
264 );
265 assert_eq!(
266 render_summary("observed", None, &[], &[("inserted".into(), 2)]),
267 "observed: — 2 inserted"
268 );
269 let (store, repo) = store_with_commits(1);
270 let page = store.changes_since(&repo, 0, 50, None).unwrap();
271 let json = page.to_json();
272 assert_eq!(
273 json["digests"][0]["revisions"][0]["contentHash"],
274 page.digests[0].revisions[0].content_hash
275 );
276 assert_eq!(json["head"], 1);
277 }
278}