holger-server-lib 0.6.9

Holger server library: config, wiring, gRPC service, Rust API
//! Virtual **group** backend: a read-only repository that owns no storage and
//! aggregates an ordered list of MEMBER backends behind a single repo name — the
//! Nexus/Artifactory "group repository". A client hits one coordinate and holger
//! resolves across the members in declaration order (first hit wins), so a team
//! can expose e.g. `maven-all = [maven-releases, maven-snapshots, central-proxy]`
//! as one URL.
//!
//! Distinct from [`ProxyBackend`](crate::proxy::ProxyBackend): a proxy has a
//! WRITABLE primary plus pull-through upstreams (an upstream hit is cached into
//! the primary); a group has no primary, caches nothing, and is always read-only.
//! `put` is refused. `format` is the members' shared format (first member's —
//! groups are homogeneous by construction; an empty group is `Raw`).

use std::sync::Arc;

use traits::{ArchiveInfo, ArtifactEntry, ArtifactFormat, ArtifactId, RepositoryBackendTrait};

/// A read-only aggregate over ordered member backends (the "group repo").
pub struct GroupBackend {
    name: String,
    format: ArtifactFormat,
    members: Vec<Arc<dyn RepositoryBackendTrait>>,
}

impl GroupBackend {
    /// Build a group named `name` over `members` (resolved in order). The group's
    /// `format` is the first member's (members are expected homogeneous); an empty
    /// group reports [`ArtifactFormat::Raw`] and resolves nothing.
    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()
    }

    /// A group never accepts writes — it is a view over its members.
    fn is_writable(&self) -> bool {
        false
    }

    /// True if ANY member is archive-backed (so the Archive tab has something to
    /// browse across the group).
    fn has_archive(&self) -> bool {
        self.members.iter().any(|m| m.has_archive())
    }

    /// First member that holds the artifact wins (declaration order).
    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
        ))
    }

    /// Walk the members in order, returning the first non-404 response (the group
    /// resolution). If every member 404s (or the group is empty), return a 404.
    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)
    }

    /// Merge each member's listing, de-duplicating by full coordinate
    /// (namespace + name + version) so an artifact present in two members is
    /// listed once (the first member's entry wins). Respects `limit` across the
    /// merged result.
    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)
    }

    /// Concatenate members' raw archive file listings, de-duplicated.
    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)
    }

    /// Sum the members' archive stats (file count + uncompressed bytes) under the
    /// group name.
    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;

    /// A tiny in-memory member: holds a set of (id → bytes) and an HTTP path map.
    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)],
        );
        // x/1 is in both — the FIRST member wins.
        assert_eq!(group.fetch(&id("x", "1")).unwrap().as_deref(), Some(b"from-a".as_slice()));
        // y/2 only in the second member — still resolved.
        assert_eq!(group.fetch(&id("y", "2")).unwrap().as_deref(), Some(b"y-from-b".as_slice()));
        // absent everywhere → None.
        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");
        // nothing serves this path → 404.
        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") // dup coord
            .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);
    }
}