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>.

//! Interface code, that can Read the legacy storage format of
//! [`Identities`][`Identity`].

use std::str::FromStr;

use simd_json::{
    base::ValueTryAsMutObject,
    derived::{ValueTryAsScalar, ValueTryIntoObject, ValueTryIntoString},
    owned,
};
use url::Url;

use super::Identity;
use crate::replica::{
    Replica,
    entity::{EntityRead, id::entity_id::EntityId, nonce::Nonce},
};

/// The stored JSON data in the legacy identity.
///
/// Git-bug currently stores each change to an identity, by creating the
/// resulting “version” of it, and storing it. So to get the full history of an
/// identity, you would need to calculate the changes in each “version” along
/// the way.
#[derive(Debug)]
pub struct VersionEntry {
    /// Additional field to version the data
    pub version: u64,

    /// The lamport times of the other entities at which this version becomes
    /// effective
    // Use a vec, to preserve order.
    pub times: Vec<(String, u64)>,

    /// The UNIX time stamp, at which this version was created.
    pub unix_time: u64,

    /// A set of arbitrary keys and values to store metadata about a version or
    /// about an Identity in general.
    pub metadata: Option<Vec<(String, String)>>,

    /// The name at this version
    pub name: Option<String>,

    /// As defined in git or from a bridge when importing the identity
    pub email: Option<String>,

    /// Sourced from a bridge when importing the identity
    pub login: Option<String>,

    /// The avatar url at this version
    pub avatar_url: Option<Url>,

    // #[serde(serialize_if = Option::is_some)]
    // /// The set of keys valid at that time, from this version onward, until they get removed
    // /// in a new version. This allows to have multiple key for the same identity (e.g. one per
    // /// device) as well as revoke key.
    // pub_keys: Vec<Key>,
    /// Mandatory random bytes to ensure a better randomness of the data of the
    /// first version of an identity, used to later generate the ID
    ///
    /// It has no functional purpose and should be ignored.
    pub(super) nonce: Nonce,
}

impl Identity {
    /// Fetch an [`Identity`][`super::Identity`] from it's legacy encoding from
    /// a git repository and decode it.
    ///
    /// The legacy encoding is used by `git-bug`.
    /// This is only useful, if you need fine grained access to the concrete
    /// data. If you simply want to access an [`Identity`], use
    /// [`Identity::read`] instead.
    ///
    /// # Errors
    /// If the associated git operations fail.
    // Only expects.
    #[allow(clippy::missing_panics_doc)]
    #[allow(clippy::too_many_lines)]
    pub fn read_legacy(
        replica: &Replica,
        id: EntityId<Self>,
    ) -> Result<Vec<VersionEntry>, read::Error> {
        const VERSION_ENTRY_NAME: &str = "version";

        let root_id = Identity::last_git_commit(replica, id)?;
        let bfs_order = Identity::breadth_first_search(replica.repo(), root_id)?;

        if bfs_order.is_empty() {
            return Err(read::Error::Empty);
        }

        let mut version_entries = vec![];
        let mut is_first_commit = true;
        for commit in bfs_order.into_iter().rev() {
            // Verify DAG structure has a single chronological root, so only the root
            // can have no parents. Said otherwise, the DAG needs to have exactly
            // one leaf.
            if !is_first_commit && commit.parent_ids().count() == 0 {
                return Err(read::Error::MultipleLeafs);
            }

            // TODO(@bpeetz): Git-bug spends a lot of lines of code on correcting the
            // sorting between entity operations in the normal Entity::Read, but
            // here git-bug just trusts it? <2025-04-20>

            {
                let tree = commit.tree()?;
                // TODO(@bpeetz): This format also does not fail early on a version mismatch,
                // but instead relies on the fact that the JSON representation
                // stayed the same for each version. I'm not a fan. <2025-04-20>

                for entry in tree.iter() {
                    let entry = entry.map_err(|err| {
                        // Need to assert, the error type
                        // (i.e., comp time only)
                        #[allow(clippy::unit_cmp)]
                        {
                            // Check that the error is really useless
                            // (we do not enable, the fancy error feature).
                            assert_eq!(err.inner, ());
                        }

                        read::Error::FailedTreeEntryDecode()
                    })?;
                    let mut object = if entry.filename() == VERSION_ENTRY_NAME {
                        entry.object().map_err(|err| read::Error::MissingObject {
                            id: id.as_id(),
                            error: err,
                        })?
                    } else {
                        return Err(read::Error::UnknownEntityName(entry.filename().to_owned()));
                    };

                    let entry: VersionEntry = {
                        use crate::replica::entity::operation::operation_data::get;

                        let mut value =
                            simd_json::to_owned_value(&mut object.data).map_err(|err| {
                                read::Error::InvalidJsonVersionEntry {
                                    got: String::from_utf8_lossy(&(object.data.clone()))
                                        .to_string(),
                                    error: err,
                                }
                            })?;
                        let object = value.try_as_object_mut()?;

                        VersionEntry {
                            version: get! {object, "version", try_as_u64, read::Error},
                            times: get! {
                            @map[preserve-order] object,
                            "times", try_as_u64, read::Error},
                            unix_time: get! {object, "unix_time", try_as_u64, read::Error},
                            metadata: get! {@option[next] object, "metadata", |some: owned::Value| {
                                let object = some.try_into_object()?;

                                Ok::<_, read::Error>(
                                    Some(get! {@mk_map object, try_into_string, read::Error}))
                            }, read::Error},
                            name: get! {@option object, "name", try_into_string, read::Error},
                            email: get! {@option object, "email", try_into_string, read::Error},
                            login: get! {@option object, "login", try_into_string, read::Error},
                            avatar_url:
                                get! {@option object, "avatar_url", try_into_string, read::Error}
                                    .map(|str| Url::from_str(&str))
                                    .transpose()?,
                            nonce: Nonce::try_from(
                                get! {object, "nonce", try_into_string, read::Error},
                            )?,
                        }
                    };

                    if entry.version != 2 {
                        return Err(read::Error::VersionMismatch {
                            got: entry.version,
                            expected: 2,
                        });
                    }
                    version_entries.push(entry);
                }
            }

            is_first_commit = false;
        }
        Ok(version_entries)
    }
}

#[allow(missing_docs)]
pub mod read {

    #[derive(Debug, thiserror::Error)]
    /// The error returned by [`Identity::read_legacy`][`super::Identity::read_legacy`].
    pub enum Error {
        #[error(transparent)]
        ReferenceResolve(#[from] crate::replica::entity::find::Error),
        #[error(transparent)]
        BreadthFirstSearch(#[from] crate::replica::entity::bfs::Error),

        #[error("This identity has no recorded legacy versions.")]
        Empty,

        #[error("Multiple leafs in the identity version DAG")]
        MultipleLeafs,

        #[error(
            "Expected to find an tree with the legacy identity commit, but found none. Error: {0}"
        )]
        MissingTree(#[from] gix::object::commit::Error),

        #[error("Failed to decode an entry from the legacy identity tree to access.")]
        FailedTreeEntryDecode(),

        #[error("Found unknown entity {0}, while decoding legacy identity tree.")]
        UnknownEntityName(gix::bstr::BString),

        #[error(
            "Failed to find the object blob for the legacy identity version tree entry of {id}, \
             because: {error}"
        )]
        MissingObject {
            id: crate::replica::entity::id::Id,
            error: gix::objs::find::existing::Error,
        },

        #[error("Failed to parse the legacy idenity version entry json ({got}), because: {error}")]
        InvalidJsonVersionEntry {
            got: String,
            error: simd_json::Error,
        },
        #[error("Failed to get the expected json field: {field}")]
        MissingJsonField { field: &'static str },
        #[error("Failed to parse the field '{field}' as correct type: {err}")]
        WrongJsonType {
            err: simd_json::TryTypeError,
            field: &'static str,
        },
        #[error("Expected a json object but got something else: {0}")]
        ExpectedJsonObject(#[from] simd_json::TryTypeError),

        #[error("Failed to decode the base64 nonce: {0}")]
        NonceParse(#[from] base64::DecodeSliceError),
        #[error("Failed to parse the avatar url: {0}")]
        UrlParse(#[from] url::ParseError),

        #[error(
            "The legacy identity entry json's version did not match. Got {got}, but expected \
             {expected}"
        )]
        VersionMismatch { got: u64, expected: i32 },

        #[error(
            "The sequnce of operation composing this legacy identity ({id}), is invalid: {error}"
        )]
        InvalidOperationSequence {
            id: crate::replica::entity::id::Id,
            error: crate::replica::entity::operation::operations::create::Error<
                crate::entities::identity::Identity,
            >,
        },
    }
}