lenso-cli 0.4.13

Authoring CLI for Lenso Plugins and App intent.
Documentation
use super::*;
use lenso_app_plan::authoring::{PluginInstanceId, PluginRootInstance};
use lenso_app_plan::{CapabilityEndpointPlan, CapabilityRequirementPlan};
use std::fs;

fn input(plugin: &str, slot: &str) -> HostPluginInput {
    HostPluginInput {
        descriptor: PluginDescriptor::new(plugin, "1.0.0", slot),
        instance: "default".to_owned(),
        configuration: serde_json::json!({}),
        source: format!("{plugin}.ts:2:1"),
    }
}

fn build() -> GeneratedHostBuild {
    GeneratedHostBuild::lower("company.app", vec![input("company.store", "store")], vec![]).unwrap()
}

fn root(build: &GeneratedHostBuild) -> tempfile::TempDir {
    let root = tempfile::tempdir().unwrap();
    fs::create_dir(root.path().join(".lenso")).unwrap();
    fs::write(
        root.path().join(HOST_BUILD),
        serde_json::to_vec(build).unwrap(),
    )
    .unwrap();
    root
}

#[test]
fn closed_host_preserves_unique_binding_and_identity_independent_of_order() {
    let mut store = input("company.store", "store");
    store.descriptor = store
        .descriptor
        .with_capability(CapabilityEndpointPlan::new("company.notes", "1", ["get"]));
    let mut notes = input("company.notes", "notes");
    notes.descriptor = notes
        .descriptor
        .with_requirement(CapabilityRequirementPlan::one("company.notes", "1"));
    let build = GeneratedHostBuild::lower("company.app", vec![notes, store], vec![]).unwrap();
    let resolved = build.resolve(&PluginRootSnapshot::default()).unwrap();
    assert_eq!(resolved.instances().len(), 2);
    assert_eq!(resolved.plan().capability_bindings().len(), 1);
    assert!(
        resolved
            .instances()
            .iter()
            .any(|item| item.id().to_string() == "company.store/default")
    );

    let a = GeneratedHostBuild::lower(
        "company.app",
        vec![input("company.a", "a"), input("company.b", "b")],
        vec![],
    )
    .unwrap();
    let b = GeneratedHostBuild::lower(
        "company.app",
        vec![input("company.b", "b"), input("company.a", "a")],
        vec![],
    )
    .unwrap();
    assert_eq!(
        serde_json::to_vec(&a).unwrap(),
        serde_json::to_vec(&b).unwrap()
    );
}

#[test]
fn closed_host_rejects_duplicates_and_requires_explicit_shared_slot() {
    let duplicate = GeneratedHostBuild::lower(
        "company.app",
        vec![input("company.a", "a"), input("company.a", "a")],
        vec![],
    )
    .unwrap_err();
    assert!(duplicate.to_string().contains("duplicate Host Instance"));
    let shared = GeneratedHostBuild::lower(
        "company.app",
        vec![input("company.a", "shared"), input("company.b", "shared")],
        vec![],
    )
    .unwrap_err();
    assert!(shared.to_string().contains("explicit Slot cardinality"));
    let explicit = GeneratedHostBuild::lower(
        "company.app",
        vec![input("company.a", "shared"), input("company.b", "shared")],
        vec![HostSlot::many("shared")],
    )
    .unwrap();
    assert_eq!(
        explicit
            .resolve(&PluginRootSnapshot::default())
            .unwrap()
            .instances()
            .len(),
        2
    );
}

#[test]
fn closed_host_does_not_choose_an_ambiguous_capability() {
    let provider = |id, slot| {
        let mut value = input(id, slot);
        value.descriptor = value
            .descriptor
            .with_capability(CapabilityEndpointPlan::new("company.notes", "1", ["get"]));
        value
    };
    let mut notes = input("company.notes", "notes");
    notes.descriptor = notes
        .descriptor
        .with_requirement(CapabilityRequirementPlan::one("company.notes", "1"));
    let error = GeneratedHostBuild::lower(
        "company.app",
        vec![
            notes,
            provider("company.a", "a"),
            provider("company.b", "b"),
        ],
        vec![],
    )
    .unwrap_err();
    assert!(format!("{error:#}").contains("multiple providers"));
}

#[test]
fn closed_host_denies_inactive_bundles_new_instances_and_disablement() {
    let build = build();
    for snapshot in [
        PluginRootSnapshot::new(
            [PluginDescriptor::new("company.extra", "1.0.0", "store")],
            [],
            [],
        ),
        PluginRootSnapshot::new([], [PluginRootInstance::new("company.store", "extra")], []),
        PluginRootSnapshot::new([], [], [PluginInstanceId::new("company.store", "extra")]),
        PluginRootSnapshot::new([], [], [PluginInstanceId::new("company.store", "default")]),
    ] {
        assert!(build.resolve(&snapshot).is_err());
    }
}

#[test]
fn closed_host_configuration_and_management_share_admission_without_writes_on_failure() {
    let mut store = input("company.store", "store");
    store.descriptor = store.descriptor.with_configuration_schema(serde_json::json!({
        "type": "object", "properties": {"limit": {"type": "integer"}}, "additionalProperties": false
    }));
    let root = root(&GeneratedHostBuild::lower("company.app", vec![store], vec![]).unwrap());
    crate::configure_instance(root.path(), "company.store", "default", b"limit = 8\n").unwrap();
    let config = root.path().join("plugins/company.store/default.toml");
    assert!(crate::configure_instance(root.path(), "company.store", "other", b"").is_err());
    assert!(
        !root
            .path()
            .join("plugins/company.store/other.toml")
            .exists()
    );
    assert!(
        crate::configure_instance(
            root.path(),
            "company.store",
            "default",
            b"filesystem = '/'\n"
        )
        .is_err()
    );
    assert_eq!(fs::read(&config).unwrap(), b"limit = 8\n");
    assert!(crate::set_instance_disabled(root.path(), "company.store", "default", true).is_err());
    assert!(!config.with_extension("disabled").exists());
    fs::write(root.path().join("plugins/company.store/other.toml"), "").unwrap();
    assert!(crate::load_resolved_app(root.path()).is_err());
    assert!(crate::inspect_plugin_root(root.path()).is_err());
}

#[test]
fn closed_host_does_not_fall_back_from_invalid_or_competing_authority() {
    let mut unsupported = serde_json::to_value(build()).unwrap();
    unsupported["schema"] = serde_json::json!("future-version");
    let unsupported: GeneratedHostBuild = serde_json::from_value(unsupported).unwrap();
    assert!(unsupported.resolve(&PluginRootSnapshot::default()).is_err());
    let root = root(&build());
    fs::write(
        root.path().join(crate::HOST_CATALOG),
        serde_json::to_vec(&HostCatalog::default()).unwrap(),
    )
    .unwrap();
    assert!(
        crate::load_resolved_app(root.path())
            .unwrap_err()
            .to_string()
            .contains("competing")
    );
    fs::remove_file(root.path().join(crate::HOST_CATALOG)).unwrap();
    fs::write(root.path().join(HOST_BUILD), b"{}").unwrap();
    assert!(crate::load_resolved_app(root.path()).is_err());
}