bmux_cli 0.0.1-alpha.4

Command-line interface for bmux terminal multiplexer
use bmux_plugin::{PluginManifest, discover_plugin_manifests};
use std::collections::BTreeMap;
use std::path::PathBuf;

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|p| p.parent())
        .expect("workspace root should exist")
        .to_path_buf()
}

fn bundled_manifest(plugin_id: &str) -> PluginManifest {
    let plugins_root = workspace_root().join("plugins");
    let report = discover_plugin_manifests(&plugins_root).expect("manifest discovery should work");
    report
        .manifest_paths
        .iter()
        .map(|path| PluginManifest::from_path(path).expect("manifest should parse"))
        .find(|manifest| manifest.id.as_str() == plugin_id)
        .unwrap_or_else(|| panic!("{plugin_id} bundled manifest should exist"))
}

#[test]
fn bundled_plugin_manifests_include_core_shipped_plugins() {
    let plugins_root = workspace_root().join("plugins");
    let report = discover_plugin_manifests(&plugins_root).expect("manifest discovery should work");
    let manifests = report
        .manifest_paths
        .iter()
        .map(|path| PluginManifest::from_path(path).expect("manifest should parse"))
        .collect::<Vec<_>>();

    assert!(
        manifests
            .iter()
            .any(|manifest| manifest.id.as_str() == "bmux.tabs")
    );
    assert!(
        manifests
            .iter()
            .any(|manifest| manifest.id.as_str() == "bmux.cluster")
    );
    assert!(
        manifests
            .iter()
            .any(|manifest| manifest.id.as_str() == "bmux.permissions")
    );
    assert!(
        manifests
            .iter()
            .any(|manifest| manifest.id.as_str() == "bmux.plugin_cli")
    );
}

#[test]
fn bundled_cluster_manifest_matches_pragmatic_command_surface() {
    let cluster = bundled_manifest("bmux.cluster");
    let commands = cluster
        .commands
        .iter()
        .map(|command| {
            (
                command.name.as_str(),
                command.path.clone(),
                command.aliases.clone(),
            )
        })
        .collect::<Vec<_>>();

    let expected = [
        (
            "cluster-up",
            vec!["cluster-up"],
            vec![vec!["cluster", "up"]],
        ),
        (
            "cluster-status",
            vec!["cluster-status"],
            vec![vec!["cluster", "status"]],
        ),
        (
            "cluster-doctor",
            vec!["cluster-doctor"],
            vec![vec!["cluster", "doctor"]],
        ),
        (
            "cluster-hosts",
            vec!["cluster-hosts"],
            vec![vec!["cluster", "hosts"]],
        ),
        (
            "cluster-pane-new",
            vec!["cluster-pane-new"],
            vec![vec!["cluster", "pane", "new"]],
        ),
        (
            "cluster-pane-move",
            vec!["cluster-pane-move"],
            vec![vec!["cluster", "pane", "move"]],
        ),
        (
            "cluster-pane-retry",
            vec!["cluster-pane-retry"],
            vec![vec!["cluster", "pane", "retry"]],
        ),
    ];

    for (name, path, aliases) in expected {
        let entry = commands
            .iter()
            .find(|(command_name, _, _)| *command_name == name)
            .unwrap_or_else(|| panic!("missing cluster command {name}"));
        let expected_path = path.iter().map(ToString::to_string).collect::<Vec<_>>();
        assert_eq!(entry.1, expected_path, "{name} path mismatch");
        let expected_aliases = aliases
            .iter()
            .map(|alias| alias.iter().map(ToString::to_string).collect::<Vec<_>>())
            .collect::<Vec<_>>();
        assert_eq!(entry.2, expected_aliases, "{name} aliases mismatch");
    }
}

#[test]
fn bundled_recording_manifest_owns_recording_cli_namespace() {
    let recording = bundled_manifest("bmux.recording");
    assert!(recording.owns_namespaces.contains("recording"));
    assert!(
        recording
            .owns_paths
            .iter()
            .any(|path| path.0 == vec!["server".to_string(), "recording".to_string()])
    );
    assert!(
        recording
            .owns_paths
            .iter()
            .any(|path| path.0 == vec!["playbook".to_string(), "from-recording".to_string()])
    );

    let commands = recording
        .commands
        .iter()
        .map(|command| (command.name.as_str(), command.expose_in_cli))
        .collect::<Vec<_>>();
    assert!(commands.contains(&("recording-cut", true)));
    assert!(commands.contains(&("recording-path", true)));
    assert!(commands.contains(&("recording-export", true)));
    assert!(commands.contains(&("server-recording", true)));
    assert!(commands.contains(&("playbook-from-recording", true)));
}

#[test]
fn bundled_plugin_cli_manifest_does_not_proxy_recording_commands() {
    let plugin_cli = bundled_manifest("bmux.plugin_cli");
    let commands = plugin_cli
        .commands
        .iter()
        .map(|command| command.name.as_str())
        .collect::<Vec<_>>();

    assert!(
        !commands
            .iter()
            .any(|command| command.starts_with("recording-"))
    );
    assert!(
        !plugin_cli
            .owns_paths
            .iter()
            .any(|path| path.0.first().is_some_and(|segment| segment == "recording"))
    );
}

#[test]
fn bundled_tabs_manifest_requires_generic_runtime_capabilities() {
    let windows = bundled_manifest("bmux.tabs");

    let required = windows
        .required_capabilities
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>();

    assert!(required.contains(&"bmux.commands".to_string()));
    assert!(required.contains(&"bmux.contexts.read".to_string()));
    assert!(required.contains(&"bmux.contexts.write".to_string()));
    assert!(required.contains(&"bmux.clients.read".to_string()));
}

#[test]
fn bundled_permissions_manifest_requires_generic_runtime_capabilities() {
    let permissions = bundled_manifest("bmux.permissions");

    let required = permissions
        .required_capabilities
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>();

    assert!(required.contains(&"bmux.commands".to_string()));
    assert!(required.contains(&"bmux.sessions.read".to_string()));
    assert!(required.contains(&"bmux.clients.read".to_string()));
    assert!(required.contains(&"bmux.storage".to_string()));
}

#[test]
fn bundled_permissions_manifest_exposes_policy_service_interface() {
    let permissions = bundled_manifest("bmux.permissions");

    assert!(permissions.services.iter().any(|service| {
        service.interface_id == "session-policy-state"
            && service.kind == bmux_plugin_sdk::ServiceKind::Query
    }));
}

#[test]
fn bundled_tabs_manifest_exposes_tab_command_service_interface() {
    let windows = bundled_manifest("bmux.tabs");

    assert!(windows.services.iter().any(|service| {
        service.interface_id == "tabs-commands"
            && service.kind == bmux_plugin_sdk::ServiceKind::Command
    }));
}

#[test]
fn bundled_tabs_manifest_matches_pragmatic_command_surface() {
    let windows = bundled_manifest("bmux.tabs");
    let commands = windows
        .commands
        .iter()
        .map(|command| {
            (
                command.name.as_str(),
                command.path.clone(),
                command.aliases.clone(),
            )
        })
        .collect::<Vec<_>>();

    let expected = [
        ("new-tab", vec!["new-tab"], vec![vec!["tab", "new"]]),
        ("list-tabs", vec!["list-tabs"], vec![vec!["tab", "list"]]),
        ("kill-tab", vec!["kill-tab"], vec![vec!["tab", "kill"]]),
        (
            "kill-all-tabs",
            vec!["kill-all-tabs"],
            vec![vec!["tab", "kill-all"]],
        ),
        (
            "switch-tab",
            vec!["switch-tab"],
            vec![vec!["tab", "switch"]],
        ),
        ("next-tab", vec!["next-tab"], vec![vec!["tab", "next"]]),
        ("prev-tab", vec!["prev-tab"], vec![vec!["tab", "prev"]]),
        ("last-tab", vec!["last-tab"], vec![vec!["tab", "last"]]),
    ];

    for (name, path, aliases) in expected {
        let entry = commands
            .iter()
            .find(|(command_name, _, _)| *command_name == name)
            .unwrap_or_else(|| panic!("missing windows command {name}"));
        let expected_path = path.iter().map(ToString::to_string).collect::<Vec<_>>();
        assert_eq!(entry.1, expected_path, "{name} path mismatch");
        let expected_aliases = aliases
            .iter()
            .map(|alias| alias.iter().map(ToString::to_string).collect::<Vec<_>>())
            .collect::<Vec<_>>();
        assert_eq!(entry.2, expected_aliases, "{name} aliases mismatch");
    }

    let runtime_keybindings = windows
        .keybindings
        .runtime
        .iter()
        .map(|(key, action)| (key.as_str(), action.as_str()))
        .collect::<BTreeMap<_, _>>();
    assert_eq!(
        runtime_keybindings.get("c").copied(),
        Some("plugin:bmux.tabs:new-tab")
    );
    assert_eq!(
        runtime_keybindings.get("n").copied(),
        Some("plugin:bmux.tabs:next-tab")
    );
    assert_eq!(
        runtime_keybindings.get("p").copied(),
        Some("plugin:bmux.tabs:prev-tab")
    );
    assert_eq!(
        runtime_keybindings.get("w").copied(),
        Some("plugin:bmux.tabs:last-tab")
    );
}

#[test]
fn bundled_permissions_manifest_matches_pragmatic_command_surface() {
    let permissions = bundled_manifest("bmux.permissions");
    let commands = permissions
        .commands
        .iter()
        .map(|command| {
            (
                command.name.as_str(),
                command.path.clone(),
                command.aliases.clone(),
            )
        })
        .collect::<Vec<_>>();

    let expected = [
        (
            "permissions",
            vec!["permissions"],
            vec![vec!["session", "permissions"]],
        ),
        (
            "permissions-current",
            vec!["permissions-current"],
            vec![vec!["session", "permissions-current"]],
        ),
        ("grant", vec!["grant"], vec![vec!["session", "grant"]]),
        ("revoke", vec!["revoke"], vec![vec!["session", "revoke"]]),
    ];

    for (name, path, aliases) in expected {
        let entry = commands
            .iter()
            .find(|(command_name, _, _)| *command_name == name)
            .unwrap_or_else(|| panic!("missing permissions command {name}"));
        let expected_path = path.iter().map(ToString::to_string).collect::<Vec<_>>();
        assert_eq!(entry.1, expected_path, "{name} path mismatch");
        let expected_aliases = aliases
            .iter()
            .map(|alias| alias.iter().map(ToString::to_string).collect::<Vec<_>>())
            .collect::<Vec<_>>();
        assert_eq!(entry.2, expected_aliases, "{name} aliases mismatch");
    }
}