use crate::{Manifest, ReportedError, TriggerType};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ValidatedPluginDefinition {
pub manifest: Manifest,
pub entry_point: EntryPoint,
}
impl ValidatedPluginDefinition {
pub fn new(manifest: Manifest, entry_point: EntryPoint) -> Self {
Self {
manifest,
entry_point,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum EntryPoint {
Single { file_name: String },
Multi,
}
impl EntryPoint {
pub fn file_name(&self) -> &str {
match self {
EntryPoint::Single { file_name } => file_name,
EntryPoint::Multi => "__init__.py",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopLevelFunctionDef {
pub name: String,
pub is_async: bool,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ValidationError {
#[error(transparent)]
SchemaReported(ReportedError),
#[error("required file {file:?} is missing from the plugin directory")]
MissingRequiredFile { file: String },
#[error("{entry_point} does not parse as valid Python: {message}")]
PythonParse {
entry_point: String,
message: String,
},
#[error(
"trigger {trigger:?} is declared in manifest.toml but has no matching \
top-level `def {}(...)` in {entry_point}",
.trigger.as_str()
)]
TriggerNotImplemented {
trigger: TriggerType,
entry_point: String,
},
#[error(
"trigger {trigger:?} is implemented as `async def` in {entry_point}; \
the runtime invokes trigger functions synchronously"
)]
AsyncTriggerFn {
trigger: TriggerType,
entry_point: String,
},
#[error("no Python entry point found in the plugin directory (no .py files at the top level)")]
NoEntryPoint,
#[error(
"multiple .py files found at the top level without __init__.py: {files:?}; add __init__.py for a multi-file plugin, or keep only one .py file"
)]
AmbiguousEntryPoint { files: Vec<String> },
#[error("plugin ({name:?}, {version:?}) already exists in the target index")]
NameVersionConflict { name: String, version: String },
}
impl ValidationError {
pub fn variant_name(&self) -> &'static str {
match self {
Self::SchemaReported(_) => "SchemaReported",
Self::MissingRequiredFile { .. } => "MissingRequiredFile",
Self::PythonParse { .. } => "PythonParse",
Self::TriggerNotImplemented { .. } => "TriggerNotImplemented",
Self::AsyncTriggerFn { .. } => "AsyncTriggerFn",
Self::NoEntryPoint => "NoEntryPoint",
Self::AmbiguousEntryPoint { .. } => "AmbiguousEntryPoint",
Self::NameVersionConflict { .. } => "NameVersionConflict",
}
}
}
pub fn classify_entry_point(file_names: &[String]) -> Result<EntryPoint, ValidationError> {
if file_names.iter().any(|name| name == "__init__.py") {
return Ok(EntryPoint::Multi);
}
let mut py_files: Vec<String> = file_names
.iter()
.filter(|name| name.ends_with(".py"))
.cloned()
.collect();
match py_files.len() {
0 => Err(ValidationError::NoEntryPoint),
1 => Ok(EntryPoint::Single {
file_name: py_files.pop().unwrap(),
}),
_ => {
py_files.sort();
Err(ValidationError::AmbiguousEntryPoint { files: py_files })
}
}
}
pub fn check_trigger_bindings(
declared: &[TriggerType],
defs: &[TopLevelFunctionDef],
entry_point: &str,
) -> Vec<ValidationError> {
let mut errors = Vec::new();
for trigger in declared {
let expected = trigger.as_str();
let resolved = defs.iter().rev().find(|d| d.name == expected);
match resolved {
None => errors.push(ValidationError::TriggerNotImplemented {
trigger: *trigger,
entry_point: entry_point.to_owned(),
}),
Some(def) if def.is_async => errors.push(ValidationError::AsyncTriggerFn {
trigger: *trigger,
entry_point: entry_point.to_owned(),
}),
Some(_) => {}
}
}
errors
}
#[derive(Debug, Clone, Copy)]
pub struct TopLevelDefConformanceCase {
pub label: &'static str,
pub source: &'static str,
pub expected: TopLevelDefExpectation,
}
#[derive(Debug, Clone, Copy)]
pub enum TopLevelDefExpectation {
Defs(&'static [ExpectedTopLevelFunctionDef]),
ParseError,
}
#[derive(Debug, Clone, Copy)]
pub struct ExpectedTopLevelFunctionDef {
pub name: &'static str,
pub is_async: bool,
}
pub const TOP_LEVEL_DEF_CONFORMANCE_CASES: &[TopLevelDefConformanceCase] = &[
TopLevelDefConformanceCase {
label: "plain_def",
source: "def foo(): pass",
expected: TopLevelDefExpectation::Defs(&[ExpectedTopLevelFunctionDef {
name: "foo",
is_async: false,
}]),
},
TopLevelDefConformanceCase {
label: "async_def",
source: "async def foo(): pass",
expected: TopLevelDefExpectation::Defs(&[ExpectedTopLevelFunctionDef {
name: "foo",
is_async: true,
}]),
},
TopLevelDefConformanceCase {
label: "decorated_def",
source: "@staticmethod\ndef foo(): pass",
expected: TopLevelDefExpectation::Defs(&[ExpectedTopLevelFunctionDef {
name: "foo",
is_async: false,
}]),
},
TopLevelDefConformanceCase {
label: "class_method",
source: "class C:\n def foo(self): pass",
expected: TopLevelDefExpectation::Defs(&[]),
},
TopLevelDefConformanceCase {
label: "nested_def",
source: "def outer():\n def inner(): pass",
expected: TopLevelDefExpectation::Defs(&[ExpectedTopLevelFunctionDef {
name: "outer",
is_async: false,
}]),
},
TopLevelDefConformanceCase {
label: "guarded_if",
source: "if True:\n def foo(): pass",
expected: TopLevelDefExpectation::Defs(&[]),
},
TopLevelDefConformanceCase {
label: "reexport",
source: "from bar import foo",
expected: TopLevelDefExpectation::Defs(&[]),
},
TopLevelDefConformanceCase {
label: "assignment",
source: "foo = bar",
expected: TopLevelDefExpectation::Defs(&[]),
},
TopLevelDefConformanceCase {
label: "same_kind_redefinition",
source: "def foo(): pass\ndef foo(): pass",
expected: TopLevelDefExpectation::Defs(&[
ExpectedTopLevelFunctionDef {
name: "foo",
is_async: false,
},
ExpectedTopLevelFunctionDef {
name: "foo",
is_async: false,
},
]),
},
TopLevelDefConformanceCase {
label: "sync_then_async",
source: "def foo(): pass\nasync def foo(): pass",
expected: TopLevelDefExpectation::Defs(&[
ExpectedTopLevelFunctionDef {
name: "foo",
is_async: false,
},
ExpectedTopLevelFunctionDef {
name: "foo",
is_async: true,
},
]),
},
TopLevelDefConformanceCase {
label: "unparseable_source",
source: "def foo(:",
expected: TopLevelDefExpectation::ParseError,
},
TopLevelDefConformanceCase {
label: "empty_source",
source: "",
expected: TopLevelDefExpectation::Defs(&[]),
},
];
#[cfg(test)]
mod tests {
use super::*;
use crate::{FieldPath, SchemaError};
use pretty_assertions::assert_eq;
#[test]
fn entry_point_file_name() {
assert_eq!(
EntryPoint::Single {
file_name: "my_plugin.py".into()
}
.file_name(),
"my_plugin.py"
);
assert_eq!(EntryPoint::Multi.file_name(), "__init__.py");
}
#[test]
fn validated_plugin_new_constructs() {
let manifest = Manifest::parse_toml(
"manifest_schema_version = \"1.0\"\n\
[plugin]\n\
name = \"p\"\n\
version = \"0.1.0\"\n\
description = \"x\"\n\
triggers = [\"process_writes\"]\n\
[dependencies]\n\
database_version = \">=3.0.0\"\n",
)
.expect("fixture manifest parses");
let vp = ValidatedPluginDefinition::new(manifest, EntryPoint::Multi);
assert_eq!(vp.entry_point, EntryPoint::Multi);
assert_eq!(vp.manifest.plugin.name.as_str(), "p");
}
fn names(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a1_init_py_present_is_multi() {
assert_eq!(
classify_entry_point(&names(&["__init__.py"])).unwrap(),
EntryPoint::Multi
);
}
#[test]
fn a2_single_py_is_single() {
assert_eq!(
classify_entry_point(&names(&["plugin.py"])).unwrap(),
EntryPoint::Single {
file_name: "plugin.py".into()
}
);
}
#[test]
fn a3_zero_py_is_no_entry_point() {
let err = classify_entry_point(&names(&["README.md"])).unwrap_err();
assert!(matches!(err, ValidationError::NoEntryPoint));
}
#[test]
fn a4_multiple_py_is_ambiguous_sorted() {
let err = classify_entry_point(&names(&["foo.py", "bar.py", "aaa.py"])).unwrap_err();
match err {
ValidationError::AmbiguousEntryPoint { files } => {
assert_eq!(files, vec!["aaa.py", "bar.py", "foo.py"]);
}
other => panic!("expected AmbiguousEntryPoint, got {other:?}"),
}
}
#[test]
fn a5_non_py_files_ignored() {
assert_eq!(
classify_entry_point(&names(&["plugin.py", "requirements.txt", "README.md"])).unwrap(),
EntryPoint::Single {
file_name: "plugin.py".into()
}
);
}
#[test]
fn a6_init_py_plus_helper_is_multi() {
assert_eq!(
classify_entry_point(&names(&["__init__.py", "helper.py"])).unwrap(),
EntryPoint::Multi
);
}
#[test]
fn a7_bare_dot_py_counts() {
assert_eq!(
classify_entry_point(&names(&[".py"])).unwrap(),
EntryPoint::Single {
file_name: ".py".into()
}
);
}
#[test]
fn a8_classification_is_case_sensitive() {
assert_eq!(
classify_entry_point(&names(&["__INIT__.py"])).unwrap(),
EntryPoint::Single {
file_name: "__INIT__.py".into()
}
);
let err = classify_entry_point(&names(&["Foo.PY"])).unwrap_err();
assert!(
matches!(err, ValidationError::NoEntryPoint),
"Foo.PY must not be recognized as a .py file"
);
}
fn def(name: &str, is_async: bool) -> TopLevelFunctionDef {
TopLevelFunctionDef {
name: name.into(),
is_async,
}
}
#[test]
fn f1_sync_def_matches_trigger() {
let errs = check_trigger_bindings(
&[TriggerType::ProcessWrites],
&[def("process_writes", false)],
"__init__.py",
);
assert!(errs.is_empty(), "expected no errors, got {errs:?}");
}
#[test]
fn f2_no_matching_def_is_not_implemented() {
let errs = check_trigger_bindings(
&[TriggerType::ProcessWrites],
&[def("something_else", false)],
"__init__.py",
);
assert_eq!(errs.len(), 1);
assert!(matches!(
errs[0],
ValidationError::TriggerNotImplemented {
trigger: TriggerType::ProcessWrites,
..
}
));
}
#[test]
fn f3_async_def_is_async_trigger_fn() {
let errs = check_trigger_bindings(
&[TriggerType::ProcessWrites],
&[def("process_writes", true)],
"__init__.py",
);
assert_eq!(errs.len(), 1);
assert!(matches!(errs[0], ValidationError::AsyncTriggerFn { .. }));
}
#[test]
fn f4_multiple_bad_triggers_all_reported() {
let errs = check_trigger_bindings(
&[
TriggerType::ProcessWrites,
TriggerType::ProcessScheduledCall,
TriggerType::ProcessRequest,
],
&[def("unrelated", false)],
"__init__.py",
);
assert_eq!(errs.len(), 3);
}
#[test]
fn f5_extra_defs_ignored() {
let errs = check_trigger_bindings(
&[TriggerType::ProcessWrites],
&[def("process_writes", false), def("helper", false)],
"__init__.py",
);
assert!(errs.is_empty());
}
#[test]
fn f13_redefinition_last_wins_sync_only() {
let errs = check_trigger_bindings(
&[TriggerType::ProcessWrites],
&[def("process_writes", false), def("process_writes", false)],
"__init__.py",
);
assert!(errs.is_empty());
}
#[test]
fn f13_redefinition_last_wins_sync_then_async() {
let errs = check_trigger_bindings(
&[TriggerType::ProcessWrites],
&[def("process_writes", false), def("process_writes", true)],
"__init__.py",
);
assert_eq!(errs.len(), 1);
assert!(matches!(errs[0], ValidationError::AsyncTriggerFn { .. }));
}
#[test]
fn f13_redefinition_last_wins_async_then_sync() {
let errs = check_trigger_bindings(
&[TriggerType::ProcessWrites],
&[def("process_writes", true), def("process_writes", false)],
"__init__.py",
);
assert!(errs.is_empty());
}
fn every_validation_variant() -> Vec<ValidationError> {
vec![
ValidationError::SchemaReported(ReportedError::new(
FieldPath::root().field("plugin").field("description"),
SchemaError::DescriptionEmpty,
)),
ValidationError::MissingRequiredFile {
file: "__init__.py".into(),
},
ValidationError::PythonParse {
entry_point: "__init__.py".into(),
message: "unexpected token".into(),
},
ValidationError::TriggerNotImplemented {
trigger: TriggerType::ProcessWrites,
entry_point: "__init__.py".into(),
},
ValidationError::AsyncTriggerFn {
trigger: TriggerType::ProcessScheduledCall,
entry_point: "__init__.py".into(),
},
ValidationError::NoEntryPoint,
ValidationError::AmbiguousEntryPoint {
files: vec!["a.py".into(), "b.py".into()],
},
ValidationError::NameVersionConflict {
name: "downsampler".into(),
version: "1.2.0".into(),
},
]
}
#[test]
fn every_validation_variant_covered() {
let tags: Vec<&'static str> = every_validation_variant()
.iter()
.map(ValidationError::variant_name)
.collect();
assert_eq!(tags.len(), 8);
}
#[test]
fn validation_error_display_stable() {
let rendered: Vec<String> = every_validation_variant()
.iter()
.map(|e| e.to_string())
.collect();
insta::assert_yaml_snapshot!("validation_error_display", rendered);
}
#[test]
fn validation_error_variant_tags_stable() {
let tags: Vec<&'static str> = every_validation_variant()
.iter()
.map(ValidationError::variant_name)
.collect();
insta::assert_yaml_snapshot!("validation_error_variant_tags", tags);
}
#[test]
fn schemas_error_structured_payload_preserved_via_validation_schema_reported() {
let reported = ReportedError::new(
FieldPath::root().field("plugin").field("description"),
SchemaError::DescriptionEmpty,
);
let wrapped = ValidationError::SchemaReported(reported);
match &wrapped {
ValidationError::SchemaReported(r) => {
assert_eq!(r.path.as_str(), "plugin.description");
assert!(matches!(r.error, SchemaError::DescriptionEmpty));
}
other => panic!("expected SchemaReported, got {other:?}"),
}
}
#[test]
fn corpus_labels_unique() {
let mut labels: Vec<&str> = TOP_LEVEL_DEF_CONFORMANCE_CASES
.iter()
.map(|c| c.label)
.collect();
let count = labels.len();
labels.sort_unstable();
labels.dedup();
assert_eq!(labels.len(), count, "corpus labels must be unique");
}
#[test]
fn corpus_covers_required_rules() {
let labels: Vec<&str> = TOP_LEVEL_DEF_CONFORMANCE_CASES
.iter()
.map(|c| c.label)
.collect();
for required in [
"plain_def",
"async_def",
"decorated_def",
"class_method",
"nested_def",
"guarded_if",
"reexport",
"assignment",
"same_kind_redefinition",
"sync_then_async",
"unparseable_source",
"empty_source",
] {
assert!(
labels.contains(&required),
"corpus missing required case `{required}`"
);
}
}
}