Files
storkit/server/src/matrix/rebuild.rs

146 lines
4.4 KiB
Rust
Raw Normal View History

//! 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<RebuildCommand> {
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<AgentPool>,
) -> 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);
}
}