git_bug/entities/identity/
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 user identities.
12//!
13//! # Note
14//! Git-bug stores identities not in the same way as [`Entities`][`Entity`] are
15//! normally stored. This means, that the [`Operations`] we provide, are
16//! completely custom to `git-bug-rs` and will not be commited to disk in this
17//! way. In general, this should not provide a problem, but it could result in
18//! their ordering being not really the same.
19//!
20//! If you need concrete access to the actual on disk format, use
21//! [`Identity::read_legacy`].
22
23use std::{iter, mem};
24
25use log::warn;
26use serde::{Deserialize, Serialize};
27use simd_json::{json_typed, owned};
28
29use self::{
30    identity_operation::{IdentityOperationData, identity_operation_type::IdentityOperationType},
31    snapshot::{history_step::IdentityHistoryStep, timeline::IdentityTimeline},
32};
33use crate::replica::{
34    Replica,
35    cache::impl_cache,
36    entity::{
37        Entity, EntityRead,
38        id::entity_id::EntityId,
39        identity::IdentityStub,
40        lamport,
41        operation::{Operation, operations::Operations},
42    },
43};
44
45pub mod identity_operation;
46pub mod legacy;
47pub mod snapshot;
48
49/// A user identity.
50/// This is a unique representation of an user.
51#[derive(Debug, Deserialize, Serialize)]
52pub struct Identity {
53    id: EntityId<Identity>,
54    operations: Operations<Self>,
55    create_time: lamport::Time,
56    edit_time: lamport::Time,
57    current_head: gix::ObjectId,
58}
59
60impl Entity for Identity {
61    type HistoryStep = IdentityHistoryStep;
62    type OperationData = IdentityOperationData;
63    type Timeline = IdentityTimeline;
64
65    const FORMAT_VERSION: usize = 4;
66    const NAMESPACE: &str = "identities";
67    const TYPENAME: &str = "Identity";
68
69    fn operations(&self) -> &Operations<Self>
70    where
71        Self: Sized,
72    {
73        &self.operations
74    }
75
76    unsafe fn from_parts(
77        operations: Operations<Self>,
78        create_time: lamport::Time,
79        edit_time: lamport::Time,
80        current_head: gix::ObjectId,
81    ) -> Self
82    where
83        Self: Sized,
84    {
85        warn!("Constructing an Identity with a invalid id.");
86        Self {
87            // FIXME(@bpeetz): This will return a ID different from git-bug! (As git-bug uses the
88            // legacy format) <2025-05-03>
89            id: operations.root().id(),
90            operations,
91            create_time,
92            edit_time,
93            current_head,
94        }
95    }
96
97    fn create_time(&self) -> &lamport::Time
98    where
99        Self: Sized,
100    {
101        &self.create_time
102    }
103
104    fn edit_time(&self) -> &lamport::Time
105    where
106        Self: Sized,
107    {
108        &self.edit_time
109    }
110
111    fn current_head(&self) -> &gix::oid
112    where
113        Self: Sized,
114    {
115        &self.current_head
116    }
117
118    fn id(&self) -> EntityId<Self>
119    where
120        Self: Sized,
121    {
122        self.id
123    }
124}
125
126#[allow(missing_docs)]
127pub mod read {
128    use super::legacy;
129
130    #[derive(Debug, thiserror::Error)]
131    /// The error used in the custom read implementation of
132    /// [`Identity`][`super::Identity`].
133    pub enum Error {
134        #[error("Reading the legacy data failed: {0}")]
135        LegacyRead(#[from] legacy::read::Error),
136
137        #[error("This identity does not contain legacy version entries")]
138        EmptyIdentity,
139
140        #[error("This identity contained a last version entry, with a missing clock name: {0}")]
141        MissingClock(String),
142    }
143}
144
145impl From<read::Error> for crate::replica::entity::read::Error<Identity> {
146    fn from(value: read::Error) -> Self {
147        Self::CustomRead(value)
148    }
149}
150
151impl EntityRead for Identity {
152    type CustomReadError = read::Error;
153
154    #[allow(clippy::too_many_lines)]
155    fn read(
156        replica: &Replica,
157        id: EntityId<Self>,
158    ) -> Result<Self, crate::replica::entity::read::Error<Self>>
159    where
160        Self: Sized,
161    {
162        impl_cache!(@mk_table "identities");
163
164        let last_id = Identity::last_git_commit(replica, id)?;
165
166        let mut key = id.as_id().as_slice().to_owned();
167        key.extend(last_id.as_slice());
168        impl_cache! {@lookup replica.db(), key.as_slice()}
169
170        // HACK(@bpeetz): We simply read the legacy identity data here and transform it
171        // enough so that it looks like it is actually a real enities' data.
172        // <2025-04-21>
173        let mut version_entries =
174            Self::read_legacy(replica, id).map_err(read::Error::LegacyRead)?;
175
176        if version_entries.is_empty() {
177            return Err(read::Error::EmptyIdentity)?;
178        }
179        // NOTE(@bpeetz): The `times` field should never be used in the code below,
180        // because that code is just about formatting. <2025-04-21>
181        let last_version_clocks = mem::take(&mut version_entries.last_mut().expect("Exists").times);
182
183        let operations: Operations<Identity> = {
184            let mut ops: Vec<Operation<Identity>> = vec![];
185
186            let mut first_version = version_entries.remove(0);
187            ops.push(
188                Operation::<Identity>::from_value(
189                    json_typed! { owned,
190                    {
191                        "type": u64::from(IdentityOperationType::Create),
192                        "timestamp": first_version.unix_time,
193                        "nonce": Into::<String>::into(first_version.nonce),
194                        "name": first_version.name.unwrap_or("<Unnamed identity>".to_owned()),
195                    }
196                    },
197                    IdentityStub { id },
198                )
199                .expect("The json is hard-coded"),
200            );
201
202            // Already stored.
203            first_version.name = None;
204
205            for version in iter::once(first_version).chain(version_entries.into_iter()) {
206                if let Some(name) = version.name {
207                    ops.push(
208                        Operation::<Identity>::from_value(
209                            json_typed! {owned,
210                            {
211                                "type": u64::from(IdentityOperationType::SetName),
212                                "timestamp": version.unix_time,
213                                "nonce": Into::<String>::into(version.nonce),
214                                "name": name,
215                            }
216                            },
217                            IdentityStub { id },
218                        )
219                        .expect("The json is hard-coded"),
220                    );
221                }
222                if let Some(email) = version.email {
223                    ops.push(
224                        Operation::<Identity>::from_value(
225                            json_typed! {owned,
226                            {
227                                "type": u64::from(IdentityOperationType::SetEmail),
228                                "timestamp": version.unix_time,
229                                "nonce": Into::<String>::into(version.nonce),
230                                "email": email,
231                            }
232                            },
233                            IdentityStub { id },
234                        )
235                        .expect("The json is hard-coded"),
236                    );
237                }
238                if let Some(login_name) = version.login {
239                    ops.push(
240                        Operation::<Identity>::from_value(
241                            json_typed! {owned,
242                            {
243                                "type": u64::from(IdentityOperationType::SetLoginName),
244                                "timestamp": version.unix_time,
245                                "nonce": Into::<String>::into(version.nonce),
246                                "login_name": login_name,
247                            }
248                            },
249                            IdentityStub { id },
250                        )
251                        .expect("The json is hard-coded"),
252                    );
253                }
254                if let Some(avatar_url) = version.avatar_url {
255                    ops.push(
256                        Operation::<Identity>::from_value(
257                            json_typed! {owned,
258                            {
259                                "type": u64::from(IdentityOperationType::SetAvatarUrl),
260                                "timestamp": version.unix_time,
261                                "nonce": Into::<String>::into(version.nonce),
262                                "url": avatar_url.to_string(),
263                            }
264                            },
265                            IdentityStub { id },
266                        )
267                        .expect("The json is hard-coded"),
268                    );
269                }
270                if let Some(metadata) = version.metadata {
271                    let mut newer_metadata = owned::Object::new();
272                    unsafe {
273                        for (key, value) in metadata {
274                            // Safety:
275                            // We just created that.
276                            newer_metadata.insert_nocheck(key, value.into());
277                        }
278                    }
279
280                    ops.push(
281                        Operation::<Identity>::from_value(
282                            json_typed! {owned,
283                            {
284                                "type": u64::from(IdentityOperationType::SetMetadata),
285                                "timestamp": version.unix_time,
286                                "nonce": Into::<String>::into(version.nonce),
287                                "metadata": owned::Value::Object(Box::new(newer_metadata))
288
289                            }
290                            },
291                            IdentityStub { id },
292                        )
293                        .expect("The json is hard-coded"),
294                    );
295                }
296            }
297            Operations::<Identity>::from_operations(ops)?
298        };
299
300        let (create_time, edit_time) = {
301            // NOTE(@bpeetz): In git-bug `times` field is generated by calling `AllClocks()`
302            // in `./repository/gogit.go`. This means, that if an identity was
303            // created _before_ a bug was created, that these fields will be empty.
304            // As such it's a weird but sort-of okay choice to default to 1 (1 sort of
305            // signals that this was after the first bug was created, but that
306            // is close enough for what this function promises.) <2025-04-21>
307
308            let create = last_version_clocks
309                .iter()
310                .find_map(|(key, value)| {
311                    if key == "bugs-create" {
312                        Some(value)
313                    } else {
314                        None
315                    }
316                })
317                .unwrap_or(&1);
318            let edit = last_version_clocks
319                .iter()
320                .find_map(|(key, value)| {
321                    if key == "bugs-edit" {
322                        Some(value)
323                    } else {
324                        None
325                    }
326                })
327                .unwrap_or(&1);
328
329            (lamport::Time::from(*create), lamport::Time::from(*edit))
330        };
331
332        // NOTE(@bpeetz): We can't actually use [`Self::from_parts`] here, as we need to
333        // store the original id. <2025-05-03>
334        let me = Self {
335            id,
336            operations,
337            create_time,
338            edit_time,
339            current_head: last_id,
340        };
341
342        impl_cache! {@populate replica.db(), key.as_slice(), &me}
343
344        Ok(me)
345    }
346}