1use std::path::Path;
6
7use omgbase_store::Store;
8use rusqlite::{OptionalExtension, params};
9use serde_json::Value;
10
11use crate::error::Result;
12use crate::freshness::{DiskDrift, detect_disk_drift};
13use crate::fs::FileSystem;
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub struct DiskStatus {
18 pub drift: DiskDrift,
19 pub checked: bool,
21}
22
23impl DiskStatus {
24 #[must_use]
25 pub fn to_json(&self) -> Value {
26 serde_json::json!({
27 "changed": self.drift.changed,
28 "deleted": self.drift.deleted,
29 "untracked": self.drift.untracked,
30 "checked": self.checked,
31 })
32 }
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct RepoStatus {
38 pub repo_id: String,
39 pub slug: String,
40 pub root_path: String,
42 pub docs: i64,
43 pub blocks: i64,
44 pub commits: i64,
45 pub open_edges: i64,
46 pub unconverged: i64,
49 pub disk: DiskStatus,
50}
51
52impl RepoStatus {
53 #[must_use]
56 pub fn to_json(&self) -> Value {
57 serde_json::json!({
58 "_id": self.repo_id,
59 "slug": self.slug,
60 "rootPath": self.root_path,
61 "docs": self.docs,
62 "blocks": self.blocks,
63 "commits": self.commits,
64 "openEdges": self.open_edges,
65 "unconverged": self.unconverged,
66 "disk": self.disk.to_json(),
67 })
68 }
69}
70
71pub fn repos_status(
74 store: &Store,
75 repo_id: &str,
76 disk: Option<(&dyn FileSystem, &Path)>,
77) -> Result<RepoStatus> {
78 let conn = store.conn();
79 let slug: Option<String> = conn
80 .query_row(
81 "SELECT slug FROM repos WHERE repo_id = ?1",
82 params![repo_id],
83 |r| r.get(0),
84 )
85 .optional()?;
86 let count =
87 |sql: &str| -> Result<i64> { Ok(conn.query_row(sql, params![repo_id], |r| r.get(0))?) };
88 let unconverged = count(
89 "SELECT count(*) FROM docs d JOIN revisions r ON r.rev_id = d.current_rev
90 WHERE d.repo_id = ?1 AND d.deleted_commit IS NULL AND d.file_hash IS NOT r.rendered_hash",
91 )?;
92 let (root_path, disk) = match disk {
93 Some((fs, root)) => (
94 root.to_string_lossy().into_owned(),
95 DiskStatus {
96 drift: detect_disk_drift(store, repo_id, fs, root)?,
97 checked: true,
98 },
99 ),
100 None => (String::new(), DiskStatus::default()),
101 };
102 Ok(RepoStatus {
103 repo_id: repo_id.to_owned(),
104 slug: slug.unwrap_or_default(),
105 root_path,
106 docs: count("SELECT count(*) FROM docs WHERE repo_id = ?1 AND deleted_commit IS NULL")?,
107 blocks: count("SELECT count(*) FROM blocks WHERE repo_id = ?1 AND deleted_commit IS NULL")?,
108 commits: count("SELECT count(*) FROM commits WHERE repo_id = ?1")?,
109 open_edges: count("SELECT count(*) FROM edges WHERE repo_id = ?1 AND to_commit IS NULL")?,
110 unconverged,
111 disk,
112 })
113}
114
115#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct SyncStatus {
118 pub last_commit_seq: i64,
119 pub last_checkpoint: Option<String>,
121 pub convergent: bool,
123 pub disk: DiskStatus,
124}
125
126impl SyncStatus {
127 #[must_use]
128 pub fn to_json(&self) -> Value {
129 serde_json::json!({
130 "lastCommitSeq": self.last_commit_seq,
131 "lastCheckpoint": self.last_checkpoint,
132 "convergent": self.convergent,
133 "diskChecked": self.disk.checked,
134 "disk": self.disk.to_json(),
135 })
136 }
137}
138
139pub fn sync_status(
141 store: &Store,
142 repo_id: &str,
143 disk: Option<(&dyn FileSystem, &Path)>,
144) -> Result<SyncStatus> {
145 let conn = store.conn();
146 let last_commit_seq: i64 = conn.query_row(
147 "SELECT COALESCE(MAX(seq), 0) FROM commits WHERE repo_id = ?1",
148 params![repo_id],
149 |r| r.get(0),
150 )?;
151 let last_checkpoint: Option<String> = conn
152 .query_row(
153 "SELECT id FROM checkpoints WHERE repo_id = ?1 ORDER BY ts DESC LIMIT 1",
154 params![repo_id],
155 |r| r.get(0),
156 )
157 .optional()?;
158 let status = repos_status(store, repo_id, disk)?;
159 Ok(SyncStatus {
160 last_commit_seq,
161 last_checkpoint,
162 convergent: status.unconverged == 0 && status.disk.checked && status.disk.drift.is_clean(),
163 disk: status.disk,
164 })
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::checkpoint::process_checkpoint;
171 use crate::fs::MemFileSystem;
172 use omgbase_reconcile::Config;
173 use omgbase_store::SequentialMinter;
174
175 const TS: &str = "2026-09-26T10:00:00.000Z";
176
177 #[test]
178 fn status_counts_and_never_green_unverified() {
179 let mut store =
180 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
181 let repo = store.create_repo("fixture").unwrap();
182 let s = sync_status(&store, &repo, None).unwrap();
183 assert_eq!(s.last_commit_seq, 0);
184 assert_eq!(s.last_checkpoint, None);
185 assert!(!s.convergent, "an unverified disk is never convergent");
186 let rs = repos_status(&store, &repo, None).unwrap();
187 assert_eq!(
188 (
189 rs.docs,
190 rs.blocks,
191 rs.commits,
192 rs.open_edges,
193 rs.unconverged
194 ),
195 (0, 0, 0, 0, 0)
196 );
197 assert_eq!(rs.slug, "fixture");
198 assert_eq!(rs.root_path, "");
199 assert!(!rs.disk.checked);
200
201 let mut fs = MemFileSystem::new();
202 fs.set("a.md", "# A\n\nSee [b](b.md).\n", 1);
203 let root = Path::new("/r");
204 process_checkpoint(
205 &mut store,
206 &repo,
207 &fs,
208 root,
209 &["a.md".to_owned()],
210 TS,
211 None,
212 &Config::default(),
213 )
214 .unwrap();
215 let rs = repos_status(&store, &repo, Some((&fs, root))).unwrap();
216 assert_eq!(
217 (
218 rs.docs,
219 rs.blocks,
220 rs.commits,
221 rs.open_edges,
222 rs.unconverged
223 ),
224 (1, 2, 1, 1, 0)
225 );
226 assert_eq!(rs.root_path, "/r");
227 assert!(rs.disk.checked);
228 assert!(rs.disk.drift.is_clean());
231 let s = sync_status(&store, &repo, Some((&fs, root))).unwrap();
232 assert_eq!(s.last_commit_seq, 1);
233 assert_eq!(s.last_checkpoint.as_deref(), Some("cp_0"));
234 assert!(s.convergent);
235 assert_eq!(s.to_json()["diskChecked"], true);
236 assert_eq!(rs.to_json()["openEdges"], 1);
237
238 fs.set("a.md", "# A\n\nEdited.\n", 2);
239 let s = sync_status(&store, &repo, Some((&fs, root))).unwrap();
240 assert!(!s.convergent);
241 assert_eq!(s.disk.drift.changed, 1);
242
243 store
245 .conn()
246 .execute("UPDATE docs SET file_hash = x'00'", [])
247 .unwrap();
248 assert_eq!(repos_status(&store, &repo, None).unwrap().unconverged, 1);
249 assert_eq!(repos_status(&store, "rp_nope", None).unwrap().slug, "");
250 }
251}