git_bug/replica/mod.rs
1// git-bug-rs - A rust library for interfacing with git-bug repositories
2//
3// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
4// SPDX-License-Identifier: GPL-3.0-or-later
5//
6// This file is part of git-bug-rs/git-gub.
7//
8// You should have received a copy of the License along with this program.
9// If not, see <https://www.gnu.org/licenses/agpl.txt>.
10
11//! Handling of [`Replicas`][`Replica`].
12
13use std::{fs, path::PathBuf};
14
15use entity_iter::{EntityIdIter, EntityIter};
16use gix::{Repository, ThreadSafeRepository};
17use redb::Database;
18
19use self::entity::{
20 Entity, EntityRead,
21 id::{Id, entity_id::EntityId},
22 snapshot::Snapshot,
23};
24use crate::query::{Query, queryable::Queryable};
25
26pub mod cache;
27pub mod entity;
28mod entity_iter;
29
30/// A persistent storage for `git-bug` data on disk.
31///
32/// For now this is always a git repository.
33#[derive(Debug)]
34pub struct Replica {
35 db: Database,
36 repo: Repository,
37}
38
39impl Replica {
40 /// Open a [`Replica`] from a path to a git repository.
41 ///
42 /// This path is extended with `.git`, if the repository is non-bare and the
43 /// path points into it.
44 ///
45 /// # Errors
46 /// If opening the repository fails.
47 pub fn from_path(path: impl Into<PathBuf>) -> Result<Self, open::Error> {
48 let path = path.into();
49
50 let repo = ThreadSafeRepository::open(path.clone())
51 .map_err(|err| open::Error::RepoOpen {
52 path,
53 error: Box::new(err),
54 })?
55 .to_thread_local();
56
57 // TODO(@bpeetz): Use this to improve the cache generation speed <2025-05-26>
58 // repo.object_cache_size(Some(usize::MAX));
59
60 let db = {
61 let db_dir = repo.path().join("git-bug-rs");
62 let db_path = db_dir.join("cache");
63
64 fs::create_dir_all(&db_dir)
65 .map_err(|err| open::Error::CacheDirCreate { err, path: db_dir })?;
66
67 Database::create(&db_path)
68 .map_err(|err| open::Error::CacheDbOpen { err, path: db_path })?
69 };
70
71 Ok(Self { db, repo })
72 }
73
74 /// Access this Replica's underlying repository.
75 pub fn repo(&self) -> &Repository {
76 &self.repo
77 }
78
79 /// Access this Replica's cache database.
80 pub fn db(&self) -> &Database {
81 &self.db
82 }
83
84 /// Return an iterator over all the [`Entities`][`Entity`]
85 /// `E` stored in this replica.
86 ///
87 /// # Errors
88 /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
89 /// ## The iterator will error
90 /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
91 /// - If the `git-bug` data does not conform to the JSON schema.
92 pub fn get_all<E: Entity + EntityRead>(
93 &self,
94 ) -> Result<
95 impl Iterator<Item = Result<Result<E, entity::read::Error<E>>, get::Error>>,
96 get::Error,
97 > {
98 EntityIter::new(self)
99 }
100
101 /// Return an iterator over all the [`Entities`][`Entity`]
102 /// `E` stored in this replica that match the `query`.
103 ///
104 /// # Note
105 /// This calls [`Query::matches`] under the hood, and as such will produce
106 /// snapshots for all Entities.
107 /// In the future, this might be improved.
108 ///
109 /// # Errors
110 /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
111 /// ## The iterator will error
112 /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
113 /// - If the `git-bug` data does not conform to the JSON schema.
114 pub fn get_all_with_query<E>(
115 &self,
116 query: &Query<Snapshot<E>>,
117 ) -> Result<
118 impl Iterator<Item = Result<Result<E, entity::read::Error<E>>, get::Error>>,
119 get::Error,
120 >
121 where
122 Snapshot<E>: Queryable,
123 E: Entity + EntityRead,
124 {
125 Ok(self.get_all::<E>()?.filter(move |maybe_entity| {
126 if let Ok(Ok(entity)) = maybe_entity {
127 return query.matches(&entity.snapshot());
128 }
129
130 // We do not silently hide errors
131 true
132 }))
133 }
134
135 /// Return an iterator over all the
136 /// [`EntityIds`][`EntityId`] for the [`Entity`] `E` stored in this replica.
137 ///
138 /// # Note
139 /// This function does not have a `_with_query` variant, as such a variant
140 /// would have to call [`Query::matches`] under the hood, and as such will create
141 /// snapshots for all Entities.
142 ///
143 /// If you only need to [`Ids`][`EntityId`] of matched [`Entities`][`Entity`] use the following
144 /// instead:
145 /// ```no_run
146 /// use git_bug::{
147 /// entities::issue::Issue,
148 /// query::{ParseMode, Query},
149 /// replica::{
150 /// Replica,
151 /// entity::{Entity, snapshot::Snapshot},
152 /// },
153 /// };
154 ///
155 /// # fn doc_test(replica: &Replica) -> Result<(), Box<dyn std::error::Error>> {
156 /// let query: Query<Snapshot<Issue>> =
157 /// Query::from_continuous_str(replica, "title:test", ParseMode::Strict)?;
158 ///
159 /// for maybe_entity in replica.get_all_with_query(&query)? {
160 /// let entity = maybe_entity??;
161 /// let id = entity.id();
162 /// println!("Found id: {id}");
163 /// }
164 /// # Ok(())
165 /// # }
166 /// ```
167 ///
168 /// # Errors
169 /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
170 /// ## Iterator Errors
171 /// - If one of the references could not be decoded.
172 pub fn get_all_ids<E: Entity + EntityRead>(
173 &self,
174 ) -> Result<impl Iterator<Item = Result<EntityId<E>, get::Error>>, get::Error> {
175 EntityIdIter::new(self.repo())
176 }
177
178 /// Get an [`Entity`] by [`EntityId`].
179 ///
180 /// # Note
181 /// This is useful if you have already obtained an [`EntityId`] via
182 /// functions like [`Replica::get_all_ids`].
183 /// If you only have an [`Id`], use the [`Replica::get_by_id`] function
184 /// instead.
185 ///
186 /// # Errors
187 /// If the entity read operation (i.e., [`EntityRead::read`] fails.)
188 pub fn get<E: Entity + EntityRead>(
189 &self,
190 id: EntityId<E>,
191 ) -> Result<E, entity::read::Error<E>> {
192 E::read(self, id)
193 }
194
195 /// Get an [`Entity`] by it's [`Id`].
196 ///
197 /// # Note
198 /// This will search for the [`Id`] first and as such should not be used if
199 /// you have already obtained an [`EntityId`]. If your [`Id`] is not
200 /// found it will return an appropriate error.
201 ///
202 /// # Errors
203 /// If the entity read operation (i.e., [`EntityRead::read`] fails.)
204 pub fn get_by_id<E: Entity + EntityRead>(
205 &self,
206 id: Id,
207 ) -> Result<Result<E, entity::read::Error<E>>, get_by_id::Error> {
208 let Some(entity_id) = self
209 .get_all_ids()
210 .map_err(get_by_id::Error::GetError)?
211 .flat_map(IntoIterator::into_iter)
212 .find(|found_id| found_id.as_id() == id)
213 else {
214 return Err(get_by_id::Error::IdNotFound(id));
215 };
216
217 Ok(E::read(self, entity_id))
218 }
219
220 /// Convenience function, that checks whether `git-bug` data for an
221 /// [`Entity`] has been stored in this replica.
222 ///
223 /// # Errors
224 /// - If iterating over the git references fails (e.g., because the underlying repository was
225 /// never initialized.)
226 pub fn contains<E: Entity + EntityRead>(&self) -> Result<bool, get::Error> {
227 Ok(self.get_all_ids::<E>()?.count() > 0)
228 }
229}
230
231#[allow(missing_docs)]
232pub mod get_by_id {
233 use super::get;
234
235 #[derive(Debug, thiserror::Error)]
236 pub enum Error {
237 #[error("Id not found for Entity: {0}")]
238 IdNotFound(super::entity::id::Id),
239
240 #[error("Constructing the underyling get iterator failed: {0}")]
241 GetError(get::Error),
242 }
243}
244
245#[allow(missing_docs)]
246pub mod get {
247 #[derive(Debug, thiserror::Error)]
248 pub enum Error {
249 #[error("Failed to open the packed buffer: {0}")]
250 PackedBufferOpen(#[from] gix::refs::packed::buffer::open::Error),
251
252 #[error("Failed to get an refererenc from the refs iter for replica: {0}")]
253 RefGet(String),
254
255 #[error("Failed to iterate over refs for namespace {nasp}: {error}")]
256 RefsIterPrefixed {
257 nasp: &'static str,
258 error: gix::reference::iter::init::Error,
259 },
260
261 #[error("Failed to read reference: {0}")]
262 InvalidRef(#[from] gix::refs::file::iter::loose_then_packed::Error),
263
264 #[error("Could not parse this Entity id ('{id}') as hex string")]
265 ParseAsHex {
266 id: String,
267 error: super::entity::id::decode::Error,
268 },
269 }
270}
271
272#[allow(missing_docs)]
273pub mod open {
274 use std::path::PathBuf;
275
276 #[derive(Debug, thiserror::Error)]
277 pub enum Error {
278 #[error("Failed to open the replica at {path}, because: {error}")]
279 RepoOpen {
280 path: PathBuf,
281 error: Box<gix::open::Error>,
282 },
283
284 #[error("Failed to open the cache database at {path}, because: {err} ")]
285 CacheDbOpen {
286 err: redb::DatabaseError,
287 path: PathBuf,
288 },
289
290 #[error("Failed to create the cache directory at {path}, because: {err} ")]
291 CacheDirCreate { err: std::io::Error, path: PathBuf },
292 }
293}