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}