git_bug/replica/entity/id/
combined_id.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//! Implementation of combined Ids.
12//!
13//! See [`CombinedId`] for more.
14
15use std::{fmt::Display, ptr};
16
17use super::{Id, prefix::IdPrefix};
18
19/// A combination of an [`Entities`][`crate::replica::entity::Entity`] [`Id`]
20/// with one of it's [`Operation's`][`crate::replica::entity::operation::Operation`] [`Id`].
21///
22/// This is useful, to uniquely identify a property of an
23/// [`Entity`][`crate::replica::entity::Entity`].
24#[derive(Debug, Clone, Copy)]
25pub struct CombinedId {
26    pub(crate) primary_id: Id,
27    pub(crate) secondary_id: Id,
28}
29
30impl CombinedId {
31    /// Return the combined raw byte slices of the two underlying
32    /// [`Ids`][`Id`] representing this [`CombinedId`].
33    #[must_use]
34    // Only asserts
35    #[allow(clippy::missing_panics_doc)]
36    pub fn to_slice(&self) -> [u8; 64] {
37        let mut value = [0; 64];
38
39        assert_eq!(self.primary_id.as_slice().len(), 32);
40        assert_eq!(self.secondary_id.as_slice().len(), 32);
41
42        // SAFETY: `value` is valid for 64 u8 elements by definition, and both sources only
43        // contain 32 u8 elements.
44        // The slices cannot overlap because mutable references are exclusive.
45        unsafe {
46            ptr::copy_nonoverlapping(self.primary_id.as_slice().as_ptr(), value.as_mut_ptr(), 32);
47            ptr::copy_nonoverlapping(
48                self.secondary_id.as_slice().as_ptr(),
49                value.as_mut_ptr(),
50                32,
51            );
52        }
53
54        value
55    }
56}
57
58/// A shortened from of an [`CombinedId`].
59///
60/// This is meant for Human interaction.
61#[derive(Debug, Clone, Copy)]
62pub struct CombinedIdPrefix {
63    primary_id: IdPrefix,
64    secondary_id: IdPrefix,
65}
66
67impl CombinedId {
68    /// Shorten an [`CombinedId`] to the required length, so that it stays
69    /// unique, but is still short enough for human consumption.
70    #[must_use]
71    pub fn shorten(self) -> CombinedIdPrefix {
72        CombinedIdPrefix {
73            primary_id: self.primary_id.shorten(),
74            secondary_id: self.secondary_id.shorten(),
75        }
76    }
77}
78
79impl Display for CombinedIdPrefix {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        // See the [`FromStr`] impl for a documentation of the layout.
82        let mut output = String::new();
83
84        let mut primary = self.primary_id.to_string();
85        let mut secondary = self.secondary_id.to_string();
86
87        for index in 0..(IdPrefix::REQUIRED_LENGTH * 2) {
88            match index {
89                i if i == 1 || i == 3 || i == 5 || i == 9 || (i >= 10 && i % 5 == 4) => {
90                    output.push(
91                        secondary
92                            .pop()
93                            .expect("This should always be valid as we max out at REQUIRED_PREFIX"),
94                    );
95                }
96                i if i == 0
97                    || i == 2
98                    || i == 4
99                    || i == 6
100                    || i == 7
101                    || i == 8
102                    || (i >= 10 && i % 5 != 4) =>
103                {
104                    output.push(
105                        primary
106                            .pop()
107                            .expect("This should always be valid as we max out at REQUIRED_PREFIX"),
108                    );
109                }
110                _ => unreachable!("All possible indices should be covered above."),
111            }
112        }
113
114        f.write_str(output.as_str())?;
115
116        Ok(())
117    }
118}
119
120#[allow(missing_docs)]
121pub mod decode {
122    use std::str::FromStr;
123
124    use super::CombinedIdPrefix;
125    use crate::replica::entity::id::prefix::{self, IdPrefix};
126
127    #[derive(Debug, thiserror::Error)]
128    pub enum Error {
129        #[error(
130            "Your combined id {combined_id} was too long: {len} of expected 64",
131            len = combined_id.len()
132        )]
133        TooLong { combined_id: String },
134
135        #[error("The separated id prefixes from the combined id prefix could not be parsed: {0}")]
136        InvalidPrefix(#[from] prefix::decode::Error),
137    }
138
139    impl FromStr for CombinedIdPrefix {
140        type Err = Error;
141
142        fn from_str(s: &str) -> Result<Self, Self::Err> {
143            // > A combined Id is computed (in `git-bug`) by merging two
144            // > Ids, so that the resulting combined Id holds information
145            // > from both the primary Id and the secondary Id.
146            //
147            // > This allows us to later find the secondary element efficiently because
148            // > we can access the primary one directly instead of searching
149            // > for a primary Entity that has a  secondary matching the Id.
150            //
151            // > An example usage of this is the Comment data in an Issue. The combined Id
152            // > will hold part of the
153            // > Issue Id and part of the Comment Id.
154            //
155            // > To allow the use of an arbitrary length prefix of this Id, Ids from primary
156            // > and secondary are interleaved with this irregular pattern to give the
157            // > best chance to find the secondary Id even with a 7 character prefix.
158            //
159            // > Format is as follows:
160            // >     10       5     5     5     5     5     5     5     5     5     5    5
161            // > PSPSPSPPPS PPPPS PPPPS PPPPS PPPPS PPPPS PPPPS PPPPS PPPPS PPPPS PPPPS PPPP
162            //
163            // > A complete combined Id holds 50 characters for the primary and 14 for the
164            // > secondary, which gives a key space of 36^50 for the primary (~6 * 10^77)
165            // > and 36^14 for the secondary (~6 * 10^21).
166            // > This asymmetry assumes a reasonable number of secondary Ids within a
167            // > primary Entity, while still allowing for a vast  key space  for the primary
168            // > (that is, a globally merged database) with a low risk of collision.
169            //
170            // > Here is the breakdown of several common prefix length:
171            //
172            // > 5:    3P, 2S
173            // > 7:    4P, 3S
174            // > 10:   6P, 4S
175            // > 16:  11P, 5S
176            //
177            // Thanks to our Id struct being Copy, we can simply store both the `primary`
178            // and the `secondary` Id cheaply. We still need to en- and decode
179            // it though, so that we can interface with git-bug (and because we
180            // can't store our two Ids).
181
182            if s.len() > 64 {
183                return Err(Error::TooLong {
184                    combined_id: s.to_owned(),
185                });
186            }
187
188            let mut primary: [u8; 50] = [0; 50];
189            let mut primary_index = 0;
190
191            let mut secondary: [u8; 14] = [0; 14];
192            let mut secondary_index = 0;
193
194            for (index, ch) in s.chars().enumerate() {
195                match index {
196                    i if i == 1 || i == 3 || i == 5 || i == 9 || (i >= 10 && i % 5 == 4) => {
197                        secondary[secondary_index] = ch as u8;
198                        secondary_index += 1;
199                    }
200                    i if i == 0
201                        || i == 2
202                        || i == 4
203                        || i == 6
204                        || i == 7
205                        || i == 8
206                        || (i >= 10 && i % 5 != 4) =>
207                    {
208                        primary[primary_index] = ch as u8;
209                        primary_index += 1;
210                    }
211                    _ => unreachable!("All possible indices should be covered above."),
212                }
213            }
214
215            Ok(Self {
216                primary_id: IdPrefix::from_hex_bytes(&primary)?,
217                secondary_id: IdPrefix::from_hex_bytes(&secondary)?,
218            })
219        }
220    }
221
222    impl TryFrom<&str> for CombinedIdPrefix {
223        type Error = Error;
224
225        fn try_from(value: &str) -> Result<Self, Self::Error> {
226            <Self as FromStr>::from_str(value)
227        }
228    }
229}