use core::marker::PhantomData;
use holger_plugin_abi::{
BlobStore, PackageHandler, WireArtifactEntry, WireArtifactId, WireHttpRequest,
WireHttpResponse, WireManifest, ABI_VERSION,
};
pub const BUNDLE_EXT: &str = ".znippy";
pub const BUNDLE_CONTENT_TYPE: &str = "application/vnd.znippy.bundle";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberDescription {
pub kind: String,
pub component: String,
pub encrypted: bool,
}
impl MemberDescription {
pub fn to_line(&self) -> String {
format!("kind\t{}\ncomponent\t{}\nencrypted\t{}", self.kind, self.component, self.encrypted)
}
}
pub trait BundleKind {
const HANDLER: &'static str;
const FORMAT: &'static str;
const WRITABLE: bool;
fn describe_member(path: &str) -> Option<MemberDescription>;
fn is_bundle_name(file_name: &str) -> bool;
}
pub struct BundleHandler<K: BundleKind>(PhantomData<K>);
impl<K: BundleKind> BundleHandler<K> {
pub const fn new() -> Self {
BundleHandler(PhantomData)
}
}
impl<K: BundleKind> Default for BundleHandler<K> {
fn default() -> Self {
Self::new()
}
}
impl<K: BundleKind> Clone for BundleHandler<K> {
fn clone(&self) -> Self {
Self::new()
}
}
pub fn store_key(id: &WireArtifactId) -> String {
format!("{}-{}{}", id.name, id.version, BUNDLE_EXT)
}
pub fn coordinate_from_key(key: &str) -> Option<WireArtifactId> {
let base = key.rsplit(['/', '\\']).next().unwrap_or(key);
let stem = base.strip_suffix(BUNDLE_EXT)?;
let (name, version) = stem.rsplit_once('-')?;
if name.is_empty() || version.is_empty() {
return None;
}
Some(WireArtifactId { namespace: None, name: name.to_string(), version: version.to_string() })
}
fn path_segments(suburl: &str) -> Vec<&str> {
let mut segs = suburl.split('/').filter(|s| !s.is_empty());
segs.next();
segs.collect()
}
fn id_of(name: &str, version: &str) -> WireArtifactId {
WireArtifactId { namespace: None, name: name.to_string(), version: version.to_string() }
}
impl<K: BundleKind> PackageHandler for BundleHandler<K> {
fn manifest(&self) -> WireManifest {
WireManifest {
abi_version: ABI_VERSION,
handler: K::HANDLER.to_string(),
format: K::FORMAT.to_string(),
writable: K::WRITABLE,
}
}
fn fetch(&self, store: &dyn BlobStore, id: &WireArtifactId) -> Result<Option<Vec<u8>>, String> {
store.get(&store_key(id))
}
fn put(&self, store: &dyn BlobStore, id: &WireArtifactId, data: &[u8]) -> Result<(), String> {
if !K::WRITABLE {
return Err(format!("{}: repository is read-only", K::HANDLER));
}
if id.name.is_empty() || id.version.is_empty() {
return Err(format!(
"{}: refusing to store a bundle with an empty name or version \
(name={:?}, version={:?})",
K::HANDLER,
id.name,
id.version
));
}
store.put(&store_key(id), data)
}
fn list(
&self,
store: &dyn BlobStore,
name_filter: Option<&str>,
limit: usize,
) -> Result<Vec<WireArtifactEntry>, String> {
let mut out = Vec::new();
let mut rows = store.list("")?;
rows.sort_by(|a, b| a.0.cmp(&b.0));
for (key, size) in rows {
if out.len() >= limit {
break;
}
let base = key.rsplit(['/', '\\']).next().unwrap_or(&key);
if !K::is_bundle_name(base) {
continue;
}
let Some(id) = coordinate_from_key(&key) else {
continue;
};
if let Some(f) = name_filter {
if !id.name.contains(f) {
continue;
}
}
out.push(WireArtifactEntry {
id,
size_bytes: size.min(i64::MAX as u64) as i64,
content_type: BUNDLE_CONTENT_TYPE.to_string(),
});
}
Ok(out)
}
fn coordinate_for_path(&self, suburl: &str) -> Option<WireArtifactId> {
let segs = path_segments(suburl);
match segs.as_slice() {
[name, version] => Some(id_of(name, version)),
[name, version, "classify", ..] => Some(id_of(name, version)),
_ => None,
}
}
fn http(&self, store: &dyn BlobStore, req: &WireHttpRequest) -> Result<WireHttpResponse, String> {
let segs = path_segments(&req.suburl);
match (req.method.as_str(), segs.as_slice()) {
("GET", []) => {
let mut names: Vec<String> = store
.list("")?
.into_iter()
.map(|(k, _)| k)
.filter(|k| K::is_bundle_name(k.rsplit(['/', '\\']).next().unwrap_or(k)))
.collect();
names.sort();
Ok(text(200, names.join("\n")))
}
("GET", [name, version]) => {
let id = id_of(name, version);
match store.get(&store_key(&id))? {
Some(body) => Ok(WireHttpResponse {
status: 200,
headers: vec![
("content-type".into(), BUNDLE_CONTENT_TYPE.into()),
("content-length".into(), body.len().to_string()),
],
body,
}),
None => Ok(text(404, format!("no such bundle: {}", store_key(&id)))),
}
}
("GET", [_name, _version, "classify", rest @ ..]) => {
if rest.is_empty() {
return Ok(text(400, "classify needs a bundle-relative path"));
}
let member_path = rest.join("/");
match K::describe_member(&member_path) {
Some(d) => Ok(text(200, d.to_line())),
None => Ok(text(
404,
format!("{}: '{member_path}' is not a recognised bundle member", K::HANDLER),
)),
}
}
("PUT", [name, version]) => {
let id = id_of(name, version);
self.put(store, &id, &req.body)?;
Ok(text(201, format!("stored {}", store_key(&id))))
}
(m, _) => Ok(text(405, format!("{}: {m} {} is not a bundle route", K::HANDLER, req.suburl))),
}
}
}
fn text(status: u16, body: impl Into<String>) -> WireHttpResponse {
let body = body.into().into_bytes();
WireHttpResponse {
status,
headers: vec![
("content-type".into(), "text/plain; charset=utf-8".into()),
("content-length".into(), body.len().to_string()),
],
body,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hyphenated_bundle_name_keeps_its_hyphens() {
let id = coordinate_from_key("rust-dev-rhel8-1.97.1.znippy").expect("parsed");
assert_eq!(id.name, "rust-dev-rhel8", "split at the FIRST hyphen instead of the last");
assert_eq!(id.version, "1.97.1");
}
#[test]
fn store_key_and_coordinate_are_inverses() {
for (name, version) in
[("tillsynia", "20260706"), ("rust-dev-rhel8", "1.97.1"), ("a", "0")]
{
let id = id_of(name, version);
let back = coordinate_from_key(&store_key(&id)).expect("round trip");
assert_eq!(back, id, "store_key/coordinate_from_key are not inverses for {name}");
}
}
#[test]
fn a_key_that_is_not_a_bundle_yields_no_coordinate() {
assert_eq!(coordinate_from_key("README.md"), None);
assert_eq!(coordinate_from_key("noversion.znippy"), None);
assert_eq!(coordinate_from_key("-1.0.znippy"), None, "empty name accepted");
assert_eq!(coordinate_from_key("x-.znippy"), None, "empty version accepted");
}
#[test]
fn the_repo_segment_is_never_part_of_a_coordinate() {
assert_eq!(path_segments("/bundles/tillsynia/20260706"), vec!["tillsynia", "20260706"]);
assert_eq!(path_segments("/bundles/"), Vec::<&str>::new());
}
}