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 user identities.
//!
//! # Note
//! Git-bug stores identities not in the same way as [`Entities`][`Entity`] are
//! normally stored. This means, that the [`Operations`] we provide, are
//! completely custom to `git-bug-rs` and will not be commited to disk in this
//! way. In general, this should not provide a problem, but it could result in
//! their ordering being not really the same.
//!
//! If you need concrete access to the actual on disk format, use
//! [`Identity::read_legacy`].

use std::{iter, mem};

use log::warn;
use serde::{Deserialize, Serialize};
use simd_json::{json_typed, owned};

use self::{
    identity_operation::{IdentityOperationData, identity_operation_type::IdentityOperationType},
    snapshot::{history_step::IdentityHistoryStep, timeline::IdentityTimeline},
};
use crate::replica::{
    Replica,
    cache::impl_cache,
    entity::{
        Entity, EntityRead,
        id::entity_id::EntityId,
        identity::IdentityStub,
        lamport,
        operation::{Operation, operations::Operations},
    },
};

pub mod identity_operation;
pub mod legacy;
pub mod snapshot;

/// A user identity.
/// This is a unique representation of an user.
#[derive(Debug, Deserialize, Serialize)]
pub struct Identity {
    id: EntityId<Identity>,
    operations: Operations<Self>,
    create_time: lamport::Time,
    edit_time: lamport::Time,
    current_head: gix::ObjectId,
}

impl Entity for Identity {
    type HistoryStep = IdentityHistoryStep;
    type OperationData = IdentityOperationData;
    type Timeline = IdentityTimeline;

    const FORMAT_VERSION: usize = 4;
    const NAMESPACE: &str = "identities";
    const TYPENAME: &str = "Identity";

    fn operations(&self) -> &Operations<Self>
    where
        Self: Sized,
    {
        &self.operations
    }

    unsafe fn from_parts(
        operations: Operations<Self>,
        create_time: lamport::Time,
        edit_time: lamport::Time,
        current_head: gix::ObjectId,
    ) -> Self
    where
        Self: Sized,
    {
        warn!("Constructing an Identity with a invalid id.");
        Self {
            // FIXME(@bpeetz): This will return a ID different from git-bug! (As git-bug uses the
            // legacy format) <2025-05-03>
            id: operations.root().id(),
            operations,
            create_time,
            edit_time,
            current_head,
        }
    }

    fn create_time(&self) -> &lamport::Time
    where
        Self: Sized,
    {
        &self.create_time
    }

    fn edit_time(&self) -> &lamport::Time
    where
        Self: Sized,
    {
        &self.edit_time
    }

    fn current_head(&self) -> &gix::oid
    where
        Self: Sized,
    {
        &self.current_head
    }

    fn id(&self) -> EntityId<Self>
    where
        Self: Sized,
    {
        self.id
    }
}

#[allow(missing_docs)]
pub mod read {
    use super::legacy;

    #[derive(Debug, thiserror::Error)]
    /// The error used in the custom read implementation of
    /// [`Identity`][`super::Identity`].
    pub enum Error {
        #[error("Reading the legacy data failed: {0}")]
        LegacyRead(#[from] legacy::read::Error),

        #[error("This identity does not contain legacy version entries")]
        EmptyIdentity,

        #[error("This identity contained a last version entry, with a missing clock name: {0}")]
        MissingClock(String),
    }
}

impl From<read::Error> for crate::replica::entity::read::Error<Identity> {
    fn from(value: read::Error) -> Self {
        Self::CustomRead(value)
    }
}

impl EntityRead for Identity {
    type CustomReadError = read::Error;

    #[allow(clippy::too_many_lines)]
    fn read(
        replica: &Replica,
        id: EntityId<Self>,
    ) -> Result<Self, crate::replica::entity::read::Error<Self>>
    where
        Self: Sized,
    {
        impl_cache!(@mk_table "identities");

        let last_id = Identity::last_git_commit(replica, id)?;

        let mut key = id.as_id().as_slice().to_owned();
        key.extend(last_id.as_slice());
        impl_cache! {@lookup replica.db(), key.as_slice()}

        // HACK(@bpeetz): We simply read the legacy identity data here and transform it
        // enough so that it looks like it is actually a real enities' data.
        // <2025-04-21>
        let mut version_entries =
            Self::read_legacy(replica, id).map_err(read::Error::LegacyRead)?;

        if version_entries.is_empty() {
            return Err(read::Error::EmptyIdentity)?;
        }
        // NOTE(@bpeetz): The `times` field should never be used in the code below,
        // because that code is just about formatting. <2025-04-21>
        let last_version_clocks = mem::take(&mut version_entries.last_mut().expect("Exists").times);

        let operations: Operations<Identity> = {
            let mut ops: Vec<Operation<Identity>> = vec![];

            let mut first_version = version_entries.remove(0);
            ops.push(
                Operation::<Identity>::from_value(
                    json_typed! { owned,
                    {
                        "type": u64::from(IdentityOperationType::Create),
                        "timestamp": first_version.unix_time,
                        "nonce": Into::<String>::into(first_version.nonce),
                        "name": first_version.name.unwrap_or("<Unnamed identity>".to_owned()),
                    }
                    },
                    IdentityStub { id },
                )
                .expect("The json is hard-coded"),
            );

            // Already stored.
            first_version.name = None;

            for version in iter::once(first_version).chain(version_entries.into_iter()) {
                if let Some(name) = version.name {
                    ops.push(
                        Operation::<Identity>::from_value(
                            json_typed! {owned,
                            {
                                "type": u64::from(IdentityOperationType::SetName),
                                "timestamp": version.unix_time,
                                "nonce": Into::<String>::into(version.nonce),
                                "name": name,
                            }
                            },
                            IdentityStub { id },
                        )
                        .expect("The json is hard-coded"),
                    );
                }
                if let Some(email) = version.email {
                    ops.push(
                        Operation::<Identity>::from_value(
                            json_typed! {owned,
                            {
                                "type": u64::from(IdentityOperationType::SetEmail),
                                "timestamp": version.unix_time,
                                "nonce": Into::<String>::into(version.nonce),
                                "email": email,
                            }
                            },
                            IdentityStub { id },
                        )
                        .expect("The json is hard-coded"),
                    );
                }
                if let Some(login_name) = version.login {
                    ops.push(
                        Operation::<Identity>::from_value(
                            json_typed! {owned,
                            {
                                "type": u64::from(IdentityOperationType::SetLoginName),
                                "timestamp": version.unix_time,
                                "nonce": Into::<String>::into(version.nonce),
                                "login_name": login_name,
                            }
                            },
                            IdentityStub { id },
                        )
                        .expect("The json is hard-coded"),
                    );
                }
                if let Some(avatar_url) = version.avatar_url {
                    ops.push(
                        Operation::<Identity>::from_value(
                            json_typed! {owned,
                            {
                                "type": u64::from(IdentityOperationType::SetAvatarUrl),
                                "timestamp": version.unix_time,
                                "nonce": Into::<String>::into(version.nonce),
                                "url": avatar_url.to_string(),
                            }
                            },
                            IdentityStub { id },
                        )
                        .expect("The json is hard-coded"),
                    );
                }
                if let Some(metadata) = version.metadata {
                    let mut newer_metadata = owned::Object::new();
                    unsafe {
                        for (key, value) in metadata {
                            // Safety:
                            // We just created that.
                            newer_metadata.insert_nocheck(key, value.into());
                        }
                    }

                    ops.push(
                        Operation::<Identity>::from_value(
                            json_typed! {owned,
                            {
                                "type": u64::from(IdentityOperationType::SetMetadata),
                                "timestamp": version.unix_time,
                                "nonce": Into::<String>::into(version.nonce),
                                "metadata": owned::Value::Object(Box::new(newer_metadata))

                            }
                            },
                            IdentityStub { id },
                        )
                        .expect("The json is hard-coded"),
                    );
                }
            }
            Operations::<Identity>::from_operations(ops)?
        };

        let (create_time, edit_time) = {
            // NOTE(@bpeetz): In git-bug `times` field is generated by calling `AllClocks()`
            // in `./repository/gogit.go`. This means, that if an identity was
            // created _before_ a bug was created, that these fields will be empty.
            // As such it's a weird but sort-of okay choice to default to 1 (1 sort of
            // signals that this was after the first bug was created, but that
            // is close enough for what this function promises.) <2025-04-21>

            let create = last_version_clocks
                .iter()
                .find_map(|(key, value)| {
                    if key == "bugs-create" {
                        Some(value)
                    } else {
                        None
                    }
                })
                .unwrap_or(&1);
            let edit = last_version_clocks
                .iter()
                .find_map(|(key, value)| {
                    if key == "bugs-edit" {
                        Some(value)
                    } else {
                        None
                    }
                })
                .unwrap_or(&1);

            (lamport::Time::from(*create), lamport::Time::from(*edit))
        };

        // NOTE(@bpeetz): We can't actually use [`Self::from_parts`] here, as we need to
        // store the original id. <2025-05-03>
        let me = Self {
            id,
            operations,
            create_time,
            edit_time,
            current_head: last_id,
        };

        impl_cache! {@populate replica.db(), key.as_slice(), &me}

        Ok(me)
    }
}