Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy

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"
        );
    }
}