//! Rebuild command: trigger a server rebuild and restart. //! //! `{bot_name} rebuild` stops all running agents, rebuilds the server binary //! with `cargo build`, and re-execs the process with the new binary. If the //! build fails the error is reported back to the room and the server keeps //! running. use crate::agents::AgentPool; use std::path::Path; use std::sync::Arc; /// A parsed rebuild command. #[derive(Debug, PartialEq)] pub struct RebuildCommand; /// Parse a rebuild command from a raw message body. /// /// Strips the bot mention prefix and checks whether the command word is /// `rebuild`. Returns `None` when the message is not a rebuild command. pub fn extract_rebuild_command( message: &str, bot_name: &str, bot_user_id: &str, ) -> Option { let stripped = strip_mention(message, bot_name, bot_user_id); let trimmed = stripped .trim() .trim_start_matches(|c: char| !c.is_alphanumeric()); let cmd = match trimmed.split_once(char::is_whitespace) { Some((c, _)) => c, None => trimmed, }; if cmd.eq_ignore_ascii_case("rebuild") { Some(RebuildCommand) } else { None } } /// Handle a rebuild command: trigger server rebuild and restart. /// /// Returns a string describing the outcome. On build failure the error /// message is returned so it can be posted to the room; the server keeps /// running. On success this function never returns (the process re-execs). pub async fn handle_rebuild( bot_name: &str, project_root: &Path, agents: &Arc, ) -> String { crate::slog!("[matrix-bot] rebuild command received (bot={bot_name})"); match crate::rebuild::rebuild_and_restart(agents, project_root, None).await { Ok(msg) => msg, Err(e) => format!("Rebuild failed: {e}"), } } /// Strip the bot mention prefix from a raw Matrix message body. fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { let trimmed = message.trim(); if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { return rest; } if let Some(localpart) = bot_user_id.split(':').next() && let Some(rest) = strip_prefix_ci(trimmed, localpart) { return rest; } if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { return rest; } trimmed } fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { if text.len() < prefix.len() { return None; } if !text[..prefix.len()].eq_ignore_ascii_case(prefix) { return None; } let rest = &text[prefix.len()..]; match rest.chars().next() { None => Some(rest), Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, _ => Some(rest), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn extract_with_display_name() { let cmd = extract_rebuild_command("Timmy rebuild", "Timmy", "@timmy:home.local"); assert_eq!(cmd, Some(RebuildCommand)); } #[test] fn extract_with_full_user_id() { let cmd = extract_rebuild_command( "@timmy:home.local rebuild", "Timmy", "@timmy:home.local", ); assert_eq!(cmd, Some(RebuildCommand)); } #[test] fn extract_with_localpart() { let cmd = extract_rebuild_command("@timmy rebuild", "Timmy", "@timmy:home.local"); assert_eq!(cmd, Some(RebuildCommand)); } #[test] fn extract_case_insensitive() { let cmd = extract_rebuild_command("Timmy REBUILD", "Timmy", "@timmy:home.local"); assert_eq!(cmd, Some(RebuildCommand)); } #[test] fn extract_non_rebuild_returns_none() { let cmd = extract_rebuild_command("Timmy help", "Timmy", "@timmy:home.local"); assert_eq!(cmd, None); } #[test] fn extract_ignores_extra_args() { // "rebuild" with trailing text is still a rebuild command let cmd = extract_rebuild_command("Timmy rebuild now", "Timmy", "@timmy:home.local"); assert_eq!(cmd, Some(RebuildCommand)); } #[test] fn extract_no_match_returns_none() { let cmd = extract_rebuild_command("Timmy status", "Timmy", "@timmy:home.local"); assert_eq!(cmd, None); } }