use influxdb3_plugin_schemas::{Index, IndexEntry};
use semver::Version;
use crate::SdkError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum YankOutcome {
Transitioned,
AlreadyInDesiredState,
}
pub fn add_entry(idx: &mut Index, entry: IndexEntry) -> Result<(), SdkError> {
idx.push_entry(entry).map_err(SdkError::from)
}
pub fn yank(idx: &mut Index, name: &str, version: &Version) -> Result<YankOutcome, SdkError> {
set_yanked(idx, name, version, true)
}
pub fn unyank(idx: &mut Index, name: &str, version: &Version) -> Result<YankOutcome, SdkError> {
set_yanked(idx, name, version, false)
}
fn set_yanked(
idx: &mut Index,
name: &str,
version: &Version,
target: bool,
) -> Result<YankOutcome, SdkError> {
let entry = find_mut(idx, name, version)?;
if entry.yanked == target {
Ok(YankOutcome::AlreadyInDesiredState)
} else {
entry.yanked = target;
Ok(YankOutcome::Transitioned)
}
}
fn find_mut<'a>(
idx: &'a mut Index,
name: &str,
version: &Version,
) -> Result<&'a mut IndexEntry, SdkError> {
idx.plugins
.iter_mut()
.find(|e| e.name.as_str() == name && &e.version == version)
.ok_or_else(|| SdkError::EntryNotFound {
name: name.to_owned(),
version: version.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use influxdb3_plugin_schemas::{
ArtifactHash, ArtifactsUrl, Dependencies, Description, IndexEntry, IndexSchemaVersion,
PublishedAt, TriggerType,
};
use rstest::rstest;
fn empty_index() -> Index {
Index {
index_schema_version: IndexSchemaVersion::CURRENT,
artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
plugins: vec![],
}
}
fn make_entry(name: &str, version: Version) -> IndexEntry {
make_entry_with_published_at(name, version, "2026-04-29T18:45:12Z")
}
fn make_entry_with_published_at(
name: &str,
version: Version,
published_at: &str,
) -> IndexEntry {
IndexEntry {
name: name.parse().unwrap(),
version,
published_at: PublishedAt::try_new(published_at).unwrap(),
description: Description::try_new("desc").unwrap(),
triggers: vec![TriggerType::ProcessWrites],
homepage: None,
repository: None,
documentation: None,
dependencies: Dependencies {
database_version: ">=3.0.0".parse().unwrap(),
python: vec![],
},
hash: ArtifactHash::try_new(
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
)
.unwrap(),
yanked: false,
}
}
#[test]
fn add_entry_appends_to_empty() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
assert_eq!(idx.plugins.len(), 1);
}
#[test]
fn add_entry_rejects_duplicate_name_version() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
let err = add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap_err();
assert!(matches!(err, SdkError::AlreadyPublished { .. }));
assert_eq!(idx.plugins.len(), 1);
}
#[test]
fn add_entry_rejects_duplicate_name_version_even_with_different_published_at() {
let mut idx = empty_index();
add_entry(
&mut idx,
make_entry_with_published_at("a", Version::new(1, 0, 0), "2026-04-29T18:45:12Z"),
)
.unwrap();
let err = add_entry(
&mut idx,
make_entry_with_published_at("a", Version::new(1, 0, 0), "2027-01-02T03:04:05Z"),
)
.unwrap_err();
assert!(matches!(err, SdkError::AlreadyPublished { .. }));
assert_eq!(idx.plugins[0].published_at.as_str(), "2026-04-29T18:45:12Z");
}
#[test]
fn add_entry_duplicate_error_lists_every_existing_version() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
add_entry(&mut idx, make_entry("a", Version::new(1, 1, 0))).unwrap();
add_entry(&mut idx, make_entry("b", Version::new(2, 0, 0))).unwrap();
let err = add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap_err();
match err {
SdkError::AlreadyPublished {
name,
version,
existing_versions,
} => {
assert_eq!(name, "a");
assert_eq!(version, "1.0.0");
assert_eq!(
existing_versions,
vec!["1.0.0".to_owned(), "1.1.0".to_owned()],
"must enumerate every version of `a` in input order, omit other names"
);
}
other => panic!("expected AlreadyPublished, got {other:?}"),
}
}
#[test]
fn add_entry_allows_same_name_different_version() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
add_entry(&mut idx, make_entry("a", Version::new(1, 1, 0))).unwrap();
assert_eq!(idx.plugins.len(), 2);
}
#[test]
fn add_entry_rejects_hyphen_underscore_canonical_collision() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("my-plugin", Version::new(1, 0, 0))).unwrap();
let err = add_entry(&mut idx, make_entry("my_plugin", Version::new(1, 0, 0))).unwrap_err();
match err {
SdkError::CanonicalCollision {
name,
canonical,
existing,
} => {
assert_eq!(name, "my_plugin");
assert_eq!(canonical, "my_plugin");
assert_eq!(
existing,
vec![("my-plugin".to_owned(), Version::new(1, 0, 0))]
);
}
other => panic!("expected CanonicalCollision, got {other:?}"),
}
assert_eq!(idx.plugins.len(), 1, "no mutation on error");
}
#[test]
fn add_entry_rejects_case_canonical_collision() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("myplugin", Version::new(1, 0, 0))).unwrap();
let err = add_entry(&mut idx, make_entry("MyPlugin", Version::new(1, 0, 0))).unwrap_err();
match err {
SdkError::CanonicalCollision {
name,
canonical,
existing,
} => {
assert_eq!(name, "MyPlugin");
assert_eq!(canonical, "myplugin");
assert_eq!(
existing,
vec![("myplugin".to_owned(), Version::new(1, 0, 0))]
);
}
other => panic!("expected CanonicalCollision, got {other:?}"),
}
assert_eq!(idx.plugins.len(), 1, "no mutation on error");
}
#[rstest]
#[case::same_version(Version::new(1, 0, 0))]
#[case::different_version(Version::new(1, 0, 1))]
fn add_entry_returns_canonical_collision_when_spellings_differ(#[case] new_version: Version) {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("my_plugin", Version::new(1, 0, 0))).unwrap();
let err = add_entry(&mut idx, make_entry("my-plugin", new_version.clone())).unwrap_err();
match err {
SdkError::CanonicalCollision {
name,
canonical,
existing,
} => {
assert_eq!(name, "my-plugin");
assert_eq!(canonical, "my_plugin");
assert_eq!(
existing,
vec![("my_plugin".to_owned(), Version::new(1, 0, 0))]
);
}
other => panic!("expected CanonicalCollision, got {other:?}"),
}
assert_eq!(idx.plugins.len(), 1, "no mutation on error");
}
#[test]
fn yank_sets_flag() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
yank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
assert!(idx.plugins[0].yanked);
}
#[test]
fn yank_preserves_published_at() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
let before = idx.plugins[0].published_at.clone();
yank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
assert_eq!(idx.plugins[0].published_at, before);
}
#[test]
fn yank_is_idempotent() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
yank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
yank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
assert!(idx.plugins[0].yanked);
}
#[test]
fn yank_signals_transitioned_vs_already_in_desired_state() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
let first = yank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
assert_eq!(first, YankOutcome::Transitioned);
let second = yank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
assert_eq!(second, YankOutcome::AlreadyInDesiredState);
}
#[test]
fn unyank_signals_transitioned_vs_already_in_desired_state() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
let already = unyank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
assert_eq!(already, YankOutcome::AlreadyInDesiredState);
yank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
let transitioned = unyank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
assert_eq!(transitioned, YankOutcome::Transitioned);
}
#[test]
fn unyank_clears_flag() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
yank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
unyank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
assert!(!idx.plugins[0].yanked);
}
#[test]
fn unyank_preserves_published_at() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
yank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
let before = idx.plugins[0].published_at.clone();
unyank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap();
assert_eq!(idx.plugins[0].published_at, before);
}
#[test]
fn yank_returns_entry_not_found_for_missing_name() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
let err = yank(&mut idx, "zzz", &Version::new(1, 0, 0)).unwrap_err();
assert!(matches!(err, SdkError::EntryNotFound { .. }));
}
#[test]
fn yank_returns_entry_not_found_for_missing_version() {
let mut idx = empty_index();
add_entry(&mut idx, make_entry("a", Version::new(1, 0, 0))).unwrap();
let err = yank(&mut idx, "a", &Version::new(2, 0, 0)).unwrap_err();
assert!(matches!(err, SdkError::EntryNotFound { .. }));
}
#[test]
fn unyank_returns_entry_not_found_on_missing() {
let mut idx = empty_index();
let err = unyank(&mut idx, "a", &Version::new(1, 0, 0)).unwrap_err();
assert!(matches!(err, SdkError::EntryNotFound { .. }));
}
}