1use std::sync::Arc;
2
3use shardline_protocol::{RepositoryProvider, RepositoryScope};
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7pub struct RepositoryScopeCacheKey {
8 provider: &'static str,
9 owner: Arc<str>,
10 repo: Arc<str>,
11 revision: Option<Arc<str>>,
12}
13
14impl RepositoryScopeCacheKey {
15 #[must_use]
17 pub fn from_scope(scope: &RepositoryScope) -> Self {
18 Self {
19 provider: provider_token(scope.provider()),
20 owner: Arc::from(scope.owner()),
21 repo: Arc::from(scope.name()),
22 revision: scope.revision().map(Arc::from),
23 }
24 }
25
26 #[must_use]
28 pub const fn provider(&self) -> &str {
29 self.provider
30 }
31
32 #[must_use]
34 pub fn owner(&self) -> &str {
35 &self.owner
36 }
37
38 #[must_use]
40 pub fn repo(&self) -> &str {
41 &self.repo
42 }
43
44 #[must_use]
46 pub fn revision(&self) -> Option<&str> {
47 self.revision.as_deref()
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
53pub struct ReconstructionCacheKey {
54 file_id: String,
55 content_hash: Option<String>,
56 repository_scope: Option<RepositoryScopeCacheKey>,
57}
58
59impl ReconstructionCacheKey {
60 #[must_use]
62 pub fn latest(file_id: &str, repository_scope: Option<&RepositoryScope>) -> Self {
63 Self {
64 file_id: file_id.to_owned(),
65 content_hash: None,
66 repository_scope: repository_scope.map(RepositoryScopeCacheKey::from_scope),
67 }
68 }
69
70 #[must_use]
72 pub fn version(
73 file_id: &str,
74 content_hash: &str,
75 repository_scope: Option<&RepositoryScope>,
76 ) -> Self {
77 Self {
78 file_id: file_id.to_owned(),
79 content_hash: Some(content_hash.to_owned()),
80 repository_scope: repository_scope.map(RepositoryScopeCacheKey::from_scope),
81 }
82 }
83
84 #[must_use]
86 pub fn file_id(&self) -> &str {
87 &self.file_id
88 }
89
90 #[must_use]
92 pub fn content_hash(&self) -> Option<&str> {
93 self.content_hash.as_deref()
94 }
95
96 #[must_use]
98 pub const fn repository_scope(&self) -> Option<&RepositoryScopeCacheKey> {
99 self.repository_scope.as_ref()
100 }
101}
102
103const fn provider_token(provider: RepositoryProvider) -> &'static str {
104 match provider {
105 RepositoryProvider::GitHub => "github",
106 RepositoryProvider::Gitea => "gitea",
107 RepositoryProvider::GitLab => "gitlab",
108 RepositoryProvider::Codeberg => "codeberg",
109 RepositoryProvider::Generic => "generic",
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use shardline_protocol::{RepositoryProvider, RepositoryScope};
116
117 use super::{ReconstructionCacheKey, RepositoryScopeCacheKey, provider_token};
118
119 #[test]
120 fn cache_key_keeps_version_and_scope_components() {
121 let scope =
122 RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
123 assert!(scope.is_ok());
124 let Ok(scope) = scope else {
125 return;
126 };
127
128 let latest = ReconstructionCacheKey::latest("asset.bin", Some(&scope));
129 let version = ReconstructionCacheKey::version("asset.bin", "deadbeef", Some(&scope));
130
131 assert_eq!(latest.file_id(), "asset.bin");
132 assert_eq!(latest.content_hash(), None);
133 assert_eq!(version.content_hash(), Some("deadbeef"));
134 let latest_scope = latest.repository_scope();
135 assert!(latest_scope.is_some());
136 let Some(latest_scope) = latest_scope else {
137 return;
138 };
139 assert_eq!(latest_scope.owner(), "team");
140 assert_eq!(latest_scope.repo(), "assets");
141 assert_eq!(latest_scope.provider(), "github");
142 assert_eq!(latest_scope.revision(), Some("main"));
143 }
144
145 #[test]
148 fn cache_key_without_scope() {
149 let latest = ReconstructionCacheKey::latest("file.bin", None);
150 assert_eq!(latest.file_id(), "file.bin");
151 assert_eq!(latest.content_hash(), None);
152 assert!(latest.repository_scope().is_none());
153 }
154
155 #[test]
156 fn cache_key_version_without_scope() {
157 let version = ReconstructionCacheKey::version("file.bin", "abc123", None);
158 assert_eq!(version.file_id(), "file.bin");
159 assert_eq!(version.content_hash(), Some("abc123"));
160 assert!(version.repository_scope().is_none());
161 }
162
163 #[test]
166 fn provider_token_all_variants() {
167 assert_eq!(provider_token(RepositoryProvider::GitHub), "github");
168 assert_eq!(provider_token(RepositoryProvider::Gitea), "gitea");
169 assert_eq!(provider_token(RepositoryProvider::GitLab), "gitlab");
170 assert_eq!(provider_token(RepositoryProvider::Codeberg), "codeberg");
171 assert_eq!(provider_token(RepositoryProvider::Generic), "generic");
172 }
173
174 #[allow(clippy::unwrap_used)]
177 #[test]
178 fn cache_key_scope_without_revision() {
179 let scope = RepositoryScope::new(RepositoryProvider::GitLab, "group", "project", None);
180 assert!(scope.is_ok());
181 let Ok(scope) = scope else {
182 return;
183 };
184 let latest = ReconstructionCacheKey::latest("doc.pdf", Some(&scope));
185 let scope_key = latest.repository_scope().unwrap();
186 assert_eq!(scope_key.provider(), "gitlab");
187 assert_eq!(scope_key.owner(), "group");
188 assert_eq!(scope_key.repo(), "project");
189 assert_eq!(scope_key.revision(), None);
190 }
191
192 #[test]
195 fn repository_scope_cache_key_from_scope() {
196 let scope = RepositoryScope::new(RepositoryProvider::Codeberg, "user", "repo", Some("v1"));
197 assert!(scope.is_ok());
198 let Ok(scope) = scope else {
199 return;
200 };
201 let scope_key = RepositoryScopeCacheKey::from_scope(&scope);
202 assert_eq!(scope_key.provider(), "codeberg");
203 assert_eq!(scope_key.owner(), "user");
204 assert_eq!(scope_key.repo(), "repo");
205 assert_eq!(scope_key.revision(), Some("v1"));
206 }
207
208 #[allow(clippy::unwrap_used)]
211 #[test]
212 fn cache_key_latest_with_each_provider() {
213 let providers = [
214 (RepositoryProvider::GitHub, "github"),
215 (RepositoryProvider::GitLab, "gitlab"),
216 (RepositoryProvider::Gitea, "gitea"),
217 (RepositoryProvider::Codeberg, "codeberg"),
218 (RepositoryProvider::Generic, "generic"),
219 ];
220 for (provider, expected_token) in &providers {
221 let scope = RepositoryScope::new(*provider, "owner", "repo", Some("branch"));
222 assert!(scope.is_ok(), "scope creation failed for {expected_token}");
223 let Ok(scope) = scope else {
224 continue;
225 };
226 let key = ReconstructionCacheKey::latest("file.bin", Some(&scope));
227 let scope_key = key.repository_scope().unwrap();
228 assert_eq!(scope_key.provider(), *expected_token);
229 assert_eq!(scope_key.owner(), "owner");
230 assert_eq!(scope_key.repo(), "repo");
231 assert_eq!(scope_key.revision(), Some("branch"));
232 assert_eq!(key.file_id(), "file.bin");
233 assert_eq!(key.content_hash(), None);
234 }
235 }
236
237 #[allow(clippy::unwrap_used)]
240 #[test]
241 fn cache_key_version_with_each_provider() {
242 let providers = [
243 (RepositoryProvider::GitHub, "github"),
244 (RepositoryProvider::GitLab, "gitlab"),
245 (RepositoryProvider::Gitea, "gitea"),
246 (RepositoryProvider::Codeberg, "codeberg"),
247 (RepositoryProvider::Generic, "generic"),
248 ];
249 for (provider, expected_token) in &providers {
250 let scope = RepositoryScope::new(*provider, "org", "project", None);
251 assert!(scope.is_ok(), "scope creation failed for {expected_token}");
252 let Ok(scope) = scope else {
253 continue;
254 };
255 let key = ReconstructionCacheKey::version("ver.bin", "hash123", Some(&scope));
256 let scope_key = key.repository_scope().unwrap();
257 assert_eq!(scope_key.provider(), *expected_token);
258 assert_eq!(scope_key.owner(), "org");
259 assert_eq!(scope_key.repo(), "project");
260 assert_eq!(scope_key.revision(), None);
261 assert_eq!(key.file_id(), "ver.bin");
262 assert_eq!(key.content_hash(), Some("hash123"));
263 }
264 }
265
266 #[test]
269 fn cache_key_latest_with_empty_file_id() {
270 let key = ReconstructionCacheKey::latest("", None);
271 assert_eq!(key.file_id(), "");
272 assert_eq!(key.content_hash(), None);
273 assert!(key.repository_scope().is_none());
274 }
275
276 #[test]
277 fn cache_key_version_with_empty_content_hash() {
278 let key = ReconstructionCacheKey::version("f", "", None);
279 assert_eq!(key.file_id(), "f");
280 assert_eq!(key.content_hash(), Some(""));
281 assert!(key.repository_scope().is_none());
282 }
283
284 #[allow(clippy::unwrap_used)]
285 #[test]
286 fn cache_key_version_with_each_provider_no_revision() {
287 let providers = [
288 RepositoryProvider::GitHub,
289 RepositoryProvider::GitLab,
290 RepositoryProvider::Gitea,
291 RepositoryProvider::Codeberg,
292 RepositoryProvider::Generic,
293 ];
294 for provider in &providers {
295 let scope = RepositoryScope::new(*provider, "ns", "name", None);
296 assert!(scope.is_ok(), "scope creation failed for {provider:?}");
297 let Ok(scope) = scope else {
298 continue;
299 };
300 let key = ReconstructionCacheKey::version("v.bin", "abc", Some(&scope));
301 let scope_key = key.repository_scope().unwrap();
302 assert_eq!(
303 scope_key.revision(),
304 None,
305 "revision should be None for {provider:?}"
306 );
307 }
308 }
309
310 #[test]
311 fn repository_scope_cache_key_from_scope_with_all_providers() {
312 for provider in &[
313 RepositoryProvider::GitHub,
314 RepositoryProvider::GitLab,
315 RepositoryProvider::Gitea,
316 RepositoryProvider::Codeberg,
317 RepositoryProvider::Generic,
318 ] {
319 let scope = RepositoryScope::new(*provider, "owner", "repo", Some("main"));
320 assert!(scope.is_ok(), "scope creation failed for {provider:?}");
321 let Ok(scope) = scope else {
322 continue;
323 };
324 let scope_key = RepositoryScopeCacheKey::from_scope(&scope);
325 assert_eq!(scope_key.owner(), "owner");
326 assert_eq!(scope_key.repo(), "repo");
327 assert_eq!(scope_key.revision(), Some("main"));
328 }
329 }
330
331 #[test]
332 fn repository_scope_cache_key_from_scope_without_revision() {
333 for provider in &[
334 RepositoryProvider::GitHub,
335 RepositoryProvider::GitLab,
336 RepositoryProvider::Gitea,
337 RepositoryProvider::Codeberg,
338 RepositoryProvider::Generic,
339 ] {
340 let scope = RepositoryScope::new(*provider, "own", "proj", None);
341 assert!(scope.is_ok(), "scope creation failed for {provider:?}");
342 let Ok(scope) = scope else {
343 continue;
344 };
345 let scope_key = RepositoryScopeCacheKey::from_scope(&scope);
346 assert_eq!(scope_key.owner(), "own");
347 assert_eq!(scope_key.repo(), "proj");
348 assert_eq!(scope_key.revision(), None);
349 }
350 }
351
352 #[test]
353 fn cache_key_latest_with_special_chars_in_file_id() {
354 let key = ReconstructionCacheKey::latest("file with spaces!@#.bin", None);
355 assert_eq!(key.file_id(), "file with spaces!@#.bin");
356 assert_eq!(key.content_hash(), None);
357 }
358
359 #[test]
360 fn cache_key_version_with_special_chars_in_hash() {
361 let key = ReconstructionCacheKey::version("f", "hash/with/slashes?", None);
362 assert_eq!(key.content_hash(), Some("hash/with/slashes?"));
363 }
364
365 #[test]
366 fn cache_key_latest_long_file_id() {
367 let long_id = "a".repeat(1000);
368 let key = ReconstructionCacheKey::latest(&long_id, None);
369 assert_eq!(key.file_id().len(), 1000);
370 }
371
372 #[test]
373 fn cache_key_repository_scope_accessors() {
374 let scope = RepositoryScope::new(RepositoryProvider::GitHub, "org", "repo", Some("dev"));
375 assert!(scope.is_ok());
376 let Ok(scope) = scope else {
377 return;
378 };
379 let scope_key = RepositoryScopeCacheKey::from_scope(&scope);
380 assert_eq!(scope_key.provider(), "github");
381 assert_eq!(scope_key.owner(), "org");
382 assert_eq!(scope_key.repo(), "repo");
383 assert_eq!(scope_key.revision(), Some("dev"));
384 }
385}