git-bug 0.2.4

A rust library for interfacing with git-bug repositories
Documentation
// git-bug-rs - A rust library for interfacing with git-bug repositories
//
// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of git-bug-rs/git-gub.
//
// You should have received a copy of the License along with this program.
// If not, see <https://www.gnu.org/licenses/agpl.txt>.

//! Handling of [`Replicas`][`Replica`].

use std::{fs, path::PathBuf};

use entity_iter::{EntityIdIter, EntityIter};
use gix::{Repository, ThreadSafeRepository};
use redb::Database;

use self::entity::{
    Entity, EntityRead,
    id::{Id, entity_id::EntityId},
    snapshot::Snapshot,
};
use crate::query::{Query, queryable::Queryable};

pub mod cache;
pub mod entity;
mod entity_iter;

/// A persistent storage for `git-bug` data on disk.
///
/// For now this is always a git repository.
#[derive(Debug)]
pub struct Replica {
    db: Database,
    repo: Repository,
}

impl Replica {
    /// Open a [`Replica`] from a path to a git repository.
    ///
    /// This path is extended with `.git`, if the repository is non-bare and the
    /// path points into it.
    ///
    /// # Errors
    /// If opening the repository fails.
    pub fn from_path(path: impl Into<PathBuf>) -> Result<Self, open::Error> {
        let path = path.into();

        let repo = ThreadSafeRepository::open(path.clone())
            .map_err(|err| open::Error::RepoOpen {
                path,
                error: Box::new(err),
            })?
            .to_thread_local();

        // TODO(@bpeetz): Use this to improve the cache generation speed  <2025-05-26>
        // repo.object_cache_size(Some(usize::MAX));

        let db = {
            let db_dir = repo.path().join("git-bug-rs");
            let db_path = db_dir.join("cache");

            fs::create_dir_all(&db_dir)
                .map_err(|err| open::Error::CacheDirCreate { err, path: db_dir })?;

            Database::create(&db_path)
                .map_err(|err| open::Error::CacheDbOpen { err, path: db_path })?
        };

        Ok(Self { db, repo })
    }

    /// Access this Replica's underlying repository.
    pub fn repo(&self) -> &Repository {
        &self.repo
    }

    /// Access this Replica's cache database.
    pub fn db(&self) -> &Database {
        &self.db
    }

    /// Return an iterator over all the [`Entities`][`Entity`]
    /// `E` stored in this replica.
    ///
    /// # Errors
    /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
    /// ## The iterator will error
    /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
    /// - If the `git-bug` data does not conform to the JSON schema.
    pub fn get_all<E: Entity + EntityRead>(
        &self,
    ) -> Result<
        impl Iterator<Item = Result<Result<E, entity::read::Error<E>>, get::Error>>,
        get::Error,
    > {
        EntityIter::new(self)
    }

    /// Return an iterator over all the [`Entities`][`Entity`]
    /// `E` stored in this replica that match the `query`.
    ///
    /// # Note
    /// This calls [`Query::matches`] under the hood, and as such will produce
    /// snapshots for all Entities.
    /// In the future, this might be improved.
    ///
    /// # Errors
    /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
    /// ## The iterator will error
    /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
    /// - If the `git-bug` data does not conform to the JSON schema.
    pub fn get_all_with_query<E>(
        &self,
        query: &Query<Snapshot<E>>,
    ) -> Result<
        impl Iterator<Item = Result<Result<E, entity::read::Error<E>>, get::Error>>,
        get::Error,
    >
    where
        Snapshot<E>: Queryable,
        E: Entity + EntityRead,
    {
        Ok(self.get_all::<E>()?.filter(move |maybe_entity| {
            if let Ok(Ok(entity)) = maybe_entity {
                return query.matches(&entity.snapshot());
            }

            // We do not silently hide errors
            true
        }))
    }

    /// Return an iterator over all the
    /// [`EntityIds`][`EntityId`] for the [`Entity`] `E` stored in this replica.
    ///
    /// # Note
    /// This function does not have a `_with_query` variant, as such a variant
    /// would have to call [`Query::matches`] under the hood, and as such will create
    /// snapshots for all Entities.
    ///
    /// If you only need to [`Ids`][`EntityId`] of matched [`Entities`][`Entity`] use the following
    /// instead:
    /// ```no_run
    /// use git_bug::{
    ///     entities::issue::Issue,
    ///     query::{ParseMode, Query},
    ///     replica::{
    ///         Replica,
    ///         entity::{Entity, snapshot::Snapshot},
    ///     },
    /// };
    ///
    /// # fn doc_test(replica: &Replica) -> Result<(), Box<dyn std::error::Error>> {
    /// let query: Query<Snapshot<Issue>> =
    ///     Query::from_continuous_str(replica, "title:test", ParseMode::Strict)?;
    ///
    /// for maybe_entity in replica.get_all_with_query(&query)? {
    ///     let entity = maybe_entity??;
    ///     let id = entity.id();
    ///     println!("Found id: {id}");
    /// }
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// # Errors
    /// - If the repository does not contain `git-bug` data (i.e., it was not initialized)
    /// ## Iterator Errors
    /// - If one of the references could not be decoded.
    pub fn get_all_ids<E: Entity + EntityRead>(
        &self,
    ) -> Result<impl Iterator<Item = Result<EntityId<E>, get::Error>>, get::Error> {
        EntityIdIter::new(self.repo())
    }

    /// Get an [`Entity`] by [`EntityId`].
    ///
    /// # Note
    /// This is useful if you have already obtained an [`EntityId`] via
    /// functions like [`Replica::get_all_ids`].
    /// If you only have an [`Id`], use the [`Replica::get_by_id`] function
    /// instead.
    ///
    /// # Errors
    /// If the entity read operation (i.e., [`EntityRead::read`] fails.)
    pub fn get<E: Entity + EntityRead>(
        &self,
        id: EntityId<E>,
    ) -> Result<E, entity::read::Error<E>> {
        E::read(self, id)
    }

    /// Get an [`Entity`] by it's [`Id`].
    ///
    /// # Note
    /// This will search for the [`Id`] first and as such should not be used if
    /// you have already obtained an [`EntityId`]. If your [`Id`] is not
    /// found it will return an appropriate error.
    ///
    /// # Errors
    /// If the entity read operation (i.e., [`EntityRead::read`] fails.)
    pub fn get_by_id<E: Entity + EntityRead>(
        &self,
        id: Id,
    ) -> Result<Result<E, entity::read::Error<E>>, get_by_id::Error> {
        let Some(entity_id) = self
            .get_all_ids()
            .map_err(get_by_id::Error::GetError)?
            .flat_map(IntoIterator::into_iter)
            .find(|found_id| found_id.as_id() == id)
        else {
            return Err(get_by_id::Error::IdNotFound(id));
        };

        Ok(E::read(self, entity_id))
    }

    /// Convenience function, that checks whether `git-bug` data for an
    /// [`Entity`] has been stored in this replica.
    ///
    /// # Errors
    /// - If iterating over the git references fails (e.g., because the underlying repository was
    ///   never initialized.)
    pub fn contains<E: Entity + EntityRead>(&self) -> Result<bool, get::Error> {
        Ok(self.get_all_ids::<E>()?.count() > 0)
    }
}

#[allow(missing_docs)]
pub mod get_by_id {
    use super::get;

    #[derive(Debug, thiserror::Error)]
    pub enum Error {
        #[error("Id not found for Entity: {0}")]
        IdNotFound(super::entity::id::Id),

        #[error("Constructing the underyling get iterator failed: {0}")]
        GetError(get::Error),
    }
}

#[allow(missing_docs)]
pub mod get {
    #[derive(Debug, thiserror::Error)]
    pub enum Error {
        #[error("Failed to open the packed buffer: {0}")]
        PackedBufferOpen(#[from] gix::refs::packed::buffer::open::Error),

        #[error("Failed to get an refererenc from the refs iter for replica: {0}")]
        RefGet(String),

        #[error("Failed to iterate over refs for namespace {nasp}: {error}")]
        RefsIterPrefixed {
            nasp: &'static str,
            error: gix::reference::iter::init::Error,
        },

        #[error("Failed to read reference: {0}")]
        InvalidRef(#[from] gix::refs::file::iter::loose_then_packed::Error),

        #[error("Could not parse this Entity id ('{id}') as hex string")]
        ParseAsHex {
            id: String,
            error: super::entity::id::decode::Error,
        },
    }
}

#[allow(missing_docs)]
pub mod open {
    use std::path::PathBuf;

    #[derive(Debug, thiserror::Error)]
    pub enum Error {
        #[error("Failed to open the replica at {path}, because: {error}")]
        RepoOpen {
            path: PathBuf,
            error: Box<gix::open::Error>,
        },

        #[error("Failed to open the cache database at {path}, because: {err} ")]
        CacheDbOpen {
            err: redb::DatabaseError,
            path: PathBuf,
        },

        #[error("Failed to create the cache directory at {path}, because: {err} ")]
        CacheDirCreate { err: std::io::Error, path: PathBuf },
    }
}