plugins/native/znippy-plugin-maven/src/row.rs
//! The ONE writer of the maven column set — shared by both plugin surfaces.
//!
//! `native.rs` turns these fields into a `znippy_common::plugin::ExtensionRow`;
//! the wasm example in `plugins/wasm/znippy-plugin-maven-wasm` turns the *same*
//! fields into the JSON `wasm_loader.rs` parses back into an `ExtensionRow`.
//! Neither surface owns a column list of its own, so the two paths cannot drift
//! apart by construction (LAW 5) — a column added here appears in both, and
//! `plugins/wasm/tests/maven_native_wasm_agree.rs` asserts they still match.
//!
//! Nothing in this module touches `znippy-common`, `arrow`, `std::fs` or threads,
//! which is exactly why it compiles unchanged for `wasm32-unknown-unknown`.
use crate::pom::MavenCoord;
/// Paths this handler claims. One list, read by the native `matches_path` /
/// `meta()` and by the wasm `plugin_extensions` export.
pub const MAVEN_EXTENSIONS: &[&str] = &[".jar", ".war", ".ear", ".pom"];
/// The columns this handler contributes, as `(name, type-tag)` pairs.
///
/// The type tag is the vocabulary `wasm_loader::parse_schema_str` speaks
/// (`utf8` / `u32` / `i8`); `native.rs` maps the same tags onto Arrow
/// `DataType`s. Every maven column is a nullable string today.
pub const MAVEN_SCHEMA: &[(&str, &str)] = &[
("group_id", "utf8"),
("artifact_id", "utf8"),
("version", "utf8"),
("packaging", "utf8"),
("classifier", "utf8"),
];
/// `plugin_extensions` / `plugin_schema` wire form: `a,b,c` and `n:t,n:t`.
pub fn extensions_str() -> String {
MAVEN_EXTENSIONS.join(",")
}
pub fn schema_str() -> String {
MAVEN_SCHEMA
.iter()
.map(|(n, t)| format!("{n}:{t}"))
.collect::<Vec<_>>()
.join(",")
}
/// Recover a maven classifier from a filename: `{artifact}-{version}[-{classifier}].{ext}`.
/// Returns `None` for a primary artifact (no classifier segment).
pub fn classifier_from_path(path: &str, artifact: &str, version: &str) -> Option<String> {
let filename = path.rsplit('/').next().unwrap_or(path);
let stem = filename.rsplit_once('.').map(|(s, _)| s).unwrap_or(filename);
let prefix = format!("{artifact}-{version}");
let rest = stem.strip_prefix(&prefix)?.strip_prefix('-')?;
if rest.is_empty() { None } else { Some(rest.to_string()) }
}
/// The single field writer. Returns one entry per `MAVEN_SCHEMA` column, in
/// schema order, with `None` for a column that has no value on this artifact.
///
/// The pom carries the GAV; a classifier (`sources`, `javadoc`, …) is encoded
/// only in the on-disk filename when the pom itself omits it, so it is recovered
/// from `path` as a fallback.
pub fn maven_fields(coord: MavenCoord, path: &str) -> Vec<(&'static str, Option<String>)> {
let from_path = classifier_from_path(path, &coord.artifact_id, &coord.version);
let classifier = coord
.classifier
.clone()
.filter(|c| !c.is_empty())
.or(from_path);
vec![
("group_id", Some(coord.group_id)),
("artifact_id", Some(coord.artifact_id)),
("version", Some(coord.version)),
("packaging", Some(coord.packaging)),
("classifier", classifier),
]
}
/// Serialize `maven_fields` as the flat JSON object `wasm_loader::parse_json_to_row`
/// reads. A `None` column is OMITTED rather than written as `null`: that parser has
/// no null, so an emitted `null` would land as the literal string `"null"` and the
/// wasm row would disagree with the native `OptStr(None)` cell.
///
/// Lives here, next to the field writer, so the wasm crate holds no field names.
pub fn fields_to_json(fields: &[(&'static str, Option<String>)]) -> String {
let body = fields
.iter()
.filter_map(|(k, v)| v.as_ref().map(|v| format!("\"{k}\":\"{}\"", escape(v))))
.collect::<Vec<_>>()
.join(",");
format!("{{{body}}}")
}
/// `parse_json_to_row` splits on `,` and `:` before it strips quotes, so a value
/// carrying either would silently shear into the wrong column. Maven GAV segments
/// cannot legally contain them; anything that does is replaced rather than trusted.
fn escape(s: &str) -> String {
s.chars()
.map(|c| match c {
',' | ':' | '"' | '\\' | '{' | '}' => '_',
c => c,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn coord(classifier: Option<&str>) -> MavenCoord {
MavenCoord {
group_id: "org.apache.spark".into(),
artifact_id: "spark-core_2.13".into(),
version: "3.5.1".into(),
packaging: "jar".into(),
classifier: classifier.map(|c| c.into()),
}
}
#[test]
fn every_schema_column_is_written_exactly_once() {
let fields = maven_fields(coord(None), "spark-core_2.13-3.5.1.jar");
let names: Vec<_> = fields.iter().map(|(n, _)| *n).collect();
let schema: Vec<_> = MAVEN_SCHEMA.iter().map(|(n, _)| *n).collect();
assert_eq!(names, schema, "field writer drifted from MAVEN_SCHEMA");
}
#[test]
fn primary_artifact_has_no_classifier_and_json_omits_it() {
let fields = maven_fields(coord(None), "spark-core_2.13-3.5.1.jar");
assert_eq!(fields[4], ("classifier", None));
assert!(
!fields_to_json(&fields).contains("classifier"),
"a null column must not reach the JSON at all"
);
}
#[test]
fn classifier_recovered_from_filename_reaches_the_json() {
let fields = maven_fields(coord(None), "spark-core_2.13-3.5.1-sources.jar");
assert_eq!(fields[4], ("classifier", Some("sources".into())));
assert!(fields_to_json(&fields).contains("\"classifier\":\"sources\""));
}
#[test]
fn pom_classifier_wins_over_the_filename() {
let fields = maven_fields(coord(Some("javadoc")), "spark-core_2.13-3.5.1-sources.jar");
assert_eq!(fields[4], ("classifier", Some("javadoc".into())));
}
#[test]
fn a_separator_in_a_value_cannot_shear_the_wire_row() {
let mut c = coord(None);
c.group_id = "org,evil:x".into();
let json = fields_to_json(&maven_fields(c, "x-1.0.jar"));
assert!(json.contains("\"group_id\":\"org_evil_x\""), "got {json}");
}
#[test]
fn schema_str_is_the_loader_vocabulary() {
assert_eq!(
schema_str(),
"group_id:utf8,artifact_id:utf8,version:utf8,packaging:utf8,classifier:utf8"
);
}
}