use influxdb3_plugin_schemas::{ArtifactHash, Index, IndexEntry, PublishedAt};
use std::path::Path;
use crate::{SdkError, archive, hash, mutate_index, validate};
#[derive(Debug)]
pub struct PackageOutput {
pub archive_bytes: Vec<u8>,
pub hash: ArtifactHash,
pub derived_index: Index,
pub new_entry: IndexEntry,
}
pub fn package_plugin(plugin_dir: &Path, input_index: Index) -> Result<PackageOutput, SdkError> {
package_plugin_with_published_at(plugin_dir, input_index, PublishedAt::now_utc())
}
fn package_plugin_with_published_at(
plugin_dir: &Path,
input_index: Index,
published_at: PublishedAt,
) -> Result<PackageOutput, SdkError> {
let manifest = validate::plugin_dir(plugin_dir)?.manifest;
let archive_bytes = archive::canonical_tar_gz(
plugin_dir,
&manifest.plugin.name,
&manifest.plugin.version,
&manifest.plugin.exclude,
)?;
let hash_value = hash::sha256_of_bytes(&archive_bytes);
let new_entry =
IndexEntry::from_manifest_with_published_at(manifest, hash_value.clone(), published_at);
let mut derived_index = input_index;
mutate_index::add_entry(&mut derived_index, new_entry.clone())?;
Ok(PackageOutput {
archive_bytes,
hash: hash_value,
derived_index,
new_entry,
})
}
#[cfg(test)]
mod tests {
use super::*;
use influxdb3_plugin_schemas::{ArtifactsUrl, IndexSchemaVersion};
use std::fs;
fn write_valid_plugin(dir: &Path) {
fs::create_dir_all(dir).unwrap();
fs::write(
dir.join("manifest.toml"),
"manifest_schema_version = \"1.0\"\n\n\
[plugin]\n\
name = \"downsampler\"\n\
version = \"1.2.0\"\n\
description = \"Test plugin\"\n\
triggers = [\"process_writes\"]\n\n\
[dependencies]\n\
database_version = \">=3.0.0\"\n\
python = [\"requests>=2.31,<3\"]\n",
)
.unwrap();
fs::write(
dir.join("__init__.py"),
"def process_writes(a, b, c):\n pass\n",
)
.unwrap();
}
fn empty_index() -> Index {
Index {
index_schema_version: IndexSchemaVersion::CURRENT,
artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
plugins: vec![],
}
}
#[test]
fn happy_path_populates_every_output_field() {
let td = tempfile::tempdir().unwrap();
let dir = td.path().join("downsampler");
write_valid_plugin(&dir);
let out = package_plugin(&dir, empty_index()).unwrap();
assert!(!out.archive_bytes.is_empty());
assert!(out.hash.as_str().starts_with("sha256:"));
assert_eq!(out.derived_index.plugins.len(), 1);
assert_eq!(out.new_entry.name.as_str(), "downsampler");
assert_eq!(
out.new_entry.version,
semver::Version::new(1, 2, 0),
"entry version should match manifest"
);
assert_eq!(out.new_entry.hash, out.hash, "entry hash matches computed");
assert_eq!(
out.derived_index.plugins[0].published_at, out.new_entry.published_at,
"new_entry and derived index must expose the same publication timestamp"
);
}
#[test]
fn happy_path_assigns_injected_published_at() {
let td = tempfile::tempdir().unwrap();
let dir = td.path().join("downsampler");
write_valid_plugin(&dir);
let published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
let out =
package_plugin_with_published_at(&dir, empty_index(), published_at.clone()).unwrap();
assert_eq!(out.new_entry.published_at, published_at);
assert_eq!(out.derived_index.plugins[0].published_at, published_at);
}
#[test]
fn entry_hash_matches_archive_bytes() {
let td = tempfile::tempdir().unwrap();
let dir = td.path().join("p");
write_valid_plugin(&dir);
let out = package_plugin(&dir, empty_index()).unwrap();
let recomputed = hash::sha256_of_bytes(&out.archive_bytes);
assert_eq!(out.hash, recomputed);
}
#[test]
fn duplicate_name_version_rejected_by_s2_2() {
let td = tempfile::tempdir().unwrap();
let dir = td.path().join("p");
write_valid_plugin(&dir);
let first = package_plugin(&dir, empty_index()).unwrap();
let err = package_plugin(&dir, first.derived_index).unwrap_err();
assert!(
matches!(err, SdkError::AlreadyPublished { .. }),
"expected AlreadyPublished, got {err:?}"
);
}
#[test]
fn validation_failure_short_circuits_pipeline() {
let td = tempfile::tempdir().unwrap();
let dir = td.path().join("p");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("manifest.toml"),
"manifest_schema_version = \"1.0\"\n\n\
[plugin]\nname = \"p\"\nversion = \"0.1.0\"\ndescription = \"x\"\ntriggers = [\"process_writes\"]\n\n\
[dependencies]\ndatabase_version = \">=3.0.0\"\n",
)
.unwrap();
fs::write(dir.join("__init__.py"), "def something_else():\n pass\n").unwrap();
let err = package_plugin(&dir, empty_index()).unwrap_err();
assert!(
matches!(err, SdkError::ValidationErrors(_)),
"expected ValidationErrors, got {err:?}"
);
}
#[test]
fn duplicate_error_does_not_mutate_clone_before_return() {
let td = tempfile::tempdir().unwrap();
let dir = td.path().join("p");
write_valid_plugin(&dir);
let first = package_plugin(&dir, empty_index()).unwrap();
let len_before = first.derived_index.plugins.len();
let snapshot = first.derived_index.clone();
let first_clone_for_compare = first.derived_index.clone();
let err = package_plugin(&dir, first.derived_index).unwrap_err();
assert!(matches!(err, SdkError::AlreadyPublished { .. }));
assert_eq!(
snapshot, first_clone_for_compare,
"snapshot must remain byte-identical; interior mutability would break this"
);
assert_eq!(snapshot.plugins.len(), len_before);
}
}