diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 6ed423c..6da5ecc 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -46,8 +46,10 @@ pub async fn run( )); // ── Memory (the brain) ──────────────────────────────────────── - let mem: Arc = - Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?); + let mem: Arc = Arc::from(memory::create_memory( + &config.memory, + &config.workspace_dir, + )?); tracing::info!(backend = mem.name(), "Memory initialized"); // ── Tools (including memory tools) ──────────────────────────── diff --git a/src/channels/discord.rs b/src/channels/discord.rs index efa03ea..c498595 100644 --- a/src/channels/discord.rs +++ b/src/channels/discord.rs @@ -39,8 +39,7 @@ impl DiscordChannel { } } -const BASE64_ALPHABET: &[u8] = - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +const BASE64_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; /// Minimal base64 decode (no extra dep) — only needs to decode the user ID portion #[allow(clippy::cast_possible_truncation)] @@ -155,8 +154,7 @@ impl Channel for DiscordChannel { let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1); let hb_interval = heartbeat_interval; tokio::spawn(async move { - let mut interval = - tokio::time::interval(std::time::Duration::from_millis(hb_interval)); + let mut interval = tokio::time::interval(std::time::Duration::from_millis(hb_interval)); loop { interval.tick().await; if hb_tx.send(()).await.is_err() { diff --git a/src/channels/imessage.rs b/src/channels/imessage.rs index ec0262e..a0ac72e 100644 --- a/src/channels/imessage.rs +++ b/src/channels/imessage.rs @@ -23,9 +23,9 @@ impl IMessageChannel { if self.allowed_contacts.iter().any(|u| u == "*") { return true; } - self.allowed_contacts.iter().any(|u| { - u.eq_ignore_ascii_case(sender) - }) + self.allowed_contacts + .iter() + .any(|u| u.eq_ignore_ascii_case(sender)) } } diff --git a/src/channels/matrix.rs b/src/channels/matrix.rs index b86fef2..9f8924c 100644 --- a/src/channels/matrix.rs +++ b/src/channels/matrix.rs @@ -93,10 +93,7 @@ impl MatrixChannel { } async fn get_my_user_id(&self) -> anyhow::Result { - let url = format!( - "{}/_matrix/client/v3/account/whoami", - self.homeserver - ); + let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver); let resp = self .client .get(&url) @@ -250,10 +247,7 @@ impl Channel for MatrixChannel { } async fn health_check(&self) -> bool { - let url = format!( - "{}/_matrix/client/v3/account/whoami", - self.homeserver - ); + let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver); let Ok(resp) = self .client .get(&url) @@ -413,8 +407,14 @@ mod tests { let room = resp.rooms.join.get("!room:matrix.org").unwrap(); assert_eq!(room.timeline.events.len(), 1); assert_eq!(room.timeline.events[0].sender, "@user:matrix.org"); - assert_eq!(room.timeline.events[0].content.body.as_deref(), Some("Hello!")); - assert_eq!(room.timeline.events[0].content.msgtype.as_deref(), Some("m.text")); + assert_eq!( + room.timeline.events[0].content.body.as_deref(), + Some("Hello!") + ); + assert_eq!( + room.timeline.events[0].content.msgtype.as_deref(), + Some("m.text") + ); } #[test] diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 70ef9ac..0bb1732 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -75,8 +75,15 @@ pub fn build_system_prompt( for skill in skills { let _ = writeln!(prompt, " "); let _ = writeln!(prompt, " {}", skill.name); - let _ = writeln!(prompt, " {}", skill.description); - let location = workspace_dir.join("skills").join(&skill.name).join("SKILL.md"); + let _ = writeln!( + prompt, + " {}", + skill.description + ); + let location = workspace_dir + .join("skills") + .join(&skill.name) + .join("SKILL.md"); let _ = writeln!(prompt, " {}", location.display()); let _ = writeln!(prompt, " "); } @@ -84,11 +91,16 @@ pub fn build_system_prompt( } // ── 4. Workspace ──────────────────────────────────────────── - let _ = writeln!(prompt, "## Workspace\n\nWorking directory: `{}`\n", workspace_dir.display()); + let _ = writeln!( + prompt, + "## Workspace\n\nWorking directory: `{}`\n", + workspace_dir.display() + ); // ── 5. Bootstrap files (injected into context) ────────────── prompt.push_str("## Project Context\n\n"); - prompt.push_str("The following workspace files define your identity, behavior, and context.\n\n"); + prompt + .push_str("The following workspace files define your identity, behavior, and context.\n\n"); let bootstrap_files = [ "AGENTS.md", @@ -118,8 +130,8 @@ pub fn build_system_prompt( let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n"); // ── 7. Runtime ────────────────────────────────────────────── - let host = hostname::get() - .map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); + let host = + hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); let _ = writeln!( prompt, "## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n", @@ -180,10 +192,7 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul ("iMessage", config.channels_config.imessage.is_some()), ("Matrix", config.channels_config.matrix.is_some()), ] { - println!( - " {} {name}", - if configured { "✅" } else { "❌" } - ); + println!(" {} {name}", if configured { "✅" } else { "❌" }); } println!("\nTo start channels: zeroclaw channel start"); println!("To configure: zeroclaw onboard"); @@ -193,7 +202,9 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul channel_type, config: _, } => { - anyhow::bail!("Channel type '{channel_type}' — use `zeroclaw onboard` to configure channels"); + anyhow::bail!( + "Channel type '{channel_type}' — use `zeroclaw onboard` to configure channels" + ); } super::ChannelCommands::Remove { name } => { anyhow::bail!("Remove channel '{name}' — edit ~/.zeroclaw/config.toml directly"); @@ -213,8 +224,10 @@ pub async fn start_channels(config: Config) -> Result<()> { .clone() .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); let temperature = config.default_temperature; - let mem: Arc = - Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?); + let mem: Arc = Arc::from(memory::create_memory( + &config.memory, + &config.workspace_dir, + )?); // Build system prompt from workspace identity files + skills let workspace = config.workspace_dir.clone(); @@ -233,7 +246,14 @@ pub async fn start_channels(config: Config) -> Result<()> { let system_prompt = build_system_prompt(&workspace, &model, &tool_descs, &skills); if !skills.is_empty() { - println!(" 🧩 Skills: {}", skills.iter().map(|s| s.name.as_str()).collect::>().join(", ")); + println!( + " 🧩 Skills: {}", + skills + .iter() + .map(|s| s.name.as_str()) + .collect::>() + .join(", ") + ); } // Collect active channels @@ -263,9 +283,7 @@ pub async fn start_channels(config: Config) -> Result<()> { } if let Some(ref im) = config.channels_config.imessage { - channels.push(Arc::new(IMessageChannel::new( - im.allowed_contacts.clone(), - ))); + channels.push(Arc::new(IMessageChannel::new(im.allowed_contacts.clone()))); } if let Some(ref mx) = config.channels_config.matrix { @@ -284,8 +302,19 @@ pub async fn start_channels(config: Config) -> Result<()> { println!("🦀 ZeroClaw Channel Server"); println!(" 🤖 Model: {model}"); - println!(" 🧠 Memory: {} (auto-save: {})", config.memory.backend, if config.memory.auto_save { "on" } else { "off" }); - println!(" 📡 Channels: {}", channels.iter().map(|c| c.name()).collect::>().join(", ")); + println!( + " 🧠 Memory: {} (auto-save: {})", + config.memory.backend, + if config.memory.auto_save { "on" } else { "off" } + ); + println!( + " 📡 Channels: {}", + channels + .iter() + .map(|c| c.name()) + .collect::>() + .join(", ") + ); println!(); println!(" Listening for messages... (Ctrl+C to stop)"); println!(); @@ -331,7 +360,10 @@ pub async fn start_channels(config: Config) -> Result<()> { } // Call the LLM with system prompt (identity + soul + tools) - match provider.chat_with_system(Some(&system_prompt), &msg.content, &model, temperature).await { + match provider + .chat_with_system(Some(&system_prompt), &msg.content, &model, temperature) + .await + { Ok(response) => { println!( " 🤖 Reply: {}", @@ -355,9 +387,7 @@ pub async fn start_channels(config: Config) -> Result<()> { eprintln!(" ❌ LLM error: {e}"); for ch in &channels { if ch.name() == msg.channel { - let _ = ch - .send(&format!("⚠️ Error: {e}"), &msg.sender) - .await; + let _ = ch.send(&format!("⚠️ Error: {e}"), &msg.sender).await; break; } } @@ -384,9 +414,17 @@ mod tests { std::fs::write(tmp.path().join("SOUL.md"), "# Soul\nBe helpful.").unwrap(); std::fs::write(tmp.path().join("IDENTITY.md"), "# Identity\nName: ZeroClaw").unwrap(); std::fs::write(tmp.path().join("USER.md"), "# User\nName: Test User").unwrap(); - std::fs::write(tmp.path().join("AGENTS.md"), "# Agents\nFollow instructions.").unwrap(); + std::fs::write( + tmp.path().join("AGENTS.md"), + "# Agents\nFollow instructions.", + ) + .unwrap(); std::fs::write(tmp.path().join("TOOLS.md"), "# Tools\nUse shell carefully.").unwrap(); - std::fs::write(tmp.path().join("HEARTBEAT.md"), "# Heartbeat\nCheck status.").unwrap(); + std::fs::write( + tmp.path().join("HEARTBEAT.md"), + "# Heartbeat\nCheck status.", + ) + .unwrap(); std::fs::write(tmp.path().join("MEMORY.md"), "# Memory\nUser likes Rust.").unwrap(); tmp } @@ -401,15 +439,24 @@ mod tests { assert!(prompt.contains("## Tools"), "missing Tools section"); assert!(prompt.contains("## Safety"), "missing Safety section"); assert!(prompt.contains("## Workspace"), "missing Workspace section"); - assert!(prompt.contains("## Project Context"), "missing Project Context"); - assert!(prompt.contains("## Current Date & Time"), "missing Date/Time"); + assert!( + prompt.contains("## Project Context"), + "missing Project Context" + ); + assert!( + prompt.contains("## Current Date & Time"), + "missing Date/Time" + ); assert!(prompt.contains("## Runtime"), "missing Runtime section"); } #[test] fn prompt_injects_tools() { let ws = make_workspace(); - let tools = vec![("shell", "Run commands"), ("memory_recall", "Search memory")]; + let tools = vec![ + ("shell", "Run commands"), + ("memory_recall", "Search memory"), + ]; let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[]); assert!(prompt.contains("**shell**")); @@ -435,7 +482,10 @@ mod tests { assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header"); assert!(prompt.contains("Be helpful"), "missing SOUL content"); assert!(prompt.contains("### IDENTITY.md"), "missing IDENTITY.md"); - assert!(prompt.contains("Name: ZeroClaw"), "missing IDENTITY content"); + assert!( + prompt.contains("Name: ZeroClaw"), + "missing IDENTITY content" + ); assert!(prompt.contains("### USER.md"), "missing USER.md"); assert!(prompt.contains("### AGENTS.md"), "missing AGENTS.md"); assert!(prompt.contains("### TOOLS.md"), "missing TOOLS.md"); @@ -460,12 +510,18 @@ mod tests { let ws = make_workspace(); // No BOOTSTRAP.md — should not appear let prompt = build_system_prompt(ws.path(), "model", &[], &[]); - assert!(!prompt.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should not appear when missing"); + assert!( + !prompt.contains("### BOOTSTRAP.md"), + "BOOTSTRAP.md should not appear when missing" + ); // Create BOOTSTRAP.md — should appear std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap(); let prompt2 = build_system_prompt(ws.path(), "model", &[], &[]); - assert!(prompt2.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should appear when present"); + assert!( + prompt2.contains("### BOOTSTRAP.md"), + "BOOTSTRAP.md should appear when present" + ); assert!(prompt2.contains("First run")); } @@ -475,13 +531,23 @@ mod tests { let memory_dir = ws.path().join("memory"); std::fs::create_dir_all(&memory_dir).unwrap(); let today = chrono::Local::now().format("%Y-%m-%d").to_string(); - std::fs::write(memory_dir.join(format!("{today}.md")), "# Daily\nSome note.").unwrap(); + std::fs::write( + memory_dir.join(format!("{today}.md")), + "# Daily\nSome note.", + ) + .unwrap(); let prompt = build_system_prompt(ws.path(), "model", &[], &[]); // Daily notes should NOT be in the system prompt (on-demand via tools) - assert!(!prompt.contains("Daily Notes"), "daily notes should not be auto-injected"); - assert!(!prompt.contains("Some note"), "daily content should not be in prompt"); + assert!( + !prompt.contains("Daily Notes"), + "daily notes should not be auto-injected" + ); + assert!( + !prompt.contains("Some note"), + "daily content should not be in prompt" + ); } #[test] @@ -513,7 +579,10 @@ mod tests { assert!(prompt.contains("code-review")); assert!(prompt.contains("Review code for bugs")); assert!(prompt.contains("SKILL.md")); - assert!(prompt.contains("loaded on demand"), "should mention on-demand loading"); + assert!( + prompt.contains("loaded on demand"), + "should mention on-demand loading" + ); // Full prompt content should NOT be dumped assert!(!prompt.contains("Long prompt content that should NOT appear")); } @@ -527,8 +596,14 @@ mod tests { let prompt = build_system_prompt(ws.path(), "model", &[], &[]); - assert!(prompt.contains("truncated at"), "large files should be truncated"); - assert!(!prompt.contains(&big_content), "full content should not appear"); + assert!( + prompt.contains("truncated at"), + "large files should be truncated" + ); + assert!( + !prompt.contains(&big_content), + "full content should not appear" + ); } #[test] @@ -539,7 +614,10 @@ mod tests { let prompt = build_system_prompt(ws.path(), "model", &[], &[]); // Empty file should not produce a header - assert!(!prompt.contains("### TOOLS.md"), "empty files should be skipped"); + assert!( + !prompt.contains("### TOOLS.md"), + "empty files should be skipped" + ); } #[test] diff --git a/src/channels/slack.rs b/src/channels/slack.rs index 662ee53..d7f807d 100644 --- a/src/channels/slack.rs +++ b/src/channels/slack.rs @@ -84,10 +84,7 @@ impl Channel for SlackChannel { loop { tokio::time::sleep(std::time::Duration::from_secs(3)).await; - let mut params = vec![ - ("channel", channel_id.clone()), - ("limit", "10".to_string()), - ]; + let mut params = vec![("channel", channel_id.clone()), ("limit", "10".to_string())]; if !last_ts.is_empty() { params.push(("oldest", last_ts.clone())); } diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index 7e71cab..56f8a3c 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -102,7 +102,9 @@ impl Channel for TelegramChannel { .unwrap_or("unknown"); if !self.is_user_allowed(username) { - tracing::warn!("Telegram: ignoring message from unauthorized user: {username}"); + tracing::warn!( + "Telegram: ignoring message from unauthorized user: {username}" + ); continue; } diff --git a/src/config/schema.rs b/src/config/schema.rs index a8ea616..ab6a3bb 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -570,10 +570,7 @@ default_temperature = 0.7 let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); assert!(parsed.imessage.is_some()); assert!(parsed.matrix.is_some()); - assert_eq!( - parsed.imessage.unwrap().allowed_contacts, - vec!["+1"] - ); + assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]); assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org"); } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 41f5ba5..64d4f29 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -22,8 +22,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { .clone() .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); let temperature = config.default_temperature; - let mem: Arc = - Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?); + let mem: Arc = Arc::from(memory::create_memory( + &config.memory, + &config.workspace_dir, + )?); // Extract webhook secret for authentication let webhook_secret: Option> = config @@ -39,7 +41,9 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { if webhook_secret.is_some() { println!(" 🔒 Webhook authentication: ENABLED (X-Webhook-Secret header required)"); } else { - println!(" ⚠️ Webhook authentication: DISABLED (set [channels.webhook] secret to enable)"); + println!( + " ⚠️ Webhook authentication: DISABLED (set [channels.webhook] secret to enable)" + ); } println!(" Press Ctrl+C to stop.\n"); @@ -64,7 +68,19 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { if let [method, path, ..] = parts.as_slice() { tracing::info!("{peer} → {method} {path}"); - handle_request(&mut stream, method, path, &request, &provider, &model, temperature, &mem, auto_save, secret.as_ref()).await; + handle_request( + &mut stream, + method, + path, + &request, + &provider, + &model, + temperature, + &mem, + auto_save, + secret.as_ref(), + ) + .await; } else { let _ = send_response(&mut stream, 400, "Bad Request").await; } @@ -116,14 +132,25 @@ async fn handle_request( match header_val { Some(val) if val == secret.as_ref() => {} _ => { - tracing::warn!("Webhook: rejected request — invalid or missing X-Webhook-Secret"); + tracing::warn!( + "Webhook: rejected request — invalid or missing X-Webhook-Secret" + ); let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"}); let _ = send_json(stream, 401, &err).await; return; } } } - handle_webhook(stream, request, provider, model, temperature, mem, auto_save).await; + handle_webhook( + stream, + request, + provider, + model, + temperature, + mem, + auto_save, + ) + .await; } _ => { @@ -206,7 +233,8 @@ mod tests { #[test] fn extract_header_finds_value() { - let req = "POST /webhook HTTP/1.1\r\nHost: localhost\r\nX-Webhook-Secret: my-secret\r\n\r\n{}"; + let req = + "POST /webhook HTTP/1.1\r\nHost: localhost\r\nX-Webhook-Secret: my-secret\r\n\r\n{}"; assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("my-secret")); } @@ -244,13 +272,19 @@ mod tests { fn extract_header_colon_in_value() { let req = "POST /webhook HTTP/1.1\r\nAuthorization: Bearer sk-abc:123\r\n\r\n{}"; // split_once on ':' means only the first colon splits key/value - assert_eq!(extract_header(req, "Authorization"), Some("Bearer sk-abc:123")); + assert_eq!( + extract_header(req, "Authorization"), + Some("Bearer sk-abc:123") + ); } #[test] fn extract_header_different_header() { let req = "POST /webhook HTTP/1.1\r\nContent-Type: application/json\r\nX-Webhook-Secret: mysecret\r\n\r\n{}"; - assert_eq!(extract_header(req, "Content-Type"), Some("application/json")); + assert_eq!( + extract_header(req, "Content-Type"), + Some("application/json") + ); assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("mysecret")); } diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 59d300e..26b14af 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -130,7 +130,9 @@ fn list_integrations(config: &Config, filter_category: Option<&str>) -> Result<( let total = available + active + coming; println!(); - println!(" {total} integrations: {active} active, {available} available, {coming} coming soon"); + println!( + " {total} integrations: {active} active, {available} available, {coming} coming soon" + ); println!(); println!(" Configure: zeroclaw onboard"); println!(" Details: zeroclaw integrations info "); @@ -144,9 +146,7 @@ fn show_integration_info(config: &Config, name: &str) -> Result<()> { let name_lower = name.to_lowercase(); let Some(entry) = entries.iter().find(|e| e.name.to_lowercase() == name_lower) else { - anyhow::bail!( - "Unknown integration: {name}. Run `zeroclaw integrations list` to see all." - ); + anyhow::bail!("Unknown integration: {name}. Run `zeroclaw integrations list` to see all."); }; let status = (entry.status_fn)(config); @@ -157,7 +157,12 @@ fn show_integration_info(config: &Config, name: &str) -> Result<()> { }; println!(); - println!(" {} {} — {}", icon, console::style(entry.name).white().bold(), entry.description); + println!( + " {} {} — {}", + icon, + console::style(entry.name).white().bold(), + entry.description + ); println!(" Category: {}", entry.category.label()); println!(" Status: {label}"); println!(); diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs index 0a2e3ea..e581bdf 100644 --- a/src/integrations/registry.rs +++ b/src/integrations/registry.rs @@ -161,7 +161,10 @@ pub fn all_integrations() -> Vec { description: "Gemini 2.5 Pro/Flash", category: IntegrationCategory::AiModel, status_fn: |c| { - if c.default_model.as_deref().is_some_and(|m| m.starts_with("google/")) { + if c.default_model + .as_deref() + .is_some_and(|m| m.starts_with("google/")) + { IntegrationStatus::Active } else { IntegrationStatus::Available @@ -173,7 +176,10 @@ pub fn all_integrations() -> Vec { description: "DeepSeek V3 & R1", category: IntegrationCategory::AiModel, status_fn: |c| { - if c.default_model.as_deref().is_some_and(|m| m.starts_with("deepseek/")) { + if c.default_model + .as_deref() + .is_some_and(|m| m.starts_with("deepseek/")) + { IntegrationStatus::Active } else { IntegrationStatus::Available @@ -185,7 +191,10 @@ pub fn all_integrations() -> Vec { description: "Grok 3 & 4", category: IntegrationCategory::AiModel, status_fn: |c| { - if c.default_model.as_deref().is_some_and(|m| m.starts_with("x-ai/")) { + if c.default_model + .as_deref() + .is_some_and(|m| m.starts_with("x-ai/")) + { IntegrationStatus::Active } else { IntegrationStatus::Available @@ -197,7 +206,10 @@ pub fn all_integrations() -> Vec { description: "Mistral Large & Codestral", category: IntegrationCategory::AiModel, status_fn: |c| { - if c.default_model.as_deref().is_some_and(|m| m.starts_with("mistral")) { + if c.default_model + .as_deref() + .is_some_and(|m| m.starts_with("mistral")) + { IntegrationStatus::Active } else { IntegrationStatus::Available @@ -655,15 +667,17 @@ pub fn all_integrations() -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::config::schema::{ChannelsConfig, IMessageConfig, MatrixConfig, TelegramConfig}; use crate::config::Config; - use crate::config::schema::{ - ChannelsConfig, IMessageConfig, MatrixConfig, TelegramConfig, - }; #[test] fn registry_has_entries() { let entries = all_integrations(); - assert!(entries.len() >= 50, "Expected 50+ integrations, got {}", entries.len()); + assert!( + entries.len() >= 50, + "Expected 50+ integrations, got {}", + entries.len() + ); } #[test] @@ -727,7 +741,10 @@ mod tests { let config = Config::default(); let entries = all_integrations(); let tg = entries.iter().find(|e| e.name == "Telegram").unwrap(); - assert!(matches!((tg.status_fn)(&config), IntegrationStatus::Available)); + assert!(matches!( + (tg.status_fn)(&config), + IntegrationStatus::Available + )); } #[test] @@ -746,7 +763,10 @@ mod tests { let config = Config::default(); let entries = all_integrations(); let im = entries.iter().find(|e| e.name == "iMessage").unwrap(); - assert!(matches!((im.status_fn)(&config), IntegrationStatus::Available)); + assert!(matches!( + (im.status_fn)(&config), + IntegrationStatus::Available + )); } #[test] @@ -768,7 +788,10 @@ mod tests { let config = Config::default(); let entries = all_integrations(); let mx = entries.iter().find(|e| e.name == "Matrix").unwrap(); - assert!(matches!((mx.status_fn)(&config), IntegrationStatus::Available)); + assert!(matches!( + (mx.status_fn)(&config), + IntegrationStatus::Available + )); } #[test] @@ -813,9 +836,21 @@ mod tests { #[test] fn category_counts_reasonable() { let entries = all_integrations(); - let chat_count = entries.iter().filter(|e| e.category == IntegrationCategory::Chat).count(); - let ai_count = entries.iter().filter(|e| e.category == IntegrationCategory::AiModel).count(); - assert!(chat_count >= 5, "Expected 5+ chat integrations, got {chat_count}"); - assert!(ai_count >= 5, "Expected 5+ AI model integrations, got {ai_count}"); + let chat_count = entries + .iter() + .filter(|e| e.category == IntegrationCategory::Chat) + .count(); + let ai_count = entries + .iter() + .filter(|e| e.category == IntegrationCategory::AiModel) + .count(); + assert!( + chat_count >= 5, + "Expected 5+ chat integrations, got {chat_count}" + ); + assert!( + ai_count >= 5, + "Expected 5+ AI model integrations, got {ai_count}" + ); } } diff --git a/src/main.rs b/src/main.rs index 6c31550..b4dc424 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,13 +19,13 @@ mod config; mod cron; mod gateway; mod heartbeat; +mod integrations; mod memory; mod observability; mod onboard; mod providers; mod runtime; mod security; -mod integrations; mod skills; mod tools; @@ -298,7 +298,11 @@ async fn main() -> Result<()> { ] { println!( " {name:9} {}", - if configured { "✅ configured" } else { "❌ not configured" } + if configured { + "✅ configured" + } else { + "❌ not configured" + } ); } } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index c980a5a..bf31273 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -222,7 +222,10 @@ fn setup_provider() -> Result<(String, String, String)> { let providers: Vec<(&str, &str)> = match tier_idx { 0 => vec![ - ("openrouter", "OpenRouter — 200+ models, 1 API key (recommended)"), + ( + "openrouter", + "OpenRouter — 200+ models, 1 API key (recommended)", + ), ("venice", "Venice AI — privacy-first (Llama, Opus)"), ("anthropic", "Anthropic — Claude Sonnet & Opus (direct)"), ("openai", "OpenAI — GPT-4o, o1, GPT-5 (direct)"), @@ -251,9 +254,7 @@ fn setup_provider() -> Result<(String, String, String)> { ("opencode", "OpenCode Zen — code-focused AI"), ("cohere", "Cohere — Command R+ & embeddings"), ], - _ => vec![ - ("ollama", "Ollama — local models (Llama, Mistral, Phi)"), - ], + _ => vec![("ollama", "Ollama — local models (Llama, Mistral, Phi)")], }; let provider_labels: Vec<&str> = providers.iter().map(|(_, label)| *label).collect(); @@ -321,18 +322,36 @@ fn setup_provider() -> Result<(String, String, String)> { // ── Model selection ── let models: Vec<(&str, &str)> = match provider_name { "openrouter" => vec![ - ("anthropic/claude-sonnet-4-20250514", "Claude Sonnet 4 (balanced, recommended)"), - ("anthropic/claude-3.5-sonnet", "Claude 3.5 Sonnet (fast, affordable)"), + ( + "anthropic/claude-sonnet-4-20250514", + "Claude Sonnet 4 (balanced, recommended)", + ), + ( + "anthropic/claude-3.5-sonnet", + "Claude 3.5 Sonnet (fast, affordable)", + ), ("openai/gpt-4o", "GPT-4o (OpenAI flagship)"), ("openai/gpt-4o-mini", "GPT-4o Mini (fast, cheap)"), - ("google/gemini-2.0-flash-001", "Gemini 2.0 Flash (Google, fast)"), - ("meta-llama/llama-3.3-70b-instruct", "Llama 3.3 70B (open source)"), + ( + "google/gemini-2.0-flash-001", + "Gemini 2.0 Flash (Google, fast)", + ), + ( + "meta-llama/llama-3.3-70b-instruct", + "Llama 3.3 70B (open source)", + ), ("deepseek/deepseek-chat", "DeepSeek Chat (affordable)"), ], "anthropic" => vec![ - ("claude-sonnet-4-20250514", "Claude Sonnet 4 (balanced, recommended)"), + ( + "claude-sonnet-4-20250514", + "Claude Sonnet 4 (balanced, recommended)", + ), ("claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet (fast)"), - ("claude-3-5-haiku-20241022", "Claude 3.5 Haiku (fastest, cheapest)"), + ( + "claude-3-5-haiku-20241022", + "Claude 3.5 Haiku (fastest, cheapest)", + ), ], "openai" => vec![ ("gpt-4o", "GPT-4o (flagship)"), @@ -345,7 +364,10 @@ fn setup_provider() -> Result<(String, String, String)> { ("llama-3.1-405b", "Llama 3.1 405B (largest open source)"), ], "groq" => vec![ - ("llama-3.3-70b-versatile", "Llama 3.3 70B (fast, recommended)"), + ( + "llama-3.3-70b-versatile", + "Llama 3.3 70B (fast, recommended)", + ), ("llama-3.1-8b-instant", "Llama 3.1 8B (instant)"), ("mixtral-8x7b-32768", "Mixtral 8x7B (32K context)"), ], @@ -367,12 +389,24 @@ fn setup_provider() -> Result<(String, String, String)> { ("sonar", "Sonar (search, fast)"), ], "fireworks" => vec![ - ("accounts/fireworks/models/llama-v3p3-70b-instruct", "Llama 3.3 70B"), - ("accounts/fireworks/models/mixtral-8x22b-instruct", "Mixtral 8x22B"), + ( + "accounts/fireworks/models/llama-v3p3-70b-instruct", + "Llama 3.3 70B", + ), + ( + "accounts/fireworks/models/mixtral-8x22b-instruct", + "Mixtral 8x22B", + ), ], "together" => vec![ - ("meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", "Llama 3.1 70B Turbo"), - ("meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", "Llama 3.1 8B Turbo"), + ( + "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", + "Llama 3.1 70B Turbo", + ), + ( + "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", + "Llama 3.1 8B Turbo", + ), ("mistralai/Mixtral-8x22B-Instruct-v0.1", "Mixtral 8x22B"), ], "cohere" => vec![ @@ -397,9 +431,7 @@ fn setup_provider() -> Result<(String, String, String)> { ("codellama", "Code Llama"), ("phi3", "Phi-3 (small, fast)"), ], - _ => vec![ - ("default", "Default model"), - ], + _ => vec![("default", "Default model")], }; let model_labels: Vec<&str> = models.iter().map(|(_, label)| *label).collect(); @@ -518,7 +550,9 @@ fn setup_project_context() -> Result { 0 => "Be direct and concise. Skip pleasantries. Get to the point.".to_string(), 1 => "Be friendly and casual. Warm but efficient.".to_string(), 2 => "Be technical and detailed. Thorough explanations, code-first.".to_string(), - _ => "Adapt to the situation. Be concise when needed, thorough when it matters.".to_string(), + _ => { + "Adapt to the situation. Be concise when needed, thorough when it matters.".to_string() + } }; println!( @@ -560,27 +594,51 @@ fn setup_channels() -> Result { let options = vec![ format!( "Telegram {}", - if config.telegram.is_some() { "✅ connected" } else { "— connect your bot" } + if config.telegram.is_some() { + "✅ connected" + } else { + "— connect your bot" + } ), format!( "Discord {}", - if config.discord.is_some() { "✅ connected" } else { "— connect your bot" } + if config.discord.is_some() { + "✅ connected" + } else { + "— connect your bot" + } ), format!( "Slack {}", - if config.slack.is_some() { "✅ connected" } else { "— connect your bot" } + if config.slack.is_some() { + "✅ connected" + } else { + "— connect your bot" + } ), format!( "iMessage {}", - if config.imessage.is_some() { "✅ configured" } else { "— macOS only" } + if config.imessage.is_some() { + "✅ configured" + } else { + "— macOS only" + } ), format!( "Matrix {}", - if config.matrix.is_some() { "✅ connected" } else { "— self-hosted chat" } + if config.matrix.is_some() { + "✅ connected" + } else { + "— self-hosted chat" + } ), format!( "Webhook {}", - if config.webhook.is_some() { "✅ configured" } else { "— HTTP endpoint" } + if config.webhook.is_some() { + "✅ configured" + } else { + "— HTTP endpoint" + } ), "Done — finish setup".to_string(), ]; @@ -670,9 +728,7 @@ fn setup_channels() -> Result { print_bullet("4. Invite bot to your server with messages permission"); println!(); - let token: String = Input::new() - .with_prompt(" Bot token") - .interact_text()?; + let token: String = Input::new().with_prompt(" Bot token").interact_text()?; if token.trim().is_empty() { println!(" {} Skipped", style("→").dim()); @@ -750,7 +806,10 @@ fn setup_channels() -> Result { { Ok(resp) if resp.status().is_success() => { let data: serde_json::Value = resp.json().unwrap_or_default(); - let ok = data.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false); + let ok = data + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); let team = data .get("team") .and_then(serde_json::Value::as_str) @@ -761,11 +820,11 @@ fn setup_channels() -> Result { style("✅").green().bold() ); } else { - let err = data.get("error").and_then(serde_json::Value::as_str).unwrap_or("unknown error"); - println!( - "\r {} Slack error: {err}", - style("❌").red().bold() - ); + let err = data + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown error"); + println!("\r {} Slack error: {err}", style("❌").red().bold()); continue; } } @@ -790,8 +849,16 @@ fn setup_channels() -> Result { config.slack = Some(SlackConfig { bot_token: token, - app_token: if app_token.is_empty() { None } else { Some(app_token) }, - channel_id: if channel.is_empty() { None } else { Some(channel) }, + app_token: if app_token.is_empty() { + None + } else { + Some(app_token) + }, + channel_id: if channel.is_empty() { + None + } else { + Some(channel) + }, allowed_users: vec![], }); } @@ -813,7 +880,9 @@ fn setup_channels() -> Result { } print_bullet("ZeroClaw reads your iMessage database and replies via AppleScript."); - print_bullet("You need to grant Full Disk Access to your terminal in System Settings."); + print_bullet( + "You need to grant Full Disk Access to your terminal in System Settings.", + ); println!(); let contacts_str: String = Input::new() @@ -824,7 +893,10 @@ fn setup_channels() -> Result { let allowed_contacts = if contacts_str.trim() == "*" { vec!["*".into()] } else { - contacts_str.split(',').map(|s| s.trim().to_string()).collect() + contacts_str + .split(',') + .map(|s| s.trim().to_string()) + .collect() }; config.imessage = Some(IMessageConfig { allowed_contacts }); @@ -855,9 +927,8 @@ fn setup_channels() -> Result { continue; } - let access_token: String = Input::new() - .with_prompt(" Access token") - .interact_text()?; + let access_token: String = + Input::new().with_prompt(" Access token").interact_text()?; if access_token.trim().is_empty() { println!(" {} Skipped — token required", style("→").dim()); @@ -936,7 +1007,11 @@ fn setup_channels() -> Result { config.webhook = Some(WebhookConfig { port: port.parse().unwrap_or(8080), - secret: if secret.is_empty() { None } else { Some(secret) }, + secret: if secret.is_empty() { + None + } else { + Some(secret) + }, }); println!( " {} Webhook on port {}", @@ -1330,9 +1405,7 @@ fn print_summary(config: &Config) { let mut step = 1u8; if config.api_key.is_none() { - let env_var = provider_env_var( - config.default_provider.as_deref().unwrap_or("openrouter"), - ); + let env_var = provider_env_var(config.default_provider.as_deref().unwrap_or("openrouter")); println!( " {} Set your API key:", style(format!("{step}.")).cyan().bold() @@ -1352,10 +1425,7 @@ fn print_summary(config: &Config) { style(format!("{step}.")).cyan().bold(), style("Launch your channels").white().bold() ); - println!( - " {}", - style("zeroclaw channel start").yellow() - ); + println!(" {}", style("zeroclaw channel start").yellow()); println!(); step += 1; } @@ -1440,10 +1510,7 @@ mod tests { scaffold_workspace(tmp.path(), &ctx).unwrap(); for dir in &["sessions", "memory", "state", "cron", "skills"] { - assert!( - tmp.path().join(dir).is_dir(), - "missing subdirectory: {dir}" - ); + assert!(tmp.path().join(dir).is_dir(), "missing subdirectory: {dir}"); } } @@ -1459,7 +1526,10 @@ mod tests { scaffold_workspace(tmp.path(), &ctx).unwrap(); let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); - assert!(user_md.contains("**Name:** Alice"), "USER.md should contain user name"); + assert!( + user_md.contains("**Name:** Alice"), + "USER.md should contain user name" + ); let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); assert!( diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index 2a0ac8e..1b52826 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -53,9 +53,7 @@ impl Provider for AnthropicProvider { temperature: f64, ) -> anyhow::Result { let api_key = self.api_key.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Anthropic API key not set. Set ANTHROPIC_API_KEY or edit config.toml." - ) + anyhow::anyhow!("Anthropic API key not set. Set ANTHROPIC_API_KEY or edit config.toml.") })?; let request = ChatRequest { @@ -122,10 +120,15 @@ mod tests { #[tokio::test] async fn chat_fails_without_key() { let p = AnthropicProvider::new(None); - let result = p.chat_with_system(None, "hello", "claude-3-opus", 0.7).await; + let result = p + .chat_with_system(None, "hello", "claude-3-opus", 0.7) + .await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); - assert!(err.contains("API key not set"), "Expected key error, got: {err}"); + assert!( + err.contains("API key not set"), + "Expected key error, got: {err}" + ); } #[tokio::test] @@ -150,7 +153,10 @@ mod tests { temperature: 0.7, }; let json = serde_json::to_string(&req).unwrap(); - assert!(!json.contains("system"), "system field should be skipped when None"); + assert!( + !json.contains("system"), + "system field should be skipped when None" + ); assert!(json.contains("claude-3-opus")); assert!(json.contains("hello")); } @@ -188,7 +194,8 @@ mod tests { #[test] fn chat_response_multiple_blocks() { - let json = r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#; + let json = + r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#; let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.content.len(), 2); assert_eq!(resp.content[0].text, "First"); diff --git a/src/providers/compatible.rs b/src/providers/compatible.rs index 78aea4e..90e4568 100644 --- a/src/providers/compatible.rs +++ b/src/providers/compatible.rs @@ -170,9 +170,14 @@ mod tests { #[tokio::test] async fn chat_fails_without_key() { let p = make_provider("Venice", "https://api.venice.ai", None); - let result = p.chat_with_system(None, "hello", "llama-3.3-70b", 0.7).await; + let result = p + .chat_with_system(None, "hello", "llama-3.3-70b", 0.7) + .await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Venice API key not set")); + assert!(result + .unwrap_err() + .to_string() + .contains("Venice API key not set")); } #[test] @@ -180,8 +185,14 @@ mod tests { let req = ChatRequest { model: "llama-3.3-70b".to_string(), messages: vec![ - Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() }, - Message { role: "user".to_string(), content: "hello".to_string() }, + Message { + role: "system".to_string(), + content: "You are ZeroClaw".to_string(), + }, + Message { + role: "user".to_string(), + content: "hello".to_string(), + }, ], temperature: 0.7, }; @@ -208,7 +219,10 @@ mod tests { #[test] fn x_api_key_auth_style() { let p = OpenAiCompatibleProvider::new( - "moonshot", "https://api.moonshot.cn", Some("ms-key"), AuthStyle::XApiKey, + "moonshot", + "https://api.moonshot.cn", + Some("ms-key"), + AuthStyle::XApiKey, ); assert!(matches!(p.auth_header, AuthStyle::XApiKey)); } @@ -216,7 +230,10 @@ mod tests { #[test] fn custom_auth_style() { let p = OpenAiCompatibleProvider::new( - "custom", "https://api.example.com", Some("key"), AuthStyle::Custom("X-Custom-Key".into()), + "custom", + "https://api.example.com", + Some("key"), + AuthStyle::Custom("X-Custom-Key".into()), ); assert!(matches!(p.auth_header, AuthStyle::Custom(_))); } @@ -238,7 +255,8 @@ mod tests { assert!(result.is_err(), "{} should fail without key", p.name); assert!( result.unwrap_err().to_string().contains("API key not set"), - "{} error should mention key", p.name + "{} error should mention key", + p.name ); } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 9f3fe58..436984e 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -250,11 +250,29 @@ mod tests { #[test] fn factory_all_providers_create_successfully() { let providers = [ - "openrouter", "anthropic", "openai", "ollama", - "venice", "vercel", "cloudflare", "moonshot", "synthetic", - "opencode", "zai", "glm", "minimax", "bedrock", "qianfan", - "groq", "mistral", "xai", "deepseek", "together", - "fireworks", "perplexity", "cohere", + "openrouter", + "anthropic", + "openai", + "ollama", + "venice", + "vercel", + "cloudflare", + "moonshot", + "synthetic", + "opencode", + "zai", + "glm", + "minimax", + "bedrock", + "qianfan", + "groq", + "mistral", + "xai", + "deepseek", + "together", + "fireworks", + "perplexity", + "cohere", ]; for name in providers { assert!( diff --git a/src/providers/ollama.rs b/src/providers/ollama.rs index 232858e..ee8c070 100644 --- a/src/providers/ollama.rs +++ b/src/providers/ollama.rs @@ -85,7 +85,9 @@ impl Provider for OllamaProvider { if !response.status().is_success() { let error = response.text().await?; - anyhow::bail!("Ollama error: {error}. Is Ollama running? (brew install ollama && ollama serve)"); + anyhow::bail!( + "Ollama error: {error}. Is Ollama running? (brew install ollama && ollama serve)" + ); } let chat_response: ChatResponse = response.json().await?; @@ -126,8 +128,14 @@ mod tests { let req = ChatRequest { model: "llama3".to_string(), messages: vec![ - Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() }, - Message { role: "user".to_string(), content: "hello".to_string() }, + Message { + role: "system".to_string(), + content: "You are ZeroClaw".to_string(), + }, + Message { + role: "user".to_string(), + content: "hello".to_string(), + }, ], stream: false, options: Options { temperature: 0.7 }, @@ -143,9 +151,10 @@ mod tests { fn request_serializes_without_system() { let req = ChatRequest { model: "mistral".to_string(), - messages: vec![ - Message { role: "user".to_string(), content: "test".to_string() }, - ], + messages: vec![Message { + role: "user".to_string(), + content: "test".to_string(), + }], stream: false, options: Options { temperature: 0.0 }, }; diff --git a/src/providers/openai.rs b/src/providers/openai.rs index 86249d7..dae89fe 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -146,8 +146,14 @@ mod tests { let req = ChatRequest { model: "gpt-4o".to_string(), messages: vec![ - Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() }, - Message { role: "user".to_string(), content: "hello".to_string() }, + Message { + role: "system".to_string(), + content: "You are ZeroClaw".to_string(), + }, + Message { + role: "user".to_string(), + content: "hello".to_string(), + }, ], temperature: 0.7, }; @@ -161,9 +167,10 @@ mod tests { fn request_serializes_without_system() { let req = ChatRequest { model: "gpt-4o".to_string(), - messages: vec![ - Message { role: "user".to_string(), content: "hello".to_string() }, - ], + messages: vec![Message { + role: "user".to_string(), + content: "hello".to_string(), + }], temperature: 0.0, }; let json = serde_json::to_string(&req).unwrap(); diff --git a/src/providers/traits.rs b/src/providers/traits.rs index 42419d5..8a24714 100644 --- a/src/providers/traits.rs +++ b/src/providers/traits.rs @@ -2,12 +2,7 @@ use async_trait::async_trait; #[async_trait] pub trait Provider: Send + Sync { - async fn chat( - &self, - message: &str, - model: &str, - temperature: f64, - ) -> anyhow::Result { + async fn chat(&self, message: &str, model: &str, temperature: f64) -> anyhow::Result { self.chat_with_system(None, message, model, temperature) .await } diff --git a/src/security/policy.rs b/src/security/policy.rs index b1f356e..5d88e9c 100644 --- a/src/security/policy.rs +++ b/src/security/policy.rs @@ -37,8 +37,13 @@ impl ActionTracker { /// Record an action and return the current count within the window. pub fn record(&self) -> usize { - let mut actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let cutoff = Instant::now().checked_sub(std::time::Duration::from_secs(3600)).unwrap_or_else(Instant::now); + let mut actions = self + .actions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let cutoff = Instant::now() + .checked_sub(std::time::Duration::from_secs(3600)) + .unwrap_or_else(Instant::now); actions.retain(|t| *t > cutoff); actions.push(Instant::now()); actions.len() @@ -46,8 +51,13 @@ impl ActionTracker { /// Count of actions in the current window without recording. pub fn count(&self) -> usize { - let mut actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let cutoff = Instant::now().checked_sub(std::time::Duration::from_secs(3600)).unwrap_or_else(Instant::now); + let mut actions = self + .actions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let cutoff = Instant::now() + .checked_sub(std::time::Duration::from_secs(3600)) + .unwrap_or_else(Instant::now); actions.retain(|t| *t > cutoff); actions.len() } @@ -55,7 +65,10 @@ impl ActionTracker { impl Clone for ActionTracker { fn clone(&self) -> Self { - let actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let actions = self + .actions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); Self { actions: Mutex::new(actions.clone()), } @@ -582,7 +595,7 @@ mod tests { max_actions_per_hour: 1, ..SecurityPolicy::default() }; - assert!(p.record_action()); // 1 — exactly at limit + assert!(p.record_action()); // 1 — exactly at limit assert!(!p.record_action()); // 2 — over assert!(!p.record_action()); // 3 — still over } diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 69a6137..f14730e 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -158,7 +158,11 @@ pub fn skills_to_prompt(skills: &[Skill]) -> String { if !skill.tools.is_empty() { prompt.push_str("Tools:\n"); for tool in &skill.tools { - let _ = writeln!(prompt, "- **{}**: {} ({})", tool.name, tool.description, tool.kind); + let _ = writeln!( + prompt, + "- **{}**: {} ({})", + tool.name, tool.description, tool.kind + ); } } @@ -242,14 +246,16 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re if !skill.tools.is_empty() { println!( " Tools: {}", - skill.tools.iter().map(|t| t.name.as_str()).collect::>().join(", ") + skill + .tools + .iter() + .map(|t| t.name.as_str()) + .collect::>() + .join(", ") ); } if !skill.tags.is_empty() { - println!( - " Tags: {}", - skill.tags.join(", ") - ); + println!(" Tags: {}", skill.tags.join(", ")); } } } @@ -270,7 +276,10 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re .output()?; if output.status.success() { - println!(" {} Skill installed successfully!", console::style("✓").green().bold()); + println!( + " {} Skill installed successfully!", + console::style("✓").green().bold() + ); println!(" Restart `zeroclaw channel start` to activate."); } else { let stderr = String::from_utf8_lossy(&output.stderr); @@ -293,7 +302,11 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re anyhow::bail!("Symlink not supported on this platform. Copy the skill directory manually."); } - println!(" {} Skill linked: {}", console::style("✓").green().bold(), dest.display()); + println!( + " {} Skill linked: {}", + console::style("✓").green().bold(), + dest.display() + ); } Ok(()) @@ -305,7 +318,11 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re } std::fs::remove_dir_all(&skill_path)?; - println!(" {} Skill '{}' removed.", console::style("✓").green().bold(), name); + println!( + " {} Skill '{}' removed.", + console::style("✓").green().bold(), + name + ); Ok(()) } } diff --git a/src/tools/memory_recall.rs b/src/tools/memory_recall.rs index 779c251..ff1385a 100644 --- a/src/tools/memory_recall.rs +++ b/src/tools/memory_recall.rs @@ -64,7 +64,9 @@ impl Tool for MemoryRecallTool { Ok(entries) => { let mut output = format!("Found {} memories:\n", entries.len()); for entry in &entries { - let score = entry.score.map_or_else(String::new, |s| format!(" [{s:.0}%]")); + let score = entry + .score + .map_or_else(String::new, |s| format!(" [{s:.0}%]")); let _ = writeln!( output, "- [{}] {}: {}{score}", @@ -102,10 +104,7 @@ mod tests { async fn recall_empty() { let (_tmp, mem) = seeded_mem(); let tool = MemoryRecallTool::new(mem); - let result = tool - .execute(json!({"query": "anything"})) - .await - .unwrap(); + let result = tool.execute(json!({"query": "anything"})).await.unwrap(); assert!(result.success); assert!(result.output.contains("No memories found")); } @@ -131,9 +130,13 @@ mod tests { async fn recall_respects_limit() { let (_tmp, mem) = seeded_mem(); for i in 0..10 { - mem.store(&format!("k{i}"), &format!("Rust fact {i}"), MemoryCategory::Core) - .await - .unwrap(); + mem.store( + &format!("k{i}"), + &format!("Rust fact {i}"), + MemoryCategory::Core, + ) + .await + .unwrap(); } let tool = MemoryRecallTool::new(mem); diff --git a/src/tools/mod.rs b/src/tools/mod.rs index ecd182e..ccc0779 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -32,10 +32,7 @@ pub fn default_tools(security: Arc) -> Vec> { } /// Create full tool registry including memory tools -pub fn all_tools( - security: Arc, - memory: Arc, -) -> Vec> { +pub fn all_tools(security: Arc, memory: Arc) -> Vec> { vec![ Box::new(ShellTool::new(security.clone())), Box::new(FileReadTool::new(security.clone())), @@ -51,8 +48,10 @@ pub async fn handle_command(command: super::ToolCommands, config: Config) -> Res workspace_dir: config.workspace_dir.clone(), ..SecurityPolicy::default() }); - let mem: Arc = - Arc::from(crate::memory::create_memory(&config.memory, &config.workspace_dir)?); + let mem: Arc = Arc::from(crate::memory::create_memory( + &config.memory, + &config.workspace_dir, + )?); let tools_list = all_tools(security, mem); match command { diff --git a/tests/memory_comparison.rs b/tests/memory_comparison.rs index f9ef8a8..8e0f4d6 100644 --- a/tests/memory_comparison.rs +++ b/tests/memory_comparison.rs @@ -6,9 +6,7 @@ use std::time::Instant; use tempfile::TempDir; // We test both backends through the public memory module -use zeroclaw::memory::{ - markdown::MarkdownMemory, sqlite::SqliteMemory, Memory, MemoryCategory, -}; +use zeroclaw::memory::{markdown::MarkdownMemory, sqlite::SqliteMemory, Memory, MemoryCategory}; // ── Helpers ──────────────────────────────────────────────────── @@ -80,16 +78,52 @@ async fn compare_recall_quality() { // Seed both with identical data let entries = vec![ - ("lang_pref", "User prefers Rust over Python", MemoryCategory::Core), - ("editor", "Uses VS Code with rust-analyzer", MemoryCategory::Core), + ( + "lang_pref", + "User prefers Rust over Python", + MemoryCategory::Core, + ), + ( + "editor", + "Uses VS Code with rust-analyzer", + MemoryCategory::Core, + ), ("tz", "Timezone is EST, works 9-5", MemoryCategory::Core), - ("proj1", "Working on ZeroClaw AI assistant", MemoryCategory::Daily), - ("proj2", "Previous project was a web scraper in Python", MemoryCategory::Daily), - ("deploy", "Deploys to Hetzner VPS via Docker", MemoryCategory::Core), - ("model", "Prefers Claude Sonnet for coding tasks", MemoryCategory::Core), - ("style", "Likes concise responses, no fluff", MemoryCategory::Core), - ("rust_note", "Rust's ownership model prevents memory bugs", MemoryCategory::Daily), - ("perf", "Cares about binary size and startup time", MemoryCategory::Core), + ( + "proj1", + "Working on ZeroClaw AI assistant", + MemoryCategory::Daily, + ), + ( + "proj2", + "Previous project was a web scraper in Python", + MemoryCategory::Daily, + ), + ( + "deploy", + "Deploys to Hetzner VPS via Docker", + MemoryCategory::Core, + ), + ( + "model", + "Prefers Claude Sonnet for coding tasks", + MemoryCategory::Core, + ), + ( + "style", + "Likes concise responses, no fluff", + MemoryCategory::Core, + ), + ( + "rust_note", + "Rust's ownership model prevents memory bugs", + MemoryCategory::Daily, + ), + ( + "perf", + "Cares about binary size and startup time", + MemoryCategory::Core, + ), ]; for (key, content, cat) in &entries { @@ -270,8 +304,10 @@ async fn compare_upsert() { println!("\n============================================================"); println!("UPSERT (store same key twice):"); - println!(" SQLite: count={sq_count}, latest=\"{}\"", - sq_entry.as_ref().map_or("none", |e| &e.content)); + println!( + " SQLite: count={sq_count}, latest=\"{}\"", + sq_entry.as_ref().map_or("none", |e| &e.content) + ); println!(" Markdown: count={md_count} (append-only, both entries kept)"); println!(" Can still find latest: {}", !md_results.is_empty()); @@ -311,7 +347,11 @@ async fn compare_forget() { ); println!( " Markdown: {} (append-only by design)", - if md_forgot { "✅ Deleted" } else { "⚠️ Cannot delete (audit trail)" }, + if md_forgot { + "✅ Deleted" + } else { + "⚠️ Cannot delete (audit trail)" + }, ); // SQLite can delete @@ -332,14 +372,28 @@ async fn compare_category_filter() { let md = markdown_backend(tmp_md.path()); // Mix of categories - sq.store("a", "core fact 1", MemoryCategory::Core).await.unwrap(); - sq.store("b", "core fact 2", MemoryCategory::Core).await.unwrap(); - sq.store("c", "daily note", MemoryCategory::Daily).await.unwrap(); - sq.store("d", "convo msg", MemoryCategory::Conversation).await.unwrap(); + sq.store("a", "core fact 1", MemoryCategory::Core) + .await + .unwrap(); + sq.store("b", "core fact 2", MemoryCategory::Core) + .await + .unwrap(); + sq.store("c", "daily note", MemoryCategory::Daily) + .await + .unwrap(); + sq.store("d", "convo msg", MemoryCategory::Conversation) + .await + .unwrap(); - md.store("a", "core fact 1", MemoryCategory::Core).await.unwrap(); - md.store("b", "core fact 2", MemoryCategory::Core).await.unwrap(); - md.store("c", "daily note", MemoryCategory::Daily).await.unwrap(); + md.store("a", "core fact 1", MemoryCategory::Core) + .await + .unwrap(); + md.store("b", "core fact 2", MemoryCategory::Core) + .await + .unwrap(); + md.store("c", "daily note", MemoryCategory::Daily) + .await + .unwrap(); let sq_core = sq.list(Some(&MemoryCategory::Core)).await.unwrap(); let sq_daily = sq.list(Some(&MemoryCategory::Daily)).await.unwrap(); @@ -352,10 +406,19 @@ async fn compare_category_filter() { println!("\n============================================================"); println!("CATEGORY FILTERING:"); - println!(" SQLite: core={}, daily={}, conv={}, all={}", - sq_core.len(), sq_daily.len(), sq_conv.len(), sq_all.len()); - println!(" Markdown: core={}, daily={}, all={}", - md_core.len(), md_daily.len(), md_all.len()); + println!( + " SQLite: core={}, daily={}, conv={}, all={}", + sq_core.len(), + sq_daily.len(), + sq_conv.len(), + sq_all.len() + ); + println!( + " Markdown: core={}, daily={}, all={}", + md_core.len(), + md_daily.len(), + md_all.len() + ); // SQLite: precise category filtering via SQL WHERE assert_eq!(sq_core.len(), 2);