butterfly-bot 0.8.0

Butterfly Bot is an opinionated personal-ops AI assistant built for people who want results, not setup overhead.
Documentation
mod common;

use std::sync::Arc;

use serde_json::json;

use butterfly_bot::error::ButterflyBotError;
use butterfly_bot::interfaces::plugins::PluginManager;
use butterfly_bot::plugins::manager::DefaultPluginManager;
use butterfly_bot::plugins::registry::ToolRegistry;

use common::{
    ConditionalTool, ConfigurablePlugin, DefaultConfigureTool, DummyPlugin, DummyTool, FailingTool,
};
use tempfile::{tempdir, NamedTempFile};

#[tokio::test]
async fn tool_registry_and_plugin_manager() {
    let registry = ToolRegistry::new();
    let tool = Arc::new(DummyTool::new("tool"));
    assert!(registry.register_tool(tool.clone()).await);
    assert!(!registry.register_tool(tool.clone()).await);

    let fail_tool = Arc::new(FailingTool);
    assert!(!registry.register_tool(fail_tool).await);

    assert!(registry.assign_tool_to_agent("agent", "tool").await);
    assert!(!registry.assign_tool_to_agent("agent", "missing").await);

    let got = registry.get_tool("tool").await.unwrap();
    assert_eq!(got.name(), "tool");

    let agent_tools = registry.get_agent_tools("agent").await;
    assert_eq!(agent_tools.len(), 1);

    let all = registry.list_all_tools().await;
    assert_eq!(all, vec!["tool".to_string()]);

    let wasm_file = NamedTempFile::new().unwrap();
    std::fs::write(wasm_file.path(), [0x00, 0x61, 0x73, 0x6D]).unwrap();
    let wasm_path = wasm_file.path().to_string_lossy().to_string();

    registry
        .configure_all_tools(json!({
            "value": 1,
            "tools": {
                "settings": {
                    "sandbox": {
                        "tools": {
                            "tool": {
                                "wasm": {
                                    "module": wasm_path
                                }
                            }
                        }
                    }
                }
            }
        }))
        .await
        .unwrap();

    let mut manager = DefaultPluginManager::new(json!({"ok":true}));
    assert!(manager.register_plugin(Box::new(DummyPlugin::new("p1", true))));
    assert!(!manager.register_plugin(Box::new(DummyPlugin::new("p2", false))));
    assert!(manager.get_plugin("p1").is_some());
    assert!(manager.get_plugin("missing").is_none());
    assert_eq!(manager.list_plugins().len(), 1);
    manager.configure(json!({"reconfigured":true}));
    manager.load_plugins();
    let _ = manager.tool_registry();

    let registry = ToolRegistry::new();
    let conditional = Arc::new(ConditionalTool {
        name: "conditional".to_string(),
    });
    assert!(registry.register_tool(conditional).await);
    let err = registry
        .configure_all_tools(json!({"fail": true}))
        .await
        .unwrap_err();
    assert!(matches!(err, ButterflyBotError::Runtime(_)));

    let registry = ToolRegistry::new();
    let default_tool = Arc::new(DefaultConfigureTool);
    assert!(registry.register_tool(default_tool).await);
}

#[tokio::test]
async fn tool_registry_audit_log() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("audit.log");
    let registry = ToolRegistry::new();
    registry
        .configure_all_tools(json!({
            "tools": {"settings": {"audit_log_path": path.to_string_lossy()}}
        }))
        .await
        .unwrap();
    registry.audit_tool_call("tool", "success").await.unwrap();

    let content = std::fs::read_to_string(path).unwrap();
    assert!(content.contains("\"tool\":\"tool\""));
    assert!(content.contains("\"status\":\"success\""));
}

#[tokio::test]
async fn plugin_manager_auto_loads() {
    use std::sync::Mutex as StdMutex;

    let config = json!({
        "plugins": [
            "p1",
            {"name":"p2","config":{"x":1}},
            {"class":"p3","config":{"y":2}},
            {"config":{"ignored":true}},
            "missing",
            {"name":"p4"}
        ]
    });
    let mut manager = DefaultPluginManager::new(config);

    let seen_p2: Arc<StdMutex<Option<serde_json::Value>>> = Arc::new(StdMutex::new(None));
    let seen_p3: Arc<StdMutex<Option<serde_json::Value>>> = Arc::new(StdMutex::new(None));

    let seen_p2_factory = seen_p2.clone();
    manager.register_factory("p1", |_| Box::new(DummyPlugin::new("p1", true)));
    manager.register_factory("p2", move |cfg| {
        *seen_p2_factory.lock().unwrap() = Some(cfg);
        Box::new(ConfigurablePlugin {
            name: "p2".to_string(),
        })
    });
    let seen_p3_factory = seen_p3.clone();
    manager.register_factory("p3", move |cfg| {
        *seen_p3_factory.lock().unwrap() = Some(cfg);
        Box::new(ConfigurablePlugin {
            name: "p3".to_string(),
        })
    });
    manager.register_factory("p4", |_| Box::new(DummyPlugin::new("p4", false)));

    let mut loaded = manager.load_plugins();
    loaded.sort();
    assert_eq!(
        loaded,
        vec!["p1".to_string(), "p2".to_string(), "p3".to_string()]
    );
    assert!(manager.get_plugin("p1").is_some());
    assert!(manager.get_plugin("missing").is_none());
    assert_eq!(*seen_p2.lock().unwrap(), Some(json!({"x":1})));
    assert_eq!(*seen_p3.lock().unwrap(), Some(json!({"y":2})));
    assert!(manager.get_plugin("p4").is_none());

    let mut manager = DefaultPluginManager::new(json!({}));
    manager.register_factory("auto1", |_| Box::new(DummyPlugin::new("auto1", true)));
    manager.register_factory("auto2", |_| Box::new(DummyPlugin::new("auto2", true)));
    assert!(manager.register_plugin(Box::new(DummyPlugin::new("auto1", true))));

    let mut loaded = manager.load_plugins();
    loaded.sort();
    assert_eq!(loaded, vec!["auto2".to_string()]);
}