rkat 0.7.13

CLI for the Meerkat agent platform — run LLM agents from the terminal
use std::fs;
use std::path::Path;

fn read_manifest(path: impl AsRef<Path>) -> Result<toml::Value, String> {
    let path = path.as_ref();
    let text = fs::read_to_string(path).map_err(|err| format!("read {}: {err}", path.display()))?;
    toml::from_str(&text).map_err(|err| format!("parse {}: {err}", path.display()))
}

fn string_array<'a>(value: &'a toml::Value, path: &str) -> Result<Vec<&'a str>, String> {
    value
        .get("features")
        .and_then(|features| features.get(path))
        .and_then(toml::Value::as_array)
        .ok_or_else(|| format!("manifest missing features.{path} array"))?
        .iter()
        .map(|entry| {
            entry
                .as_str()
                .ok_or_else(|| format!("features.{path} contains non-string entry"))
        })
        .collect()
}

fn dependency_table<'a>(
    manifest: &'a toml::Value,
    name: &str,
) -> Result<&'a toml::value::Table, String> {
    manifest
        .get("dependencies")
        .and_then(|dependencies| dependencies.get(name))
        .and_then(toml::Value::as_table)
        .ok_or_else(|| format!("manifest should depend on {name}"))
}

fn workspace_dependency_table<'a>(
    manifest: &'a toml::Value,
    name: &str,
) -> Result<&'a toml::value::Table, String> {
    manifest
        .get("workspace")
        .and_then(|workspace| workspace.get("dependencies"))
        .and_then(|dependencies| dependencies.get(name))
        .and_then(toml::Value::as_table)
        .ok_or_else(|| format!("workspace should depend on {name}"))
}

fn dependency_features<'a>(manifest: &'a toml::Value, name: &str) -> Result<Vec<&'a str>, String> {
    Ok(dependency_table(manifest, name)?
        .get("features")
        .and_then(toml::Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(toml::Value::as_str)
        .collect())
}

#[test]
fn slim_cli_does_not_enable_openai_realtime_unconditionally() -> Result<(), String> {
    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let cli_manifest = read_manifest(manifest_dir.join("Cargo.toml"))?;

    let default_features = string_array(&cli_manifest, "default")?;
    assert!(
        default_features.contains(&"openai-realtime"),
        "default rkat builds should keep OpenAI realtime support"
    );

    let realtime_features = string_array(&cli_manifest, "openai-realtime")?;
    assert_eq!(
        realtime_features,
        vec![
            "openai",
            "meerkat/openai-realtime",
            "meerkat-rpc?/openai-realtime"
        ],
        "the CLI realtime feature should be explicit and forward only through the facade"
    );

    let meerkat_dependency = dependency_table(&cli_manifest, "meerkat")?;
    let unconditional_features = meerkat_dependency
        .get("features")
        .and_then(toml::Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(toml::Value::as_str)
        .collect::<Vec<_>>();
    assert!(
        !unconditional_features.contains(&"openai-realtime"),
        "rkat --no-default-features must not pull the OpenAI realtime stack through its facade dependency"
    );
    Ok(())
}

#[test]
fn integration_tests_restore_rpc_default_feature_surface() -> Result<(), String> {
    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let workspace_root = manifest_dir
        .parent()
        .ok_or_else(|| "rkat manifest dir should have workspace parent".to_string())?;
    let integration_manifest = read_manifest(workspace_root.join("tests/integration/Cargo.toml"))?;

    let rpc_dependency = dependency_table(&integration_manifest, "meerkat-rpc")?;
    assert_eq!(
        rpc_dependency
            .get("workspace")
            .and_then(toml::Value::as_bool),
        Some(true),
        "integration tests should still inherit the workspace meerkat-rpc path/version"
    );

    let features = dependency_features(&integration_manifest, "meerkat-rpc")?;
    for expected in [
        "comms",
        "mcp",
        "mob",
        "openai-realtime",
        "schedule",
        "workgraph",
    ] {
        assert!(
            features.contains(&expected),
            "integration tests should opt back into meerkat-rpc/{expected} after workspace defaults are suppressed"
        );
    }
    Ok(())
}

#[test]
fn slim_cli_mob_feature_does_not_inherit_rpc_realtime_defaults() -> Result<(), String> {
    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let workspace_root = manifest_dir
        .parent()
        .ok_or_else(|| "rkat manifest dir should have workspace parent".to_string())?;
    let workspace_manifest = read_manifest(workspace_root.join("Cargo.toml"))?;
    let cli_manifest = read_manifest(manifest_dir.join("Cargo.toml"))?;
    let rpc_manifest = read_manifest(workspace_root.join("meerkat-rpc").join("Cargo.toml"))?;

    let rpc_dependency = workspace_dependency_table(&workspace_manifest, "meerkat-rpc")?;
    assert_eq!(
        rpc_dependency
            .get("default-features")
            .and_then(toml::Value::as_bool),
        Some(false),
        "rkat's optional RPC dependency must not enable RPC defaults in slim feature builds"
    );

    let mob_features = string_array(&cli_manifest, "mob")?;
    assert!(
        !mob_features
            .iter()
            .any(|feature| feature.contains("meerkat-rpc")),
        "the slim CLI mob feature should not enable the hosted RPC surface"
    );
    assert!(
        mob_features.contains(&"session-store"),
        "mob commands use persistent sessions, so slim mob builds should request that feature explicitly"
    );

    let default_features = string_array(&cli_manifest, "default")?;
    assert!(
        default_features.contains(&"rpc-surface"),
        "default rkat builds should keep the hosted RPC surface"
    );

    let rpc_surface_features = string_array(&cli_manifest, "rpc-surface")?;
    assert!(
        rpc_surface_features.contains(&"dep:meerkat-rpc")
            && rpc_surface_features.contains(&"meerkat-rpc/mob"),
        "the RPC surface feature should own the optional meerkat-rpc dependency"
    );

    let rpc_default_features = string_array(&rpc_manifest, "default")?;
    assert!(
        rpc_default_features.contains(&"openai-realtime"),
        "default RPC builds should preserve OpenAI realtime support"
    );

    let rpc_realtime_features = string_array(&rpc_manifest, "openai-realtime")?;
    assert_eq!(
        rpc_realtime_features,
        vec!["meerkat/openai-realtime", "meerkat-client/openai-realtime"],
        "RPC realtime support should be explicit so slim rkat mob builds do not inherit it"
    );

    for dependency in ["meerkat", "meerkat-client"] {
        let features = dependency_table(&rpc_manifest, dependency)?
            .get("features")
            .and_then(toml::Value::as_array)
            .into_iter()
            .flatten()
            .filter_map(toml::Value::as_str)
            .collect::<Vec<_>>();
        assert!(
            !features.contains(&"openai-realtime"),
            "meerkat-rpc should not hard-enable openai-realtime on its {dependency} dependency"
        );
    }
    Ok(())
}

#[test]
fn facade_does_not_pull_websocket_transport_without_live_features() -> Result<(), String> {
    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let workspace_root = manifest_dir
        .parent()
        .ok_or_else(|| "rkat manifest dir should have workspace parent".to_string())?;
    let facade_manifest = read_manifest(workspace_root.join("meerkat").join("Cargo.toml"))?;

    let native_dependencies = facade_manifest
        .get("target")
        .and_then(|target| target.get("cfg(not(target_arch = \"wasm32\"))"))
        .and_then(|target| target.get("dependencies"))
        .and_then(toml::Value::as_table)
        .ok_or_else(|| "meerkat facade should declare native target dependencies".to_string())?;
    assert!(
        !native_dependencies.contains_key("tokio-tungstenite"),
        "WebSocket transport belongs behind live/realtime features, not the slim facade graph"
    );
    Ok(())
}