use std::sync::Arc;
use traits::{ArchiveInfo, ArtifactEntry, ArtifactFormat, ArtifactId, RepositoryBackendTrait};
pub struct GroupBackend {
name: String,
format: ArtifactFormat,
members: Vec<Arc<dyn RepositoryBackendTrait>>,
}
impl GroupBackend {
pub fn new(name: String, members: Vec<Arc<dyn RepositoryBackendTrait>>) -> Self {
let format = members
.first()
.map(|m| m.format())
.unwrap_or(ArtifactFormat::Raw);
Self { name, format, members }
}
}
impl RepositoryBackendTrait for GroupBackend {
fn name(&self) -> &str {
&self.name
}
fn format(&self) -> ArtifactFormat {
self.format.clone()
}
fn is_writable(&self) -> bool {
false
}
fn has_archive(&self) -> bool {
self.members.iter().any(|m| m.has_archive())
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
for member in &self.members {
if let Some(data) = member.fetch(id)? {
return Ok(Some(data));
}
}
Ok(None)
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Err(anyhow::anyhow!(
"group repository '{}' is a read-only view — push to a member repo instead",
self.name
))
}
fn handle_http2_request(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
let mut last = (404u16, Vec::new(), b"Not found in group".to_vec());
for member in &self.members {
let resp = member.handle_http2_request(method, suburl, body)?;
if resp.0 != 404 {
return Ok(resp);
}
last = resp;
}
Ok(last)
}
fn list(&self, name_filter: Option<&str>, limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<ArtifactEntry> = Vec::new();
for member in &self.members {
for entry in member.list(name_filter, 0)? {
let key = (
entry.id.namespace.clone(),
entry.id.name.clone(),
entry.id.version.clone(),
);
if seen.insert(key) {
out.push(entry);
if limit != 0 && out.len() >= limit {
return Ok(out);
}
}
}
}
Ok(out)
}
fn archive_files(&self, prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for member in &self.members {
for f in member.archive_files(prefix)? {
if seen.insert(f.clone()) {
out.push(f);
}
}
}
Ok(out)
}
fn archive_info(&self) -> anyhow::Result<ArchiveInfo> {
let mut file_count = 0u64;
let mut total = 0u64;
for member in &self.members {
let info = member.archive_info()?;
file_count += info.file_count;
total += info.total_uncompressed_bytes;
}
Ok(ArchiveInfo {
file_count,
total_uncompressed_bytes: total,
archive_path: self.name.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct MemberRepo {
name: String,
format: ArtifactFormat,
blobs: Mutex<Vec<(ArtifactId, Vec<u8>)>>,
http: Mutex<Vec<(String, Vec<u8>)>>,
}
impl MemberRepo {
fn new(name: &str, format: ArtifactFormat) -> Self {
Self {
name: name.into(),
format,
blobs: Mutex::new(Vec::new()),
http: Mutex::new(Vec::new()),
}
}
fn with_blob(self, id: ArtifactId, data: &[u8]) -> Self {
self.blobs.lock().unwrap().push((id, data.to_vec()));
self
}
fn with_http(self, path: &str, data: &[u8]) -> Self {
self.http.lock().unwrap().push((path.into(), data.to_vec()));
self
}
}
impl RepositoryBackendTrait for MemberRepo {
fn name(&self) -> &str {
&self.name
}
fn format(&self) -> ArtifactFormat {
self.format.clone()
}
fn is_writable(&self) -> bool {
false
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
Ok(self
.blobs
.lock()
.unwrap()
.iter()
.find(|(i, _)| i == id)
.map(|(_, d)| d.clone()))
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Err(anyhow::anyhow!("read-only"))
}
fn list(&self, _f: Option<&str>, _l: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
Ok(self
.blobs
.lock()
.unwrap()
.iter()
.map(|(id, d)| ArtifactEntry {
id: id.clone(),
size_bytes: d.len() as i64,
content_type: "application/octet-stream".into(),
})
.collect())
}
fn handle_http2_request(
&self,
_m: &str,
suburl: &str,
_b: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
match self.http.lock().unwrap().iter().find(|(p, _)| p == suburl) {
Some((_, d)) => Ok((200, vec![], d.clone())),
None => Ok((404, vec![], b"nf".to_vec())),
}
}
}
fn id(name: &str, version: &str) -> ArtifactId {
ArtifactId { namespace: None, name: name.into(), version: version.into() }
}
#[test]
fn fetch_returns_first_member_hit_in_order() {
let a = MemberRepo::new("a", ArtifactFormat::Maven3).with_blob(id("x", "1"), b"from-a");
let b = MemberRepo::new("b", ArtifactFormat::Maven3)
.with_blob(id("x", "1"), b"from-b")
.with_blob(id("y", "2"), b"y-from-b");
let group = GroupBackend::new(
"maven-all".into(),
vec![Arc::new(a), Arc::new(b)],
);
assert_eq!(group.fetch(&id("x", "1")).unwrap().as_deref(), Some(b"from-a".as_slice()));
assert_eq!(group.fetch(&id("y", "2")).unwrap().as_deref(), Some(b"y-from-b".as_slice()));
assert_eq!(group.fetch(&id("z", "9")).unwrap(), None);
}
#[test]
fn http_returns_first_non_404_member() {
let a = MemberRepo::new("a", ArtifactFormat::Maven3);
let b = MemberRepo::new("b", ArtifactFormat::Maven3).with_http("/g/x", b"body-b");
let group = GroupBackend::new("g".into(), vec![Arc::new(a), Arc::new(b)]);
let (s, _h, body) = group.handle_http2_request("GET", "/g/x", &[]).unwrap();
assert_eq!(s, 200);
assert_eq!(body, b"body-b");
let (s, _h, _b) = group.handle_http2_request("GET", "/g/missing", &[]).unwrap();
assert_eq!(s, 404);
}
#[test]
fn list_merges_and_dedups_by_coordinate() {
let a = MemberRepo::new("a", ArtifactFormat::Maven3).with_blob(id("x", "1"), b"a");
let b = MemberRepo::new("b", ArtifactFormat::Maven3)
.with_blob(id("x", "1"), b"b") .with_blob(id("y", "2"), b"b");
let group = GroupBackend::new("g".into(), vec![Arc::new(a), Arc::new(b)]);
let listed = group.list(None, 0).unwrap();
assert_eq!(listed.len(), 2, "x/1 dedup'd across members, y/2 added");
assert!(listed.iter().any(|e| e.id.name == "x" && e.id.version == "1"));
assert!(listed.iter().any(|e| e.id.name == "y" && e.id.version == "2"));
}
#[test]
fn group_is_read_only_and_takes_member_format() {
let a = MemberRepo::new("a", ArtifactFormat::Npm);
let group = GroupBackend::new("g".into(), vec![Arc::new(a)]);
assert!(!group.is_writable());
assert_eq!(group.format(), ArtifactFormat::Npm, "group inherits its members' format");
assert!(group.put(&id("x", "1"), b"data").is_err(), "group refuses writes");
}
#[test]
fn empty_group_is_raw_and_resolves_nothing() {
let group = GroupBackend::new("empty".into(), vec![]);
assert_eq!(group.format(), ArtifactFormat::Raw);
assert_eq!(group.fetch(&id("x", "1")).unwrap(), None);
let (s, _h, _b) = group.handle_http2_request("GET", "/empty/x", &[]).unwrap();
assert_eq!(s, 404);
}
}