@jpgilldev / steid

steid/src/domain/object.rs
8.2 KBRaw
1//! Git objects, as the domain sees them.
2//!
3//! Deliberately not a model of git: an id, what a tree entry is, and enough of a commit
4//! to list one. [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md)
5//! rejected modelling git objects properly as premature, and it still is — this exists
6//! so a query port returns meaning rather than `String`s.
7
8use std::{fmt, time::SystemTime};
9
10use super::{DomainError, RefName};
11
12/// The id of a git object, hex-encoded.
13///
14/// Accepts both widths git uses: 40 characters for SHA-1 and 64 for SHA-256. Steid
15/// creates SHA-1 repositories today, and refusing the wider form would turn a
16/// repository created elsewhere unreadable rather than merely unsupported.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct ObjectId(String);
19
20impl ObjectId {
21 const SHA1_LEN: usize = 40;
22 const SHA256_LEN: usize = 64;
23
24 /// How much of an id to show. Seven is what git itself abbreviates to by default.
25 const SHORT_LEN: usize = 7;
26
27 pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
28 let value = value.as_ref().trim();
29
30 if value.len() != Self::SHA1_LEN && value.len() != Self::SHA256_LEN {
31 return Err(DomainError::validation(
32 "object id",
33 format!(
34 "an object id is {} or {} characters, got {}",
35 Self::SHA1_LEN,
36 Self::SHA256_LEN,
37 value.len()
38 ),
39 ));
40 }
41
42 if !value.chars().all(|c| c.is_ascii_hexdigit()) {
43 return Err(DomainError::validation(
44 "object id",
45 "an object id is hexadecimal",
46 ));
47 }
48
49 // Lowercased on the way in so two spellings of one id compare equal.
50 Ok(Self(value.to_ascii_lowercase()))
51 }
52
53 pub fn from_trusted(value: impl Into<String>) -> Self {
54 Self(value.into())
55 }
56
57 pub fn as_str(&self) -> &str {
58 &self.0
59 }
60
61 /// The abbreviated form, for display.
62 ///
63 /// Never for lookup: an abbreviation can become ambiguous as a repository grows,
64 /// which is exactly the bug that only appears once a repository is large.
65 pub fn short(&self) -> &str {
66 &self.0[..Self::SHORT_LEN.min(self.0.len())]
67 }
68}
69
70impl fmt::Display for ObjectId {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 f.write_str(&self.0)
73 }
74}
75
76/// What a tree entry is.
77///
78/// Git encodes this in a file mode, which is a POSIX mode only by resemblance — the
79/// meaningful values are a fixed set, so this is an enum rather than a bitfield.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
81pub enum EntryKind {
82 /// A directory.
83 ///
84 /// Ordered first so a listing sorts directories above files without a custom
85 /// comparator at every call site.
86 Tree,
87 /// A file.
88 Blob,
89 /// A symbolic link. Its "content" is the target path.
90 Symlink,
91 /// Another repository, mounted as a submodule. Steid cannot look inside one.
92 Submodule,
93}
94
95impl EntryKind {
96 /// Reads git's mode field.
97 ///
98 /// Returns `Result`, never a default: an unknown mode silently becoming a file
99 /// would render a submodule as an empty blob.
100 pub fn from_mode(mode: &str) -> Result<Self, DomainError> {
101 // Trimmed because `ls-tree` pads the mode of a tree to six characters with a
102 // leading zero, while `cat-file` does not.
103 match mode.trim().trim_start_matches('0') {
104 "40000" => Ok(Self::Tree),
105 "100644" | "100755" => Ok(Self::Blob),
106 "120000" => Ok(Self::Symlink),
107 "160000" => Ok(Self::Submodule),
108 other => Err(DomainError::validation(
109 "mode",
110 format!("unknown git file mode {other:?}"),
111 )),
112 }
113 }
114
115 pub fn is_tree(self) -> bool {
116 self == Self::Tree
117 }
118
119 pub fn as_str(self) -> &'static str {
120 match self {
121 Self::Tree => "tree",
122 Self::Blob => "blob",
123 Self::Symlink => "symlink",
124 Self::Submodule => "submodule",
125 }
126 }
127}
128
129/// One entry in a directory listing.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct TreeEntry {
132 /// The entry's own name, never a path — the containing path is the caller's.
133 pub name: String,
134 pub kind: EntryKind,
135 pub id: ObjectId,
136 /// A blob's size in bytes. `None` for anything without one.
137 pub size: Option<u64>,
138}
139
140impl TreeEntry {
141 /// Orders a listing the way a file browser does: directories first, then by name.
142 ///
143 /// Case-insensitive, because a listing sorted by byte value puts every capitalised
144 /// name above every lowercase one, which reads as unsorted.
145 pub fn ordering_key(&self) -> (EntryKind, String) {
146 (self.kind, self.name.to_lowercase())
147 }
148}
149
150/// Whether a ref is a branch or a tag.
151///
152/// The two are told apart by which namespace the ref lives in, not by what it points
153/// at: a lightweight tag and a branch both point straight at a commit, so the object
154/// says nothing about which one a visitor asked for.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum RefKind {
157 Branch,
158 Tag,
159}
160
161/// A branch or a tag, short-named as a switcher shows it.
162///
163/// Short (`main`, not `refs/heads/main`) because that is what the browse URLs take and
164/// what a person recognises. The ambiguity a full name would resolve — a branch and a
165/// tag sharing a name — is carried by [`kind`](Self::kind) instead.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct GitRef {
168 pub name: RefName,
169 pub kind: RefKind,
170}
171
172/// A commit, reduced to what a log entry shows.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct CommitSummary {
175 pub id: ObjectId,
176 /// The first line of the message.
177 pub summary: String,
178 pub author_name: String,
179 pub committed_at: SystemTime,
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 const SHA1: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
187
188 #[test]
189 fn both_hash_widths_are_accepted() {
190 assert!(ObjectId::new(SHA1).is_ok());
191 assert!(ObjectId::new("a".repeat(64)).is_ok());
192 }
193
194 #[test]
195 fn an_id_is_lowercased_so_two_spellings_compare_equal() {
196 assert_eq!(
197 ObjectId::new(SHA1.to_uppercase()).expect("valid"),
198 ObjectId::new(SHA1).expect("valid")
199 );
200 }
201
202 #[test]
203 fn a_wrong_width_or_non_hex_id_is_refused() {
204 for value in ["", "abc", &"a".repeat(39), &"a".repeat(41), &"g".repeat(40)] {
205 assert!(ObjectId::new(value).is_err(), "{value:?} should be refused");
206 }
207 }
208
209 #[test]
210 fn the_short_form_is_for_display_only() {
211 assert_eq!(ObjectId::new(SHA1).expect("valid").short(), "a1b2c3d");
212 }
213
214 #[test]
215 fn git_modes_map_to_kinds() {
216 for (mode, expected) in [
217 ("040000", EntryKind::Tree),
218 ("40000", EntryKind::Tree),
219 ("100644", EntryKind::Blob),
220 ("100755", EntryKind::Blob),
221 ("120000", EntryKind::Symlink),
222 ("160000", EntryKind::Submodule),
223 ] {
224 assert_eq!(
225 EntryKind::from_mode(mode).expect("known mode"),
226 expected,
227 "mode {mode}"
228 );
229 }
230 }
231
232 #[test]
233 fn an_unknown_mode_is_an_error_not_a_default() {
234 // Defaulting would render a submodule as an empty file.
235 assert!(EntryKind::from_mode("100600").is_err());
236 assert!(EntryKind::from_mode("").is_err());
237 }
238
239 #[test]
240 fn a_listing_sorts_directories_first_then_case_insensitively_by_name() {
241 let entry = |name: &str, kind| TreeEntry {
242 name: name.to_owned(),
243 kind,
244 id: ObjectId::new(SHA1).expect("valid"),
245 size: None,
246 };
247
248 let mut entries = [
249 entry("README.md", EntryKind::Blob),
250 entry("src", EntryKind::Tree),
251 entry("Cargo.toml", EntryKind::Blob),
252 entry("migrations", EntryKind::Tree),
253 ];
254 entries.sort_by_key(TreeEntry::ordering_key);
255
256 assert_eq!(
257 entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
258 vec!["migrations", "src", "Cargo.toml", "README.md"]
259 );
260 }
261}