1use async_trait::async_trait;
12use gonzalo_core::{
13 Body, CoreError, DEFAULT_ANCESTOR_CAP, DeletePlan, DeleteResult, Identity, KeyPrefix,
14 MergeOutcome, PurgePlan, PutPlan, PutResult, Record, RecordKey, Result, Revision,
15 decode_segment, merge, now_ms, plan_delete, plan_purge, plan_put, plan_put_raw,
16 reconciled_record, record_components, tombstone_winner, validate_ancestor_cap,
17};
18use rustix::fs::{FlockOperation, flock};
19use std::cell::RefCell;
20use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::rc::Rc;
23
24mod diff;
25pub use diff::{ChangedPaths, changed_paths, head_commit, is_git_repo};
26
27type PutPlanner = fn(Option<&Record>, Record, Option<Revision>, i64, usize) -> PutPlan;
31
32#[derive(Debug, Clone)]
35pub struct PullConflict {
36 pub key: RecordKey,
37 pub local: Box<Record>,
38 pub remote: Box<Record>,
39}
40
41#[derive(Debug, Default)]
43#[must_use = "a PullReport may contain unresolved conflicts that must be handled"]
44pub struct PullReport {
45 pub fast_forwarded: bool,
47 pub advanced: Vec<RecordKey>,
53 pub merged: Vec<RecordKey>,
55 pub conflicts: Vec<PullConflict>,
57}
58
59pub struct GitStore {
60 root: PathBuf,
61 cap: usize,
64}
65
66impl GitStore {
67 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
69 let root = root.into();
70 std::fs::create_dir_all(&root).map_err(|e| CoreError::Backend(e.to_string()))?;
71 match git2::Repository::open(&root) {
72 Ok(_) => {}
73 Err(_) => {
74 git2::Repository::init(&root).map_err(|e| CoreError::Backend(e.to_string()))?;
75 }
76 }
77 Ok(Self {
78 root,
79 cap: DEFAULT_ANCESTOR_CAP,
80 })
81 }
82
83 pub fn with_ancestor_cap(mut self, cap: usize) -> Result<Self> {
86 self.cap = validate_ancestor_cap(cap)?;
87 Ok(self)
88 }
89
90 fn handle(&self) -> Self {
92 Self {
93 root: self.root.clone(),
94 cap: self.cap,
95 }
96 }
97
98 fn put_locked(
104 &self,
105 record: Record,
106 expected: Option<Revision>,
107 plan: PutPlanner,
108 ) -> Result<PutResult> {
109 let _lock = lock_repo(&self.root)?;
110 let key = record.key.clone();
111 let current = self.read(&key)?;
112 match plan(current.as_ref(), record, expected, now_ms(), self.cap) {
116 PutPlan::Write(stored) => {
117 self.write_and_commit(&stored, &format!("put {key}"))?;
118 Ok(PutResult::Committed(stored.revision))
119 }
120 PutPlan::Conflict(conflict) => Ok(PutResult::Conflict(conflict)),
121 PutPlan::NotFound => Err(CoreError::NotFound(key)),
122 PutPlan::Rejected(reason) => Err(CoreError::Invalid(reason.to_string())),
127 }
128 }
129
130 fn write_and_commit(&self, record: &Record, message: &str) -> Result<()> {
133 let rel = rel_path(&record.key);
134 let abs = self.root.join(&rel);
135 if let Some(parent) = abs.parent() {
136 std::fs::create_dir_all(parent).map_err(be)?;
137 }
138 let bytes =
139 serde_json::to_vec_pretty(record).map_err(|e| CoreError::Serde(e.to_string()))?;
140 write_atomic(&abs, &bytes).map_err(be)?;
141 self.commit_file(&rel, message)
142 }
143
144 fn remove_and_commit(&self, key: &RecordKey, message: &str) -> Result<()> {
147 let rel = rel_path(key);
148 match std::fs::remove_file(self.root.join(&rel)) {
149 Ok(()) => {}
150 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
151 Err(e) => return Err(be(e)),
152 }
153 self.commit_removal(&rel, message)
154 }
155
156 fn is_listed(&self, key: &RecordKey) -> bool {
163 match self.read(key) {
164 Ok(Some(rec)) => !rec.is_tombstone(),
165 Ok(None) => false,
166 Err(_) => true,
167 }
168 }
169
170 fn path_for(&self, key: &RecordKey) -> PathBuf {
171 let (ns, col, file) = record_components(key);
172 self.root.join(ns).join(col).join(file)
173 }
174
175 fn read(&self, key: &RecordKey) -> Result<Option<Record>> {
176 let path = self.path_for(key);
177 match std::fs::read(&path) {
178 Ok(bytes) => Ok(Some(
179 serde_json::from_slice(&bytes).map_err(|e| CoreError::Serde(e.to_string()))?,
180 )),
181 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
182 Err(e) => Err(CoreError::Backend(e.to_string())),
183 }
184 }
185
186 fn commit_file(&self, rel: &Path, message: &str) -> Result<()> {
187 let repo =
188 git2::Repository::open(&self.root).map_err(|e| CoreError::Backend(e.to_string()))?;
189 let mut index = repo
190 .index()
191 .map_err(|e| CoreError::Backend(e.to_string()))?;
192 index
193 .add_path(rel)
194 .map_err(|e| CoreError::Backend(e.to_string()))?;
195 index
196 .write()
197 .map_err(|e| CoreError::Backend(e.to_string()))?;
198 let tree_oid = index
199 .write_tree()
200 .map_err(|e| CoreError::Backend(e.to_string()))?;
201 let tree = repo
202 .find_tree(tree_oid)
203 .map_err(|e| CoreError::Backend(e.to_string()))?;
204 let sig = git2::Signature::now("gonzalo", "gonzalo@localhost")
205 .map_err(|e| CoreError::Backend(e.to_string()))?;
206 let parent = repo
207 .head()
208 .ok()
209 .and_then(|h| h.target())
210 .and_then(|oid| repo.find_commit(oid).ok());
211 let parents: Vec<&git2::Commit> = parent.iter().collect();
212 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
213 .map_err(|e| CoreError::Backend(e.to_string()))?;
214 Ok(())
215 }
216
217 fn commit_removal(&self, rel: &Path, message: &str) -> Result<()> {
221 let repo =
222 git2::Repository::open(&self.root).map_err(|e| CoreError::Backend(e.to_string()))?;
223 let mut index = repo
224 .index()
225 .map_err(|e| CoreError::Backend(e.to_string()))?;
226 index
227 .remove_path(rel)
228 .map_err(|e| CoreError::Backend(e.to_string()))?;
229 index
230 .write()
231 .map_err(|e| CoreError::Backend(e.to_string()))?;
232 let tree_oid = index
233 .write_tree()
234 .map_err(|e| CoreError::Backend(e.to_string()))?;
235 let tree = repo
236 .find_tree(tree_oid)
237 .map_err(|e| CoreError::Backend(e.to_string()))?;
238 let sig = git2::Signature::now("gonzalo", "gonzalo@localhost")
239 .map_err(|e| CoreError::Backend(e.to_string()))?;
240 let parent = repo
241 .head()
242 .ok()
243 .and_then(|h| h.target())
244 .and_then(|oid| repo.find_commit(oid).ok());
245 let parents: Vec<&git2::Commit> = parent.iter().collect();
246 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
247 .map_err(|e| CoreError::Backend(e.to_string()))?;
248 Ok(())
249 }
250
251 pub async fn pull(&self, remote: &str, branch: &str) -> Result<PullReport> {
257 let root = self.root.clone();
258 let remote = remote.to_string();
259 let branch = branch.to_string();
260 let cap = self.cap;
261 run_blocking(move || git_pull(&root, &remote, &branch, cap)).await
262 }
263
264 pub async fn push(&self, remote: &str, branch: &str) -> Result<()> {
266 let root = self.root.clone();
267 let remote = remote.to_string();
268 let branch = branch.to_string();
269 run_blocking(move || git_push(&root, &remote, &branch)).await
270 }
271}
272
273fn rel_path(key: &RecordKey) -> PathBuf {
275 let (ns, col, file) = record_components(key);
276 Path::new(&ns).join(&col).join(&file)
277}
278
279fn be<E: std::fmt::Display>(e: E) -> CoreError {
280 CoreError::Backend(e.to_string())
281}
282
283fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
299 use std::io::Write;
300 let tmp = path.with_extension("json.tmp");
301 let mut f = std::fs::File::create(&tmp)?;
302 f.write_all(bytes)?;
303 f.sync_all()?;
304 drop(f);
305 std::fs::rename(&tmp, path)?;
306 if let Some(parent) = path.parent() {
307 fsync_dir(parent)?;
308 }
309 Ok(())
310}
311
312fn fsync_dir(path: &Path) -> std::io::Result<()> {
318 let dir = std::fs::File::open(path)?;
319 match dir.sync_all() {
320 Ok(()) => Ok(()),
321 Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => Ok(()),
322 Err(e) => Err(e),
323 }
324}
325
326fn lock_repo(root: &Path) -> Result<std::fs::File> {
338 let lock_path = root.join(".gonzalo-git.lock");
339 let lock = std::fs::OpenOptions::new()
340 .create(true)
341 .truncate(false)
342 .write(true)
343 .open(&lock_path)
344 .map_err(be)?;
345 flock(&lock, FlockOperation::LockExclusive).map_err(be)?;
346 Ok(lock)
347}
348
349fn git_pull(root: &Path, remote: &str, branch: &str, cap: usize) -> Result<PullReport> {
350 let _lock = lock_repo(root)?;
354 let repo = git2::Repository::open(root).map_err(be)?;
355 let mut rem = repo.find_remote(remote).map_err(be)?;
356 rem.fetch(&[branch], None, None).map_err(be)?;
357 let fetch_head = repo.find_reference("FETCH_HEAD").map_err(be)?;
358 let fetch_commit = repo
359 .reference_to_annotated_commit(&fetch_head)
360 .map_err(be)?;
361 let (analysis, _) = repo.merge_analysis(&[&fetch_commit]).map_err(be)?;
362
363 if analysis.is_up_to_date() {
364 return Ok(PullReport::default());
365 }
366 if analysis.is_fast_forward() {
367 let refname = format!("refs/heads/{branch}");
368 let mut reference = repo.find_reference(&refname).map_err(be)?;
369 reference
370 .set_target(fetch_commit.id(), "fast-forward")
371 .map_err(be)?;
372 repo.set_head(&refname).map_err(be)?;
373 repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
374 .map_err(be)?;
375 return Ok(PullReport {
376 fast_forwarded: true,
377 ..Default::default()
378 });
379 }
380
381 merge_non_ff(&repo, remote, branch, fetch_commit.id(), cap)
382}
383
384fn merge_non_ff(
398 repo: &git2::Repository,
399 remote: &str,
400 branch: &str,
401 remote_oid: git2::Oid,
402 cap: usize,
403) -> Result<PullReport> {
404 let local_oid = repo
405 .head()
406 .map_err(be)?
407 .target()
408 .ok_or_else(|| CoreError::Backend("local HEAD is unborn".into()))?;
409 let local_commit = repo.find_commit(local_oid).map_err(be)?;
410 let remote_commit = repo.find_commit(remote_oid).map_err(be)?;
411 let local_tree = local_commit.tree().map_err(be)?;
412 let remote_tree = remote_commit.tree().map_err(be)?;
413 let base_tree = match repo.merge_base(local_oid, remote_oid) {
416 Ok(base_oid) => Some(repo.find_commit(base_oid).map_err(be)?.tree().map_err(be)?),
417 Err(_) => None,
418 };
419
420 let mut index = repo.index().map_err(be)?;
422 index.read_tree(&local_tree).map_err(be)?;
423
424 let local_changed = changed_paths_set(repo, base_tree.as_ref(), &local_tree)?;
425 let remote_diff = repo
426 .diff_tree_to_tree(base_tree.as_ref(), Some(&remote_tree), None)
427 .map_err(be)?;
428
429 let mut report = PullReport::default();
430 for delta in remote_diff.deltas() {
431 let Some(path) = delta
432 .new_file()
433 .path()
434 .or_else(|| delta.old_file().path())
435 .map(Path::to_path_buf)
436 else {
437 continue;
438 };
439
440 if !local_changed.contains(&path) {
441 if delta.status() == git2::Delta::Deleted {
444 index.remove_path(&path).map_err(be)?;
445 } else if let Some(bytes) = tree_blob(repo, &remote_tree, &path)? {
446 index
447 .add_frombuffer(&blob_entry(&path), &bytes)
448 .map_err(be)?;
449 }
450 continue;
451 }
452
453 let Some(key) = key_from_path(&path) else {
455 continue; };
457 let local_rec = record_at(repo, Some(&local_tree), &path)?;
458 let remote_rec = record_at(repo, Some(&remote_tree), &path)?;
459 match (local_rec, remote_rec) {
460 (Some(local), Some(remote)) if local.revision == remote.revision => {}
463 (Some(local), Some(remote)) if remote.ancestors.contains(&local.revision) => {
470 stage_record(&mut index, &path, &remote)?;
471 report.advanced.push(key);
472 }
473 (Some(local), Some(remote)) if local.ancestors.contains(&remote.revision) => {}
476 (Some(local), Some(remote)) if local.is_tombstone() && remote.is_tombstone() => {
482 let winner = tombstone_winner(&local, &remote, cap);
483 stage_record(&mut index, &path, &winner)?;
484 report.merged.push(key);
485 }
486 (Some(local), Some(remote)) if local.is_tombstone() || remote.is_tombstone() => {
489 report.conflicts.push(PullConflict {
490 key,
491 local: Box::new(local),
492 remote: Box::new(remote),
493 });
494 }
495 (Some(local), Some(remote)) if local.body != remote.body => {
496 let base_body = record_at(repo, base_tree.as_ref(), &path)?
497 .map(|r| r.body)
498 .unwrap_or(Body::Inline(Vec::new()));
499 match merge(
500 local.kind.merge_class(),
501 &base_body,
502 &local.body,
503 &remote.body,
504 ) {
505 MergeOutcome::Merged(body) => {
506 let merged = merged_record(&local, &remote, body, cap);
507 stage_record(&mut index, &path, &merged)?;
508 report.merged.push(key);
509 }
510 MergeOutcome::NeedsResolution => {
511 report.conflicts.push(PullConflict {
513 key,
514 local: Box::new(local),
515 remote: Box::new(remote),
516 });
517 }
518 }
519 }
520 (Some(_local), None) => {}
523 (None, Some(remote)) => {
527 stage_record(&mut index, &path, &remote)?;
528 }
529 _ => {}
531 }
532 }
533
534 let tree_oid = index.write_tree_to(repo).map_err(be)?;
536 let tree = repo.find_tree(tree_oid).map_err(be)?;
537 repo.checkout_tree(
544 tree.as_object(),
545 Some(git2::build::CheckoutBuilder::new().force()),
546 )
547 .map_err(be)?;
548 let sig = git2::Signature::now("gonzalo", "gonzalo@localhost").map_err(be)?;
549 let refname = format!("refs/heads/{branch}");
550 repo.commit(
551 Some(&refname),
552 &sig,
553 &sig,
554 &format!("merge {remote}/{branch}"),
555 &tree,
556 &[&local_commit, &remote_commit],
557 )
558 .map_err(be)?;
559 repo.set_head(&refname).map_err(be)?;
560 Ok(report)
564}
565
566fn stage_record(index: &mut git2::Index, path: &Path, record: &Record) -> Result<()> {
568 let bytes = serde_json::to_vec_pretty(record).map_err(|e| CoreError::Serde(e.to_string()))?;
569 index.add_frombuffer(&blob_entry(path), &bytes).map_err(be)
570}
571
572fn changed_paths_set(
574 repo: &git2::Repository,
575 base: Option<&git2::Tree>,
576 tree: &git2::Tree,
577) -> Result<HashSet<PathBuf>> {
578 let diff = repo.diff_tree_to_tree(base, Some(tree), None).map_err(be)?;
579 let mut set = HashSet::new();
580 for delta in diff.deltas() {
581 if let Some(path) = delta.new_file().path().or_else(|| delta.old_file().path()) {
582 set.insert(path.to_path_buf());
583 }
584 }
585 Ok(set)
586}
587
588fn record_at(
591 repo: &git2::Repository,
592 tree: Option<&git2::Tree>,
593 path: &Path,
594) -> Result<Option<Record>> {
595 let Some(tree) = tree else {
596 return Ok(None);
597 };
598 match tree.get_path(path) {
599 Ok(entry) => {
600 let obj = entry.to_object(repo).map_err(be)?;
601 let blob = obj
602 .as_blob()
603 .ok_or_else(|| CoreError::Backend("record path is not a blob".into()))?;
604 let rec = serde_json::from_slice(blob.content())
605 .map_err(|e| CoreError::Serde(e.to_string()))?;
606 Ok(Some(rec))
607 }
608 Err(_) => Ok(None),
609 }
610}
611
612fn tree_blob(repo: &git2::Repository, tree: &git2::Tree, path: &Path) -> Result<Option<Vec<u8>>> {
614 match tree.get_path(path) {
615 Ok(entry) => {
616 let obj = entry.to_object(repo).map_err(be)?;
617 Ok(obj.as_blob().map(|b| b.content().to_vec()))
618 }
619 Err(_) => Ok(None),
620 }
621}
622
623fn key_from_path(path: &Path) -> Option<RecordKey> {
625 let comps: Vec<String> = path
626 .components()
627 .map(|c| c.as_os_str().to_string_lossy().into_owned())
628 .collect();
629 if comps.len() != 3 {
630 return None;
631 }
632 let id = comps[2].strip_suffix(".json")?;
633 Some(RecordKey::new(
634 decode_segment(&comps[0]),
635 decode_segment(&comps[1]),
636 decode_segment(id),
637 ))
638}
639
640fn blob_entry(path: &Path) -> git2::IndexEntry {
643 git2::IndexEntry {
644 ctime: git2::IndexTime::new(0, 0),
645 mtime: git2::IndexTime::new(0, 0),
646 dev: 0,
647 ino: 0,
648 mode: 0o100644,
649 uid: 0,
650 gid: 0,
651 file_size: 0,
652 id: git2::Oid::zero(),
653 flags: 0,
654 flags_extended: 0,
655 path: path.to_string_lossy().into_owned().into_bytes(),
656 }
657}
658
659fn merged_record(local: &Record, remote: &Record, body: Body, cap: usize) -> Record {
663 reconciled_record(
664 local,
665 remote,
666 body,
667 Identity::new("gonzalo-merge"),
668 "git-pull",
669 cap,
670 )
671}
672
673fn git_push(root: &Path, remote: &str, branch: &str) -> Result<()> {
674 let repo = git2::Repository::open(root).map_err(be)?;
675 let mut rem = repo.find_remote(remote).map_err(be)?;
676 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
677
678 let rejected: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
684 let mut callbacks = git2::RemoteCallbacks::new();
685 {
686 let rejected = Rc::clone(&rejected);
687 callbacks.push_update_reference(move |refname, status| {
688 if let Some(msg) = status {
689 rejected.borrow_mut().push(format!("{refname}: {msg}"));
690 }
691 Ok(())
692 });
693 }
694 let mut opts = git2::PushOptions::new();
695 opts.remote_callbacks(callbacks);
696 rem.push(&[refspec.as_str()], Some(&mut opts)).map_err(be)?;
697 drop(opts); let rejected = rejected.borrow();
700 if !rejected.is_empty() {
701 return Err(CoreError::Backend(format!(
702 "push rejected by remote '{remote}': {}",
703 rejected.join(", ")
704 )));
705 }
706 Ok(())
707}
708
709async fn run_blocking<F, T>(f: F) -> Result<T>
710where
711 F: FnOnce() -> Result<T> + Send + 'static,
712 T: Send + 'static,
713{
714 tokio::task::spawn_blocking(f)
715 .await
716 .map_err(|e| CoreError::Backend(e.to_string()))?
717}
718
719#[async_trait]
720impl gonzalo_core::Store for GitStore {
721 async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
722 let store = self.handle();
724 let key = key.clone();
725 run_blocking(move || Ok(store.read(&key)?.filter(|rec| !rec.is_tombstone()))).await
726 }
727
728 async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
729 let store = self.handle();
730 run_blocking(move || store.put_locked(record, expected, plan_put)).await
731 }
732
733 async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
734 let store = self.handle();
738 run_blocking(move || store.put_locked(record, expected, plan_put_raw)).await
739 }
740
741 async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
742 let store = self.handle();
745 let prefix = prefix.clone();
746 run_blocking(move || {
747 let mut keys = Vec::new();
748 collect_keys(&store.root, &prefix, &mut keys)?;
749 let mut out = Vec::with_capacity(keys.len());
750 for key in keys {
751 if store.is_listed(&key) {
752 out.push(key);
753 }
754 }
755 Ok(out)
756 })
757 .await
758 }
759
760 async fn delete_as(
762 &self,
763 key: &RecordKey,
764 expected: Option<Revision>,
765 author: Option<Identity>,
766 ) -> Result<DeleteResult> {
767 let store = self.handle();
768 let key = key.clone();
769 run_blocking(move || {
770 let _lock = lock_repo(&store.root)?;
774 let current = store.read(&key)?;
775 match plan_delete(
780 current.as_ref(),
781 expected,
782 now_ms(),
783 store.cap,
784 author.as_ref(),
785 ) {
786 DeletePlan::Write(tombstone) => {
787 store.write_and_commit(&tombstone, &format!("delete {key}"))?;
788 Ok(DeleteResult::Deleted)
789 }
790 DeletePlan::Noop => Ok(DeleteResult::Deleted),
791 DeletePlan::Conflict(conflict) => Ok(DeleteResult::Conflict(conflict)),
792 }
793 })
794 .await
795 }
796
797 async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
798 let store = self.handle();
800 let key = key.clone();
801 run_blocking(move || store.read(&key)).await
802 }
803
804 async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
805 let store = self.handle();
808 let prefix = prefix.clone();
809 run_blocking(move || {
810 let mut out = Vec::new();
811 collect_keys(&store.root, &prefix, &mut out)?;
812 Ok(out)
813 })
814 .await
815 }
816
817 async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
818 let store = self.handle();
819 let key = key.clone();
820 run_blocking(move || {
821 let _lock = lock_repo(&store.root)?;
825 let current = store.read(&key)?;
826 match plan_purge(current.as_ref(), &expected) {
827 PurgePlan::Remove => {
828 store.remove_and_commit(&key, &format!("purge {key}"))?;
829 Ok(DeleteResult::Deleted)
830 }
831 PurgePlan::Noop => Ok(DeleteResult::Deleted),
832 PurgePlan::Conflict(conflict) => Ok(DeleteResult::Conflict(conflict)),
833 }
834 })
835 .await
836 }
837}
838
839fn collect_keys(root: &Path, prefix: &KeyPrefix, out: &mut Vec<RecordKey>) -> Result<()> {
840 let namespaces = match std::fs::read_dir(root) {
841 Ok(rd) => rd,
842 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
843 Err(e) => return Err(CoreError::Backend(e.to_string())),
844 };
845 for ns in namespaces {
846 let ns = ns.map_err(|e| CoreError::Backend(e.to_string()))?;
847 let ns_name = ns.file_name().to_string_lossy().to_string();
848 if ns_name == ".git" || !ns.path().is_dir() {
849 continue;
850 }
851 for col in std::fs::read_dir(ns.path()).map_err(|e| CoreError::Backend(e.to_string()))? {
852 let col = col.map_err(|e| CoreError::Backend(e.to_string()))?;
853 if !col.path().is_dir() {
854 continue;
855 }
856 let col_name = col.file_name().to_string_lossy().to_string();
857 for f in std::fs::read_dir(col.path()).map_err(|e| CoreError::Backend(e.to_string()))? {
858 let f = f.map_err(|e| CoreError::Backend(e.to_string()))?;
859 let fname = f.file_name().to_string_lossy().to_string();
860 if let Some(id) = fname.strip_suffix(".json") {
861 let key = RecordKey::new(
864 decode_segment(&ns_name),
865 decode_segment(&col_name),
866 decode_segment(id),
867 );
868 if prefix.matches(&key) {
869 out.push(key);
870 }
871 }
872 }
873 }
874 }
875 Ok(())
876}