holger-python-znippy-repository 0.5.0

Holger guards your artifacts at rest. May Allfather Odin watch over every bit.
use std::collections::BTreeSet;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;

use anyhow::{anyhow, Result};

use holger_traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait};
use znippy_common::{ZnippyArchive, ZnippyReader};

/// znippy-backed Python package index implementing PEP 503.
///
/// Serves packages from any `ZnippyReader` backend.
/// Files stored as: `packages/{normalized-name}/{filename}`.
pub struct PipRepoZnippy {
    pub name: String,
    reader: Option<Arc<dyn ZnippyReader>>,
}

// Manual Debug impl because dyn ZnippyReader does not implement Debug.
impl fmt::Debug for PipRepoZnippy {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("PipRepoZnippy")
            .field("name", &self.name)
            .field("reader", &self.reader.as_ref().map(|_| "<dyn ZnippyReader>"))
            .finish()
    }
}

/// Normalize a package name per PEP 503: lowercase and replace `_` / `.` with `-`.
fn normalize_name(s: &str) -> String {
    s.to_lowercase().replace(['_', '.'], "-")
}

impl PipRepoZnippy {
    pub fn new(name: String) -> Self {
        Self { name, reader: None }
    }

    /// Create a repo backed by a specific znippy archive file
    pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
        let archive = ZnippyArchive::open(&archive_path)?;
        Ok(Self {
            name,
            reader: Some(Arc::new(archive)),
        })
    }

    /// Create a repo backed by any ZnippyReader implementation
    pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
        Self {
            name,
            reader: Some(reader),
        }
    }

    pub fn list_files(&self) -> Vec<String> {
        match &self.reader {
            Some(r) => r.list_files().unwrap_or_default(),
            None => vec![],
        }
    }

    pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
        let reader = self.reader.as_ref()
            .ok_or_else(|| anyhow!("No reader configured"))?;
        reader.extract_file(relative_path)
    }
}

impl RepositoryBackendTrait for PipRepoZnippy {
    fn name(&self) -> &str {
        &self.name
    }

    fn handle_http2_request(
        &self,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        let _ = body;
        log::debug!("Pip repo znippy handle_http2_request.suburl={}", suburl);

        // Strip leading repo name prefix: /{repo}/simple/... or /{repo}/packages/...
        let path = suburl.trim_start_matches('/');
        let remainder = match path.strip_prefix(&self.name) {
            Some(r) => r.trim_start_matches('/'),
            None => path,
        };

        // Split on '/' and drop empty segments (handles trailing slashes).
        let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();

        match parts.as_slice() {
            // GET /{repo}/simple/  →  list all unique package names
            ["simple"] => {
                let files = self.list_files();
                let mut names: BTreeSet<String> = BTreeSet::new();
                for f in &files {
                    if let Some(rest) = f.strip_prefix("packages/") {
                        if let Some(slash_pos) = rest.find('/') {
                            let pkg_name = &rest[..slash_pos];
                            if !pkg_name.is_empty() {
                                names.insert(pkg_name.to_string());
                            }
                        }
                    }
                }

                let mut links = String::new();
                for n in &names {
                    links.push_str(&format!("<a href=\"{}/\">{}</a><br>\n", n, n));
                }
                let html = format!(
                    "<!DOCTYPE html>\n<html><head><title>Simple Index</title></head>\n<body>\n{}</body></html>",
                    links
                );
                Ok((
                    200,
                    vec![("Content-Type".into(), "text/html".into())],
                    html.into_bytes(),
                ))
            }

            // GET /{repo}/simple/{name}/  →  list all files for the package
            ["simple", name] => {
                let normalized = normalize_name(name);
                let prefix = format!("packages/{}/", normalized);
                let files = self.list_files();

                let mut links = String::new();
                for f in &files {
                    if let Some(filename) = f.strip_prefix(&prefix) {
                        // Skip nested directories
                        if !filename.is_empty() && !filename.contains('/') {
                            let href = format!("/{}/{}", self.name, f);
                            links.push_str(&format!(
                                "<a href=\"{}\">{}</a><br>\n",
                                href, filename
                            ));
                        }
                    }
                }

                let html = format!(
                    "<!DOCTYPE html>\n<html><head><title>Links for {name}</title></head>\n<body><h1>Links for {name}</h1>\n{links}</body></html>",
                    name = name,
                    links = links,
                );
                Ok((
                    200,
                    vec![("Content-Type".into(), "text/html".into())],
                    html.into_bytes(),
                ))
            }

            // GET /{repo}/packages/{name}/{filename}  →  serve file from archive
            ["packages", name, filename] => {
                let normalized = normalize_name(name);
                let archive_path = format!("packages/{}/{}", normalized, filename);
                match self.get_file(&archive_path) {
                    Ok(data) => {
                        let content_type = if filename.ends_with(".whl") {
                            "application/zip"
                        } else if filename.ends_with(".tar.gz") {
                            "application/gzip"
                        } else if filename.ends_with(".zip") {
                            "application/zip"
                        } else {
                            "application/octet-stream"
                        };
                        Ok((
                            200,
                            vec![("Content-Type".into(), content_type.into())],
                            data,
                        ))
                    }
                    Err(_) => Ok((404, Vec::new(), b"Not found in archive".to_vec())),
                }
            }

            _ => Ok((404, Vec::new(), b"Not found".to_vec())),
        }
    }

    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Pip
    }

    fn is_writable(&self) -> bool {
        false
    }

    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
        // id.namespace = None, id.name = "requests", id.version = "2.31.0"
        // Try: packages/requests/requests-2.31.0.tar.gz
        let canonical = format!("packages/{}/{}-{}.tar.gz", id.name, id.name, id.version);
        if let Ok(data) = self.get_file(&canonical) {
            return Ok(Some(data));
        }

        // Fallback: list all files under packages/{name}/ and return the first
        // whose filename contains the requested version string.
        let prefix = format!("packages/{}/", normalize_name(&id.name));
        for f in self.list_files() {
            if f.starts_with(&prefix) {
                if let Some(filename) = f.strip_prefix(&prefix) {
                    if filename.contains(id.version.as_str()) {
                        if let Ok(data) = self.get_file(&f) {
                            return Ok(Some(data));
                        }
                    }
                }
            }
        }

        Ok(None)
    }

    fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
        Err(anyhow!("Pip znippy repository is read-only"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_new() {
        let repo = PipRepoZnippy::new("pip-test".to_string());
        assert_eq!(repo.name(), "pip-test");
        assert!(repo.list_files().is_empty());
    }

    #[test]
    fn test_readonly() {
        let repo = PipRepoZnippy::new("pip-test".to_string());
        assert!(!repo.is_writable());
        let id = ArtifactId {
            namespace: None,
            name: "requests".to_string(),
            version: "2.31.0".to_string(),
        };
        assert!(repo.put(&id, b"data").is_err());
    }

    #[test]
    fn test_format() {
        let repo = PipRepoZnippy::new("pip-test".to_string());
        assert_eq!(repo.format(), ArtifactFormat::Pip);
    }

    #[test]
    fn test_normalize_name() {
        assert_eq!(normalize_name("Requests"), "requests");
        assert_eq!(normalize_name("my_package"), "my-package");
        assert_eq!(normalize_name("my.package"), "my-package");
        assert_eq!(normalize_name("My_Package.Name"), "my-package-name");
    }
}