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};
pub struct PipRepoZnippy {
pub name: String,
reader: Option<Arc<dyn ZnippyReader>>,
}
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()
}
}
fn normalize_name(s: &str) -> String {
s.to_lowercase().replace(['_', '.'], "-")
}
impl PipRepoZnippy {
pub fn new(name: String) -> Self {
Self { name, reader: None }
}
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)),
})
}
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);
let path = suburl.trim_start_matches('/');
let remainder = match path.strip_prefix(&self.name) {
Some(r) => r.trim_start_matches('/'),
None => path,
};
let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
match parts.as_slice() {
["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(),
))
}
["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) {
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(),
))
}
["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>>> {
let canonical = format!("packages/{}/{}-{}.tar.gz", id.name, id.name, id.version);
if let Ok(data) = self.get_file(&canonical) {
return Ok(Some(data));
}
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");
}
}