Skip to main content

shardline_cache/
key.rs

1use std::sync::Arc;
2
3use shardline_protocol::{RepositoryProvider, RepositoryScope};
4
5/// Repository scope material embedded into a cache key.
6#[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    /// Creates a repository scope cache key from a protocol scope.
16    #[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    /// Returns the repository provider.
27    #[must_use]
28    pub const fn provider(&self) -> &str {
29        self.provider
30    }
31
32    /// Returns the repository owner or namespace.
33    #[must_use]
34    pub fn owner(&self) -> &str {
35        &self.owner
36    }
37
38    /// Returns the repository name.
39    #[must_use]
40    pub fn repo(&self) -> &str {
41        &self.repo
42    }
43
44    /// Returns the optional scoped revision.
45    #[must_use]
46    pub fn revision(&self) -> Option<&str> {
47        self.revision.as_deref()
48    }
49}
50
51/// Stable cache key for one file reconstruction response.
52#[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    /// Creates a cache key for the latest visible reconstruction of one file.
61    #[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    /// Creates a cache key for one immutable file version.
71    #[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    /// Returns the file identifier.
85    #[must_use]
86    pub fn file_id(&self) -> &str {
87        &self.file_id
88    }
89
90    /// Returns the optional immutable content hash.
91    #[must_use]
92    pub fn content_hash(&self) -> Option<&str> {
93        self.content_hash.as_deref()
94    }
95
96    /// Returns the optional repository scope.
97    #[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    // ── None scope ───────────────────────────────────────────────────────
146
147    #[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    // ── All provider tokens ──────────────────────────────────────────────
164
165    #[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    // ── Scope with no revision ───────────────────────────────────────────
175
176    #[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    // ── RepositoryScopeCacheKey from_scope ───────────────────────────────
193
194    #[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    // ── latest with all provider combinations ────────────────────────────
209
210    #[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    // ── version with all provider combinations ───────────────────────────
238
239    #[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    // ── empty values in constructors ─────────────────────────────────────
267
268    #[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}