steid

@jamesgill /

steid/src/domain/object.rs
7.4 KBCode·Blame·Raw
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;
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/// A commit, reduced to what a log entry shows.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct CommitSummary {
153 pub id: ObjectId,
154 /// The first line of the message.
155 pub summary: String,
156 pub author_name: String,
157 pub committed_at: SystemTime,
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 const SHA1: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
165
166 #[test]
167 fn both_hash_widths_are_accepted() {
168 assert!(ObjectId::new(SHA1).is_ok());
169 assert!(ObjectId::new("a".repeat(64)).is_ok());
170 }
171
172 #[test]
173 fn an_id_is_lowercased_so_two_spellings_compare_equal() {
174 assert_eq!(
175 ObjectId::new(SHA1.to_uppercase()).expect("valid"),
176 ObjectId::new(SHA1).expect("valid")
177 );
178 }
179
180 #[test]
181 fn a_wrong_width_or_non_hex_id_is_refused() {
182 for value in ["", "abc", &"a".repeat(39), &"a".repeat(41), &"g".repeat(40)] {
183 assert!(ObjectId::new(value).is_err(), "{value:?} should be refused");
184 }
185 }
186
187 #[test]
188 fn the_short_form_is_for_display_only() {
189 assert_eq!(ObjectId::new(SHA1).expect("valid").short(), "a1b2c3d");
190 }
191
192 #[test]
193 fn git_modes_map_to_kinds() {
194 for (mode, expected) in [
195 ("040000", EntryKind::Tree),
196 ("40000", EntryKind::Tree),
197 ("100644", EntryKind::Blob),
198 ("100755", EntryKind::Blob),
199 ("120000", EntryKind::Symlink),
200 ("160000", EntryKind::Submodule),
201 ] {
202 assert_eq!(
203 EntryKind::from_mode(mode).expect("known mode"),
204 expected,
205 "mode {mode}"
206 );
207 }
208 }
209
210 #[test]
211 fn an_unknown_mode_is_an_error_not_a_default() {
212 // Defaulting would render a submodule as an empty file.
213 assert!(EntryKind::from_mode("100600").is_err());
214 assert!(EntryKind::from_mode("").is_err());
215 }
216
217 #[test]
218 fn a_listing_sorts_directories_first_then_case_insensitively_by_name() {
219 let entry = |name: &str, kind| TreeEntry {
220 name: name.to_owned(),
221 kind,
222 id: ObjectId::new(SHA1).expect("valid"),
223 size: None,
224 };
225
226 let mut entries = [
227 entry("README.md", EntryKind::Blob),
228 entry("src", EntryKind::Tree),
229 entry("Cargo.toml", EntryKind::Blob),
230 entry("migrations", EntryKind::Tree),
231 ];
232 entries.sort_by_key(TreeEntry::ordering_key);
233
234 assert_eq!(
235 entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
236 vec!["migrations", "src", "Cargo.toml", "README.md"]
237 );
238 }
239}