weaveffi-core 0.9.0

Generator trait, orchestrator, validation, and shared utilities for WeaveFFI
Documentation
//! Shared package-identity resolution.
//!
//! A single `package:` block in the IDL ([`weaveffi_ir::ir::Package`]) is the
//! source of truth for the name, version, and metadata stamped into every
//! ecosystem manifest (`package.json`, `pyproject.toml`, `*.gemspec`,
//! `*.csproj`, `pubspec.yaml`, `Package.swift`, `build.gradle`, `go.mod`).
//!
//! This module centralizes the *resolution* rules — precedence and defaults —
//! so all eleven generators agree on the package identity instead of each one
//! hardcoding `weaveffi` / `0.1.0`. Generators read [`resolve`] in their
//! manifest code and map the [`ResolvedPackage`] fields onto whatever their
//! ecosystem's manifest format requires.

use weaveffi_ir::ir::Api;

/// Fallback package version when the IDL omits `package.version`.
pub const DEFAULT_VERSION: &str = "0.1.0";

/// Fallback package name when the IDL omits `package.name` and no per-target
/// override or input basename is available.
pub const DEFAULT_NAME: &str = "weaveffi";

/// Package identity resolved for one generator/manifest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedPackage {
    /// Distribution name (npm/PyPI/gem/NuGet/pub/etc.).
    pub name: String,
    /// Semantic version stamped into the manifest.
    pub version: String,
    pub description: Option<String>,
    pub license: Option<String>,
    pub authors: Vec<String>,
    pub homepage: Option<String>,
    pub repository: Option<String>,
}

impl ResolvedPackage {
    /// Human-readable description, or a generated default when the IDL omits it.
    pub fn description_or_default(&self) -> String {
        self.description
            .clone()
            .filter(|s| !s.is_empty())
            .unwrap_or_else(|| format!("{} bindings generated by WeaveFFI", self.name))
    }

    /// The `name` rewritten so it is a legal lower-snake identifier (e.g. a
    /// Python import package or a Ruby `require` path): non-alphanumerics
    /// collapse to `_`. `"my-kv.store"` → `"my_kv_store"`.
    pub fn ident_name(&self) -> String {
        sanitize_ident(&self.name)
    }

    /// The `name` rewritten as an UpperCamelCase identifier suitable for a
    /// language-level module or namespace (Ruby `module`, .NET `namespace`,
    /// Swift module, Dart class prefix). `"my-kv.store"` → `"MyKvStore"`.
    pub fn module_name(&self) -> String {
        pascal_ident(&self.name)
    }
}

/// UpperCamelCase identifier-safe form of an arbitrary package name. Word
/// boundaries are any run of non-alphanumerics (and existing camel humps are
/// preserved). `"my-kv.store"` → `"MyKvStore"`, `"kvstore"` → `"Kvstore"`.
pub fn pascal_ident(name: &str) -> String {
    let mut out = String::with_capacity(name.len());
    let mut start_word = true;
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() {
            if start_word {
                out.push(ch.to_ascii_uppercase());
            } else {
                out.push(ch);
            }
            start_word = false;
        } else {
            start_word = true;
        }
    }
    if out.is_empty() {
        pascal_ident(DEFAULT_NAME)
    } else {
        out
    }
}

/// Lower-case identifier-safe form of an arbitrary package name.
pub fn sanitize_ident(name: &str) -> String {
    let mut out = String::with_capacity(name.len());
    let mut prev_us = false;
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_us = false;
        } else if !prev_us && !out.is_empty() {
            out.push('_');
            prev_us = true;
        }
    }
    let trimmed = out.trim_end_matches('_');
    if trimmed.is_empty() {
        DEFAULT_NAME.to_string()
    } else {
        trimmed.to_string()
    }
}

/// Strip directory and extension from an IDL basename to use as a fallback
/// package name. `"path/kvstore.yml"` → `"kvstore"`, `None`/empty →
/// [`DEFAULT_NAME`].
pub fn name_from_basename(basename: Option<&str>) -> String {
    basename
        .and_then(|b| b.rsplit(['/', '\\']).next())
        .map(|b| b.split('.').next().unwrap_or(b))
        .filter(|s| !s.is_empty())
        .unwrap_or(DEFAULT_NAME)
        .to_string()
}

/// Resolve package identity for a generator.
///
/// Name precedence (first non-empty wins):
/// 1. explicit per-target `name_override` (e.g. `python.package_name`),
/// 2. `api.package.name`,
/// 3. the IDL file stem (`input_basename`),
/// 4. [`DEFAULT_NAME`].
///
/// Version: `api.package.version` → [`DEFAULT_VERSION`]. All other metadata is
/// taken verbatim from the `package:` block (absent → `None`/empty).
pub fn resolve(
    api: &Api,
    name_override: Option<&str>,
    input_basename: Option<&str>,
) -> ResolvedPackage {
    let pkg = api.package.as_ref();
    let name = name_override
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .or_else(|| {
            pkg.map(|p| p.name.trim().to_string())
                .filter(|s| !s.is_empty())
        })
        .unwrap_or_else(|| name_from_basename(input_basename));
    let version = pkg
        .map(|p| p.version.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
    ResolvedPackage {
        name,
        version,
        description: pkg
            .and_then(|p| p.description.clone())
            .filter(|s| !s.is_empty()),
        license: pkg
            .and_then(|p| p.license.clone())
            .filter(|s| !s.is_empty()),
        authors: pkg.map(|p| p.authors.clone()).unwrap_or_default(),
        homepage: pkg
            .and_then(|p| p.homepage.clone())
            .filter(|s| !s.is_empty()),
        repository: pkg
            .and_then(|p| p.repository.clone())
            .filter(|s| !s.is_empty()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use weaveffi_ir::ir::Package;

    fn api_with(pkg: Option<Package>) -> Api {
        Api {
            version: "0.3.0".into(),
            package: pkg,
            modules: vec![],
            generators: None,
        }
    }

    fn full_pkg() -> Package {
        Package {
            name: "kvstore".into(),
            version: "1.2.0".into(),
            description: Some("KV store".into()),
            license: Some("MIT".into()),
            authors: vec!["Ada".into()],
            homepage: Some("https://example.com".into()),
            repository: Some("https://github.com/x/kvstore".into()),
        }
    }

    #[test]
    fn package_block_drives_identity() {
        let api = api_with(Some(full_pkg()));
        let r = resolve(&api, None, Some("ignored.yml"));
        assert_eq!(r.name, "kvstore");
        assert_eq!(r.version, "1.2.0");
        assert_eq!(r.license.as_deref(), Some("MIT"));
        assert_eq!(r.authors, vec!["Ada".to_string()]);
    }

    #[test]
    fn target_override_beats_package_name() {
        let api = api_with(Some(full_pkg()));
        let r = resolve(&api, Some("kvstore_py"), Some("kvstore.yml"));
        assert_eq!(r.name, "kvstore_py");
        // Version still comes from the package block.
        assert_eq!(r.version, "1.2.0");
    }

    #[test]
    fn falls_back_to_file_stem_then_default() {
        let api = api_with(None);
        let r = resolve(&api, None, Some("path/to/contacts.yml"));
        assert_eq!(r.name, "contacts");
        assert_eq!(r.version, DEFAULT_VERSION);

        let r2 = resolve(&api, None, None);
        assert_eq!(r2.name, DEFAULT_NAME);
    }

    #[test]
    fn description_default_is_generated() {
        let api = api_with(None);
        let r = resolve(&api, Some("widgets"), None);
        assert_eq!(
            r.description_or_default(),
            "widgets bindings generated by WeaveFFI"
        );
    }

    #[test]
    fn ident_name_sanitizes() {
        assert_eq!(sanitize_ident("my-kv.store"), "my_kv_store");
        assert_eq!(sanitize_ident("Kvstore"), "kvstore");
        assert_eq!(sanitize_ident("--"), DEFAULT_NAME);
    }

    #[test]
    fn pascal_ident_upper_camels() {
        assert_eq!(pascal_ident("my-kv.store"), "MyKvStore");
        assert_eq!(pascal_ident("kvstore"), "Kvstore");
        assert_eq!(pascal_ident("contacts"), "Contacts");
        assert_eq!(pascal_ident("--"), "Weaveffi");
    }
}