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}