1use serde::{Deserialize, Serialize};
27
28use crate::address::Address;
29use crate::error::{AxCode, AxError};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct B3Hash([u8; 32]);
34
35impl B3Hash {
36 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
37 B3Hash(bytes)
38 }
39
40 pub fn as_bytes(&self) -> &[u8; 32] {
41 &self.0
42 }
43
44 pub fn digest(bytes: &[u8]) -> Self {
48 B3Hash(*blake3::hash(bytes).as_bytes())
49 }
50}
51
52impl std::fmt::Display for B3Hash {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 write_hex(f, &self.0)
55 }
56}
57
58impl Serialize for B3Hash {
59 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
60 write_digest(self, &self.0, serializer)
61 }
62}
63
64impl<'de> Deserialize<'de> for B3Hash {
65 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
66 read_digest(deserializer).map(B3Hash)
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
73pub struct GitOid([u8; 20]);
74
75impl GitOid {
76 pub fn from_bytes(bytes: [u8; 20]) -> Self {
77 GitOid(bytes)
78 }
79
80 #[must_use]
92 pub fn parse(raw: &str) -> Option<Self> {
93 decode_hex_fixed::<20>(raw).map(GitOid)
94 }
95}
96
97impl std::fmt::Display for GitOid {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 write_hex(f, &self.0)
100 }
101}
102
103impl Serialize for GitOid {
104 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
105 write_digest(self, &self.0, serializer)
106 }
107}
108
109impl<'de> Deserialize<'de> for GitOid {
110 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
113 read_digest(deserializer).map(GitOid)
114 }
115}
116
117fn write_digest<const N: usize, S: serde::Serializer>(
121 spelled: &impl std::fmt::Display,
122 bytes: &[u8; N],
123 serializer: S,
124) -> Result<S::Ok, S::Error>
125where
126 [u8; N]: Serialize,
127{
128 if serializer.is_human_readable() {
129 serializer.collect_str(spelled)
130 } else {
131 bytes.serialize(serializer)
132 }
133}
134
135fn read_digest<'de, const N: usize, D: serde::Deserializer<'de>>(
139 deserializer: D,
140) -> Result<[u8; N], D::Error>
141where
142 [u8; N]: Deserialize<'de>,
143{
144 if !deserializer.is_human_readable() {
145 return <[u8; N]>::deserialize(deserializer);
146 }
147 let raw = String::deserialize(deserializer)?;
148 decode_hex_fixed::<N>(&raw).ok_or_else(|| {
149 serde::de::Error::custom(format_args!(
150 "expected exactly {} lowercase hex digits",
151 N.saturating_mul(2)
152 ))
153 })
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum Range {
159 Lines { from: u64, to: u64 },
160 Bytes { from: u64, to: u64 },
161}
162
163impl Range {
164 pub fn lines(from: u64, to: u64) -> Result<Self, AxError> {
166 if from == 0 || from > to {
167 return Err(range_error("lines", from, to));
168 }
169 Ok(Range::Lines { from, to })
170 }
171
172 pub fn bytes(from: u64, to: u64) -> Result<Self, AxError> {
174 if from > to {
175 return Err(range_error("bytes", from, to));
176 }
177 Ok(Range::Bytes { from, to })
178 }
179}
180
181fn range_error(unit: &str, from: u64, to: u64) -> AxError {
182 AxError::failure(
183 AxCode::InvalidArgs,
184 "construct range",
185 format!("{unit} {from}-{to}"),
186 )
187 .with_recovery("require from <= to; line ranges start at 1, byte ranges at 0")
188}
189
190impl std::fmt::Display for Range {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 match self {
193 Range::Lines { from, to } => write!(f, "L{from}-{to}"),
194 Range::Bytes { from, to } => write!(f, "B{from}-{to}"),
195 }
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum Locator {
202 Cas {
203 hash: B3Hash,
204 range: Option<Range>,
205 },
206 File {
207 address: Address,
208 oid: GitOid,
209 range: Option<Range>,
210 },
211}
212
213impl Locator {
214 #[must_use]
218 pub const fn cas(hash: B3Hash) -> Self {
219 Locator::Cas { hash, range: None }
220 }
221
222 pub fn parse(raw: &str) -> Result<Self, AxError> {
224 let parsed = Locator::parse_inner(raw)?;
225 let echo = parsed.to_string();
227 if echo != raw {
228 return Err(invalid(
229 raw,
230 format!("non-canonical spelling; use `{echo}`"),
231 ));
232 }
233 Ok(parsed)
234 }
235
236 fn parse_inner(raw: &str) -> Result<Self, AxError> {
237 if let Some(rest) = raw.strip_prefix("cas:") {
238 let (body, range) = split_range(raw, rest)?;
239 let hex = body.strip_prefix("b3-").ok_or_else(|| {
240 invalid(
241 raw,
242 "unknown or missing algorithm tag; the only tag is `b3-`",
243 )
244 })?;
245 let hash = decode_hex_fixed::<32>(hex)
246 .map(B3Hash)
247 .ok_or_else(|| invalid(raw, "hash must be exactly 64 lowercase hex digits"))?;
248 return Ok(Locator::Cas { hash, range });
249 }
250 if let Some(rest) = raw.strip_prefix("file:") {
251 let (body, range) = split_range(raw, rest)?;
252 let (addr_raw, oid_raw) = body
253 .rsplit_once('@')
254 .ok_or_else(|| invalid(raw, "file locator needs `<address>@<git-oid>`"))?;
255 let address = Address::parse(addr_raw)
256 .map_err(|_| invalid(raw, "address part violates the address grammar"))?;
257 let oid = GitOid::parse(oid_raw)
258 .ok_or_else(|| invalid(raw, "git oid must be exactly 40 lowercase hex digits"))?;
259 return Ok(Locator::File {
260 address,
261 oid,
262 range,
263 });
264 }
265 if raw.starts_with("secret:") {
266 return Err(invalid(
267 raw,
268 "SecretRef never enters the locator grammar (separate parser by design)",
269 ));
270 }
271 Err(invalid(
272 raw,
273 "unknown scheme; only `cas:` and `file:` exist",
274 ))
275 }
276}
277
278impl std::fmt::Display for Locator {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 match self {
281 Locator::Cas { hash, range } => {
282 write!(f, "cas:b3-{hash}")?;
283 write_range(f, range)
284 }
285 Locator::File {
286 address,
287 oid,
288 range,
289 } => {
290 write!(f, "file:{address}@{oid}")?;
291 write_range(f, range)
292 }
293 }
294 }
295}
296
297impl Serialize for Locator {
298 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
299 serializer.collect_str(self)
300 }
301}
302
303impl<'de> Deserialize<'de> for Locator {
304 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
305 let raw = String::deserialize(deserializer)?;
306 Locator::parse(&raw).map_err(serde::de::Error::custom)
307 }
308}
309
310fn invalid(raw: &str, violation: impl Into<String>) -> AxError {
311 AxError::failure(AxCode::LocatorInvalid, "parse locator", raw).with_recovery(violation.into())
312}
313
314fn split_range(raw: &str, rest: &str) -> Result<(String, Option<Range>), AxError> {
317 match rest.split_once('#') {
318 None => Ok((rest.to_owned(), None)),
319 Some((body, fragment)) => {
320 let range = parse_range(raw, fragment)?;
321 Ok((body.to_owned(), Some(range)))
322 }
323 }
324}
325
326fn parse_range(raw: &str, fragment: &str) -> Result<Range, AxError> {
327 let mut chars = fragment.chars();
328 let unit = chars
329 .next()
330 .ok_or_else(|| invalid(raw, "empty range fragment"))?;
331 let body: &str = chars.as_str();
332 let (from_raw, to_raw) = body
333 .split_once('-')
334 .ok_or_else(|| invalid(raw, "range needs `<from>-<to>`"))?;
335 let from = parse_canonical_u64(raw, from_raw)?;
336 let to = parse_canonical_u64(raw, to_raw)?;
337 let range = match unit {
338 'L' => Range::lines(from, to),
339 'B' => Range::bytes(from, to),
340 _ => {
341 return Err(invalid(
342 raw,
343 "range unit must be `L` (lines) or `B` (bytes)",
344 ));
345 }
346 };
347 range.map_err(|_| invalid(raw, "range bounds are out of order or out of base"))
348}
349
350fn parse_canonical_u64(raw: &str, digits: &str) -> Result<u64, AxError> {
352 if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
353 return Err(invalid(raw, "range bound must be decimal digits"));
354 }
355 if digits.len() > 1 && digits.starts_with('0') {
356 return Err(invalid(raw, "range bound must not carry leading zeros"));
357 }
358 digits
359 .parse::<u64>()
360 .map_err(|_| invalid(raw, "range bound exceeds u64"))
361}
362
363pub(crate) fn write_hex(f: &mut std::fmt::Formatter<'_>, bytes: &[u8]) -> std::fmt::Result {
364 for b in bytes {
365 write!(f, "{b:02x}")?;
366 }
367 Ok(())
368}
369
370fn write_range(f: &mut std::fmt::Formatter<'_>, range: &Option<Range>) -> std::fmt::Result {
371 match range {
372 Some(r) => write!(f, "#{r}"),
373 None => Ok(()),
374 }
375}
376
377pub(crate) fn decode_hex_fixed<const N: usize>(raw: &str) -> Option<[u8; N]> {
379 if raw.len() != N.checked_mul(2)? {
380 return None;
381 }
382 let mut out = Vec::with_capacity(N);
383 let mut bytes = raw.bytes();
384 while let Some(hi) = bytes.next() {
385 let lo = bytes.next()?;
386 let value = nibble(hi)?.checked_mul(16)?.checked_add(nibble(lo)?)?;
387 out.push(value);
388 }
389 <[u8; N]>::try_from(out.as_slice()).ok()
390}
391
392fn nibble(c: u8) -> Option<u8> {
394 match c {
395 b'0'..=b'9' => c.checked_sub(b'0'),
396 b'a'..=b'f' => c.checked_sub(b'a')?.checked_add(10),
397 _ => None,
398 }
399}
400
401#[cfg(test)]
402#[allow(
403 clippy::unwrap_used,
404 clippy::expect_used,
405 clippy::panic,
406 clippy::indexing_slicing,
407 clippy::wildcard_enum_match_arm,
408 clippy::let_underscore_must_use,
409 clippy::let_underscore_untyped,
410 reason = "test code"
411)]
412mod tests;