Skip to main content

lonca_core/
objects.rs

1//! The plain data of the object store: ids, file modes and flattened trees.
2//! No Git library: a client of the daemon reads and writes these without
3//! the store (feature `store`).
4
5use std::collections::BTreeMap;
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::{Error, Result};
11
12/// A Git object id in lowercase hex. Read from JSON (a request, a log
13/// entry) only as Lonca writes one, 40 lowercase hex digits: anything else
14/// is refused there, not found later by whatever looks at it, and what is
15/// read is never changed on the way (an entry's hash covers it).
16#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(try_from = "String", into = "String")]
18pub struct GitId(pub String);
19
20impl TryFrom<String> for GitId {
21    type Error = Error;
22
23    fn try_from(s: String) -> Result<Self> {
24        let id = Self::parse(&s)?;
25        if id.0 != s {
26            return Err(Error::Invalid(format!("git id {s:?}: not in lowercase")));
27        }
28        Ok(id)
29    }
30}
31
32impl From<GitId> for String {
33    fn from(id: GitId) -> String {
34        id.0
35    }
36}
37
38/// Hex digits in a SHA-1 object id, the only kind Lonca stores.
39const HEX_LEN: usize = 40;
40
41impl GitId {
42    /// A SHA-1 id: 40 hex digits, either case, kept in lowercase.
43    pub fn parse(s: &str) -> Result<Self> {
44        if s.len() != HEX_LEN || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
45            return Err(Error::Invalid(format!(
46                "git id {s:?}: not {HEX_LEN} hex digits"
47            )));
48        }
49        Ok(Self(s.to_ascii_lowercase()))
50    }
51
52    /// The first ten characters, for people to read.
53    pub fn short(&self) -> &str {
54        let end = self
55            .0
56            .char_indices()
57            .nth(10)
58            .map_or(self.0.len(), |(i, _)| i);
59        &self.0[..end]
60    }
61}
62
63impl fmt::Display for GitId {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(&self.0)
66    }
67}
68
69impl fmt::Debug for GitId {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "GitId({})", self.short())
72    }
73}
74
75#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum FileMode {
78    Regular,
79    Executable,
80    Symlink,
81    /// A submodule commit; carried through unchanged, never materialized.
82    Submodule,
83}
84
85#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
86pub struct FileEntry {
87    pub mode: FileMode,
88    pub blob: GitId,
89}
90
91/// A flattened tree: repository path -> entry.
92pub type FileMap = BTreeMap<String, FileEntry>;
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn an_id_is_forty_hex_digits_kept_in_lowercase() {
100        let lower = "0123456789abcdef0123456789abcdef01234567";
101        assert_eq!(GitId::parse(lower).unwrap().0, lower);
102        let upper = lower.to_ascii_uppercase();
103        assert_eq!(GitId::parse(&upper).unwrap().0, lower);
104        for bad in [
105            "",
106            &lower[..39],
107            &format!("{lower}8"),
108            &lower.replace('a', "g"),
109            &"0".repeat(64),
110        ] {
111            assert!(GitId::parse(bad).is_err(), "{bad}");
112        }
113        // Not a byte count: a multi-byte character of the right length.
114        let wide = format!("{}é", &lower[..38]);
115        assert_eq!(wide.len(), 40);
116        assert!(GitId::parse(&wide).is_err());
117        assert_eq!(GitId(lower.into()).short(), "0123456789");
118        assert_eq!(GitId("abc".into()).short(), "abc");
119        assert_eq!(format!("{:?}", GitId(lower.into())), "GitId(0123456789)");
120        assert_eq!(GitId(lower.into()).to_string(), lower);
121        // Ten characters, not ten bytes: a cut inside one would panic.
122        assert_eq!(GitId("ééééééééééé".into()).short(), "éééééééééé");
123        assert_eq!(GitId("ab".repeat(8)).short(), "ababababab");
124    }
125
126    /// JSON gives a git id only as Lonca writes one, unchanged.
127    #[test]
128    fn a_git_id_is_read_from_json_only_as_written() {
129        let lower = "0123456789abcdef0123456789abcdef01234567";
130        let id: GitId = serde_json::from_value(serde_json::json!(lower)).unwrap();
131        assert_eq!(id.0, lower);
132        assert_eq!(serde_json::to_value(&id).unwrap(), serde_json::json!(lower));
133        let upper = lower.to_uppercase();
134        for bad in [
135            "",
136            "abc",
137            "ééééééééééééééééééé",
138            &format!("{}é", &lower[..38]),
139            &upper,
140        ] {
141            let r = serde_json::from_value::<GitId>(serde_json::json!(bad));
142            assert!(r.is_err(), "{bad}");
143        }
144    }
145}