microsandbox-types 0.7.2

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

use ts_rs::TS;

use crate::snapshot::cloud_manifest::{
    CheckpointSnapshotState, FileSnapshotState, ImageRef, Manifest as SnapshotManifest,
    SnapshotFormat, SnapshotScope, SnapshotState, UpperIntegrity, UpperLayer,
};
use crate::{
    Action, CloudCreateSandboxRequest, CloudCreateSandboxResponse, CloudCreateSnapshotRequest,
    CloudDiskImageFormat, CloudErrorBody, CloudErrorDetails, CloudHostPattern,
    CloudMessageResponse, CloudNetworkSpec, CloudPaginated, CloudPatch, CloudPullPolicy,
    CloudRlimit, CloudRlimitResource, CloudRootfsSource, CloudSandboxComputeResources,
    CloudSandboxResources, CloudSandboxRuntimeOptions, CloudSandboxSpec, CloudSandboxStatus,
    CloudSandboxStatusReason, CloudSecretEntry, CloudSecretSource, CloudSecretsConfig,
    CloudSnapshot, CloudSnapshotDetails, CloudSnapshotKind, CloudSnapshotLocation,
    CloudSnapshotOperation, CloudSnapshotOperationStatus, CloudSnapshotSpec, CloudViolationAction,
    CloudVolumeMount, Destination, DestinationGroup, Direction, EnvVar, HandoffInit,
    HostPermissions, MountOptions, NetworkPolicy, OwnedVolumeStorage, PortRange, Protocol, Rule,
    SandboxLogLevel, SandboxPolicy, SecretSubstitution, 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",
    "OwnedVolumeStorage",
    "NetworkPolicy",
    "PortRange",
    "Protocol",
    "Rule",
    "SandboxLogLevel",
    "SandboxPolicy",
    "SecretSubstitution",
    "SecurityProfile",
    "StatVirtualization",
];

/// Snapshot schema type names the cloud twins may reference. Filtered into the
/// `cloud.ts` import list the same way as [`DOMAIN_TYPE_NAMES`].
const SNAPSHOT_TYPE_NAMES: &[&str] = &[
    "SnapshotManifest",
    "SnapshotScope",
    "SnapshotFormat",
    "SnapshotState",
    "ImageRef",
    "UpperLayer",
    "UpperIntegrity",
    "FileSnapshotState",
    "CheckpointSnapshotState",
];

//--------------------------------------------------------------------------------------------------
// 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 `snapshot.ts` — the shared snapshot descriptor schema.
pub fn render_snapshot() -> String {
    format_ts(&format!("{HEADER}{}", body(&snapshot_declarations())))
}

/// Render `cloud.ts` — the cloud wire twins. Imports the domain and snapshot
/// types they use from `./domain` and `./snapshot` and re-exports both surfaces
/// so consumers of the package entry see the whole cloud contract.
pub fn render_cloud() -> String {
    let decls = cloud_declarations();

    let mut output = String::from(HEADER);
    output.push_str(&import_block(DOMAIN_TYPE_NAMES, &decls, "./domain.js"));
    output.push_str(&import_block(SNAPSHOT_TYPE_NAMES, &decls, "./snapshot.js"));
    output.push('\n');
    output.push_str(&body(&decls));
    format_ts(&output)
}

/// Import the referenced subset of `names` from `module` and re-export the
/// module's whole surface.
fn import_block(names: &[&str], decls: &[String], module: &str) -> String {
    let imports: Vec<&str> = names
        .iter()
        .copied()
        .filter(|name| decls.iter().any(|decl| mentions(decl, name)))
        .collect();
    format!(
        "import type {{ {} }} from \"{module}\";\nexport type * from \"{module}\";\n",
        imports.join(", ")
    )
}

/// 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),
        OwnedVolumeStorage::decl(&cfg),
        StatVirtualization::decl(&cfg),
        HostPermissions::decl(&cfg),
        SecretSubstitution::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 shared snapshot descriptor schema,
/// transitively closed from `SnapshotManifest`.
pub fn snapshot_declarations() -> Vec<String> {
    let cfg = ts_rs::Config::new().with_large_int("number");

    vec![
        SnapshotManifest::decl(&cfg),
        SnapshotScope::decl(&cfg),
        ImageRef::decl(&cfg),
        SnapshotState::decl(&cfg),
        FileSnapshotState::decl(&cfg),
        CheckpointSnapshotState::decl(&cfg),
        SnapshotFormat::decl(&cfg),
        UpperLayer::decl(&cfg),
        UpperIntegrity::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![
        CloudCreateSandboxRequest::decl(&cfg),
        CloudSandboxSpec::decl(&cfg),
        CloudSandboxComputeResources::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),
        CloudCreateSnapshotRequest::decl(&cfg),
        CloudSnapshotSpec::decl(&cfg),
        CloudSnapshot::decl(&cfg),
        CloudSnapshotDetails::decl(&cfg),
        CloudSnapshotLocation::decl(&cfg),
        CloudSnapshotOperation::decl(&cfg),
        CloudSnapshotOperationStatus::decl(&cfg),
        CloudSnapshotKind::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()),
            ("snapshot.ts", render_snapshot()),
            ("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_network_limits_are_optional_in_typescript_and_on_the_wire() {
        let network: crate::cloud::CloudNetworkSpec = serde_json::from_str("{}").unwrap();
        let wire = serde_json::to_value(network).unwrap();
        let declaration = crate::cloud::CloudNetworkSpec::decl(&ts_rs::Config::new());
        for field in ["max_connections", "max_udp_connections"] {
            assert!(wire.get(field).is_none());
            assert!(declaration.contains(&format!("{field}?:")));
        }
    }

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

        let cloud = render_cloud();
        // Cloud twins live here and their domain deps are imported/re-exported.
        for source in ["oci", "bind", "disk_image", "disk_snapshot"] {
            assert!(cloud.contains(&format!("\"source\": \"{source}\"")));
        }
        assert!(cloud.contains("export type CloudSandboxSpec"));
        assert!(cloud.contains("export type CloudSandboxComputeResources"));
        assert!(cloud.contains("import type {"));
        assert!(cloud.contains("EnvVar"));
        assert!(cloud.contains("export type * from \"./domain.js\""));
        // The snapshot schema is imported/re-exported the same way.
        assert!(cloud.contains("export type CloudSnapshot"));
        assert!(cloud.contains("import type { SnapshotManifest } from \"./snapshot.js\""));
        assert!(cloud.contains("export type * from \"./snapshot.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 ="));

        let snapshot = render_snapshot();
        // The canonical descriptor generates under its exposed name.
        assert!(snapshot.contains("export type SnapshotManifest"));
        assert!(snapshot.contains("export type SnapshotState"));
        assert!(!snapshot.contains("export type Manifest"));
    }
}