microsandbox-types 0.6.7

Shared task and wire contract types for microsandbox.
Documentation
//! TypeScript binding generation helpers.
//!
//! Two files are emitted. `domain.ts` carries only the domain types the cloud
//! contract transitively references; `cloud.ts` carries the cloud wire twins and
//! imports its domain dependencies from `./domain`. Domain types the cloud never
//! touches (the modification plan set, snapshots, volume specs, the domain twins
//! the cloud replaces, …) are intentionally not generated.

use ts_rs::TS;

use crate::{
    Action, CloudCreateSandboxResponse, CloudDiskImageFormat, CloudErrorBody, CloudErrorDetails,
    CloudHostPattern, CloudMessageResponse, CloudNetworkSpec, CloudPaginated, CloudPatch,
    CloudPullPolicy, CloudRlimit, CloudRlimitResource, CloudRootfsSource, CloudSandboxResources,
    CloudSandboxRuntimeOptions, CloudSandboxSpec, CloudSandboxStatus, CloudSandboxStatusReason,
    CloudSecretEntry, CloudSecretSource, CloudSecretsConfig, CloudViolationAction,
    CloudVolumeMount, Destination, DestinationGroup, Direction, EnvVar, HandoffInit,
    HostPermissions, MountOptions, NetworkPolicy, PortRange, Protocol, Rule, SandboxLogLevel,
    SandboxPolicy, SecretInjection, SecurityProfile, StatVirtualization,
};

//--------------------------------------------------------------------------------------------------
// Constants
//--------------------------------------------------------------------------------------------------

const HEADER: &str = "// @generated by microsandbox-types. Do not edit by hand.\n\n";

/// Domain type names the cloud twins may reference. The `cloud.ts` import list is
/// the subset of these that actually appears in the cloud declarations, so a
/// twin change adds/drops imports without any edit here.
const DOMAIN_TYPE_NAMES: &[&str] = &[
    "Action",
    "Destination",
    "DestinationGroup",
    "Direction",
    "EnvVar",
    "HandoffInit",
    "HostPermissions",
    "MountOptions",
    "NetworkPolicy",
    "PortRange",
    "Protocol",
    "Rule",
    "SandboxLogLevel",
    "SandboxPolicy",
    "SecretInjection",
    "SecurityProfile",
    "StatVirtualization",
];

//--------------------------------------------------------------------------------------------------
// Functions
//--------------------------------------------------------------------------------------------------

/// Render `domain.ts` — the domain types the cloud contract transitively needs.
pub fn render_domain() -> String {
    format_ts(&format!("{HEADER}{}", body(&domain_declarations())))
}

/// Render `cloud.ts` — the cloud wire twins. Imports the domain types they use
/// from `./domain` and re-exports the domain surface so consumers of the package
/// entry see the whole cloud contract.
pub fn render_cloud() -> String {
    let decls = cloud_declarations();
    let imports: Vec<&str> = DOMAIN_TYPE_NAMES
        .iter()
        .copied()
        .filter(|name| decls.iter().any(|decl| mentions(decl, name)))
        .collect();

    let mut output = String::from(HEADER);
    output.push_str(&format!(
        "import type {{ {} }} from \"./domain.js\";\n",
        imports.join(", ")
    ));
    output.push_str("export type * from \"./domain.js\";\n\n");
    output.push_str(&body(&decls));
    format_ts(&output)
}

/// Format generated TypeScript with dprint's Deno style — the same formatter
/// ts-rs uses under its `format` feature, applied here since we assemble the
/// files from raw `decl()` output rather than ts-rs's own export path.
fn format_ts(source: &str) -> String {
    use dprint_plugin_typescript::configuration::ConfigurationBuilder;
    use dprint_plugin_typescript::{FormatTextOptions, format_text};
    use std::path::Path;

    let config = ConfigurationBuilder::new().deno().build();
    let options = FormatTextOptions {
        config: &config,
        path: Path::new("bindings.ts"),
        text: source.to_string(),
        extension: None,
        external_formatter: None,
    };
    match format_text(options) {
        Ok(Some(formatted)) => formatted,
        Ok(None) => source.to_string(),
        Err(err) => panic!("dprint failed to format generated bindings: {err}"),
    }
}

/// Raw `ts-rs` declarations for the domain types the cloud contract references,
/// transitively closed (e.g. `NetworkPolicy` drags in `Rule`, `Action`, …).
pub fn domain_declarations() -> Vec<String> {
    let cfg = ts_rs::Config::new().with_large_int("number");

    vec![
        EnvVar::decl(&cfg),
        HandoffInit::decl(&cfg),
        SecurityProfile::decl(&cfg),
        SandboxPolicy::decl(&cfg),
        SandboxLogLevel::decl(&cfg),
        MountOptions::decl(&cfg),
        StatVirtualization::decl(&cfg),
        HostPermissions::decl(&cfg),
        SecretInjection::decl(&cfg),
        NetworkPolicy::decl(&cfg),
        Rule::decl(&cfg),
        Action::decl(&cfg),
        Direction::decl(&cfg),
        Protocol::decl(&cfg),
        Destination::decl(&cfg),
        DestinationGroup::decl(&cfg),
        PortRange::decl(&cfg),
    ]
}

/// Raw `ts-rs` declarations for the cloud wire twins.
pub fn cloud_declarations() -> Vec<String> {
    let cfg = ts_rs::Config::new().with_large_int("number");

    vec![
        CloudSandboxSpec::decl(&cfg),
        CloudRootfsSource::decl(&cfg),
        CloudVolumeMount::decl(&cfg),
        CloudSandboxResources::decl(&cfg),
        CloudSandboxRuntimeOptions::decl(&cfg),
        CloudPullPolicy::decl(&cfg),
        CloudDiskImageFormat::decl(&cfg),
        CloudRlimitResource::decl(&cfg),
        CloudRlimit::decl(&cfg),
        CloudPatch::decl(&cfg),
        CloudNetworkSpec::decl(&cfg),
        CloudSecretsConfig::decl(&cfg),
        CloudSecretEntry::decl(&cfg),
        CloudSecretSource::decl(&cfg),
        CloudHostPattern::decl(&cfg),
        CloudViolationAction::decl(&cfg),
        CloudCreateSandboxResponse::decl(&cfg),
        CloudSandboxStatus::decl(&cfg),
        CloudSandboxStatusReason::decl(&cfg),
        CloudPaginated::<CloudCreateSandboxResponse>::decl(&cfg),
        CloudMessageResponse::decl(&cfg),
        CloudErrorBody::decl(&cfg),
        CloudErrorDetails::decl(&cfg),
    ]
}

/// Join declarations into an exported, trimmed file body.
fn body(declarations: &[String]) -> String {
    let mut output = declarations
        .iter()
        .cloned()
        .map(export_declaration)
        .map(trim_line_endings)
        .collect::<Vec<_>>()
        .join("\n\n");
    output.push('\n');
    output
}

/// True if `name` appears in `decl` as a whole identifier, so `Destination`
/// never matches inside `DestinationGroup`.
fn mentions(decl: &str, name: &str) -> bool {
    decl.match_indices(name).any(|(i, _)| {
        let boundary = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric() && c != '_');
        boundary(decl[..i].chars().next_back()) && boundary(decl[i + name.len()..].chars().next())
    })
}

fn export_declaration(declaration: String) -> String {
    if declaration.starts_with("export ") {
        return declaration;
    }

    for keyword in ["type ", "interface "] {
        if let Some(rest) = declaration.strip_prefix(keyword) {
            return format!("export {keyword}{rest}");
        }
    }

    declaration
}

fn trim_line_endings(declaration: String) -> String {
    declaration
        .lines()
        .map(str::trim_end)
        .collect::<Vec<_>>()
        .join("\n")
}

//--------------------------------------------------------------------------------------------------
// Tests
//--------------------------------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;

    use super::*;

    fn src_dir() -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .expect("microsandbox-types rust crate should live under <package>/rust")
            .join("typescript/src")
    }

    #[test]
    fn checked_in_bindings_match_generated_output() {
        for (file, generated) in [("domain.ts", render_domain()), ("cloud.ts", render_cloud())] {
            let path = src_dir().join(file);
            let current = fs::read_to_string(&path)
                .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
            assert_eq!(current, generated, "{} is stale", path.display());
        }
    }

    #[test]
    fn cloud_bindings_import_domain_and_stay_scoped() {
        assert_eq!(domain_declarations().len(), 17);
        assert_eq!(cloud_declarations().len(), 23);

        let cloud = render_cloud();
        // Cloud twins live here and their domain deps are imported/re-exported.
        assert!(cloud.contains("export type CloudSandboxSpec"));
        assert!(cloud.contains("import type {"));
        assert!(cloud.contains("EnvVar"));
        assert!(cloud.contains("export type * from \"./domain.js\""));

        let domain = render_domain();
        // Transitive domain deps are present...
        assert!(domain.contains("export type NetworkPolicy"));
        assert!(domain.contains("export type Rule"));
        // ...but domain-only types the cloud never reaches are not generated.
        assert!(!domain.contains("SandboxModificationPlan"));
        assert!(!domain.contains("export type SandboxSpec ="));
    }
}