umbriel-mcp 0.4.2

MCP server for the Umbriel Wayland compositor: windows, workspaces, outputs, pointer and keyboard input, screenshots with OCR, and the clipboard.
use anyhow::{Result, anyhow};
use serde_json::{Map, Value, json};

#[derive(Clone, Debug, PartialEq)]
pub struct CompositorAction {
    name: String,
    fields: Map<String, Value>,
    legacy_argument: Option<String>,
}

fn workspace_field(workspace: &str) -> Value {
    let workspace = workspace.trim();
    match workspace.parse::<u64>() {
        Ok(position) if !workspace.is_empty() => json!(position),
        _ => json!(workspace),
    }
}

impl CompositorAction {
    fn new(name: &str) -> Self {
        Self {
            name: name.to_owned(),
            fields: Map::new(),
            legacy_argument: None,
        }
    }

    fn with(mut self, field: &str, value: Value, legacy_argument: String) -> Self {
        self.fields.insert(field.to_owned(), value);
        self.legacy_argument = Some(legacy_argument);
        self
    }

    pub fn plain(name: &str) -> Self {
        Self::new(name)
    }

    pub fn on_workspace(name: &str, workspace: &str, output: Option<&str>) -> Self {
        let legacy = match output {
            Some(output) => format!("{}/{output}", workspace.trim()),
            None => workspace.trim().to_owned(),
        };
        let mut action = Self::new(name).with("workspace", workspace_field(workspace), legacy);
        if let Some(output) = output {
            action.fields.insert("output".to_owned(), json!(output));
        }
        action
    }

    pub fn on_window(name: &str, window_id: Option<&str>) -> Self {
        match window_id.map(str::trim).filter(|id| !id.is_empty()) {
            Some(id) => Self::new(name).with("window", json!(id), id.to_owned()),
            None => Self::new(name),
        }
    }

    pub fn by_delta(name: &str, delta: f64) -> Self {
        Self::new(name).with("delta", json!(delta), delta.to_string())
    }

    pub fn set_layout(mode: &str) -> Self {
        let mode = mode.trim().to_lowercase();
        let typed = if mode == "floating" {
            "stacking"
        } else {
            &mode
        };
        let legacy = if mode == "stacking" {
            "floating"
        } else {
            &mode
        };
        Self::new("workspace-set-layout").with("layout", json!(typed), legacy.to_owned())
    }

    pub fn spawn(command: &str) -> Self {
        Self::new("spawn").with("command", json!(command), command.to_owned())
    }

    pub fn typed_request(&self) -> Value {
        let mut request = Map::new();
        request.insert("cmd".to_owned(), json!("action"));
        request.insert("action".to_owned(), json!(self.name));
        request.extend(self.fields.clone());
        Value::Object(request)
    }

    pub fn summary(&self) -> String {
        let mut words = vec![self.name.clone()];
        for (field, value) in &self.fields {
            let value = value
                .as_str()
                .map(str::to_owned)
                .unwrap_or_else(|| value.to_string());
            if field == "output" {
                words.push("--output".to_owned());
            }
            words.push(value);
        }
        words.join(" ")
    }

    pub fn legacy_text(&self) -> String {
        match &self.legacy_argument {
            Some(argument) => format!("{}:{argument}", self.name),
            None => self.name.clone(),
        }
    }
}

pub fn split_words(text: &str) -> Result<Vec<String>> {
    let mut words = Vec::new();
    let mut current = String::new();
    let mut in_word = false;
    let mut quote: Option<char> = None;
    let mut characters = text.chars();
    while let Some(character) = characters.next() {
        match (quote, character) {
            (Some(open), close) if close == open => quote = None,
            (Some('"') | None, '\\') => {
                let escaped = characters
                    .next()
                    .ok_or_else(|| anyhow!("the action ends with a lone backslash"))?;
                current.push(escaped);
                in_word = true;
            }
            (Some(_), other) => current.push(other),
            (None, '\'' | '"') => {
                quote = Some(character);
                in_word = true;
            }
            (None, blank) if blank.is_whitespace() => {
                if in_word {
                    words.push(std::mem::take(&mut current));
                    in_word = false;
                }
            }
            (None, other) => {
                current.push(other);
                in_word = true;
            }
        }
    }
    if quote.is_some() {
        return Err(anyhow!("the action has an unclosed quote"));
    }
    if in_word {
        words.push(current);
    }
    Ok(words)
}

const WORKSPACE_SELECTING_ACTIONS: &[&str] = &["workspace-switch", "window-move-to-workspace"];

fn legacy_form(text: &str) -> Option<(&str, &str)> {
    let first_word = text.split_whitespace().next()?;
    let (name, _) = first_word.split_once(':')?;
    let looks_like_an_action_name = !name.is_empty()
        && name
            .chars()
            .all(|character| character.is_ascii_alphanumeric() || character == '-');
    looks_like_an_action_name.then(|| (name, &text[name.len() + 1..]))
}

pub fn words_for_command_line(text: &str) -> Result<Vec<String>> {
    let text = text.trim();
    if let Some((name, argument)) = legacy_form(text) {
        let argument = argument.trim();
        let mut words = vec![name.to_owned()];
        match argument.split_once('/') {
            Some((workspace, output)) if WORKSPACE_SELECTING_ACTIONS.contains(&name) => {
                words.extend([
                    workspace.to_owned(),
                    "--output".to_owned(),
                    output.to_owned(),
                ]);
            }
            _ if argument.is_empty() => {}
            _ => words.push(argument.to_owned()),
        }
        return Ok(words);
    }
    if let Some(command) = text
        .strip_prefix("spawn")
        .filter(|rest| rest.starts_with(char::is_whitespace))
    {
        return Ok(vec!["spawn".to_owned(), command.trim().to_owned()]);
    }
    let words = split_words(text)?;
    if words.is_empty() {
        return Err(anyhow!("the action is empty"));
    }
    Ok(words)
}

pub fn legacy_text_for(text: &str) -> Result<String> {
    let text = text.trim();
    if legacy_form(text).is_some() {
        return Ok(text.to_owned());
    }
    let words = words_for_command_line(text)?;
    let (name, arguments) = words.split_first().expect("at least one word");
    if arguments.is_empty() {
        return Ok(name.clone());
    }
    let mut argument = String::new();
    let mut remaining = arguments.iter();
    while let Some(word) = remaining.next() {
        if word == "--output" {
            let output = remaining
                .next()
                .ok_or_else(|| anyhow!("--output needs an output name after it"))?;
            argument.push('/');
            argument.push_str(output);
        } else {
            if !argument.is_empty() {
                argument.push(' ');
            }
            argument.push_str(word);
        }
    }
    Ok(format!("{name}:{argument}"))
}

#[cfg(test)]
mod tests {
    use super::{CompositorAction, legacy_text_for, split_words, words_for_command_line};
    use serde_json::json;

    #[test]
    fn workspace_actions_send_positions_as_numbers_and_names_as_text() {
        let switch = CompositorAction::on_workspace("workspace-switch", "2", Some("DP-1"));
        assert_eq!(
            switch.typed_request(),
            json!({"cmd": "action", "action": "workspace-switch", "workspace": 2, "output": "DP-1"})
        );
        assert_eq!(switch.legacy_text(), "workspace-switch:2/DP-1");
        let named = CompositorAction::on_workspace("workspace-switch", "web", None);
        assert_eq!(named.typed_request()["workspace"], "web");
        assert_eq!(named.legacy_text(), "workspace-switch:web");
    }

    #[test]
    fn window_actions_name_the_window_only_when_given() {
        let focus = CompositorAction::on_window("window-focus", Some("abc"));
        assert_eq!(
            focus.typed_request(),
            json!({"cmd": "action", "action": "window-focus", "window": "abc"})
        );
        assert_eq!(focus.legacy_text(), "window-focus:abc");
        let close = CompositorAction::on_window("window-close", None);
        assert_eq!(
            close.typed_request(),
            json!({"cmd": "action", "action": "window-close"})
        );
        assert_eq!(close.legacy_text(), "window-close");
    }

    #[test]
    fn the_floating_layout_is_called_stacking_by_newer_umbriel() {
        assert_eq!(
            CompositorAction::set_layout("floating").typed_request()["layout"],
            "stacking"
        );
        assert_eq!(
            CompositorAction::set_layout("stacking").legacy_text(),
            "workspace-set-layout:floating"
        );
        assert_eq!(
            CompositorAction::set_layout("dwindle").legacy_text(),
            "workspace-set-layout:dwindle"
        );
    }

    #[test]
    fn deltas_and_commands_keep_their_values() {
        let resize = CompositorAction::by_delta("window-modify-width-left", -0.1);
        assert_eq!(resize.typed_request()["delta"], json!(-0.1));
        assert_eq!(resize.legacy_text(), "window-modify-width-left:-0.1");
        let spawn = CompositorAction::spawn("sh -c 'echo hi'");
        assert_eq!(spawn.typed_request()["command"], "sh -c 'echo hi'");
        assert_eq!(spawn.legacy_text(), "spawn:sh -c 'echo hi'");
    }

    #[test]
    fn words_split_like_config_strings() {
        assert_eq!(
            split_words(r#"workspace-switch 'My web' --output "DP 1" a\ b"#).unwrap(),
            ["workspace-switch", "My web", "--output", "DP 1", "a b"]
        );
        assert!(split_words("workspace-switch 'open").is_err());
    }

    fn words(text: &str) -> Vec<String> {
        words_for_command_line(text).unwrap()
    }

    #[test]
    fn either_syntax_becomes_command_line_words() {
        assert_eq!(
            words("workspace-switch 2 --output DP-1"),
            ["workspace-switch", "2", "--output", "DP-1"]
        );
        assert_eq!(
            words("workspace-switch:2/DP-1"),
            ["workspace-switch", "2", "--output", "DP-1"]
        );
        assert_eq!(words("window-focus:abc"), ["window-focus", "abc"]);
        assert_eq!(words("overview-toggle"), ["overview-toggle"]);
        assert_eq!(words("spawn sh -c 'echo hi'"), ["spawn", "sh -c 'echo hi'"]);
        assert_eq!(words("spawn:kitty --hold"), ["spawn", "kitty --hold"]);
        assert!(words_for_command_line("   ").is_err());
    }

    #[test]
    fn either_syntax_becomes_the_older_name_colon_argument_form() {
        assert_eq!(
            legacy_text_for("workspace-switch 2 --output DP-1").unwrap(),
            "workspace-switch:2/DP-1"
        );
        assert_eq!(
            legacy_text_for("window-focus abc").unwrap(),
            "window-focus:abc"
        );
        assert_eq!(
            legacy_text_for("window-focus:abc").unwrap(),
            "window-focus:abc"
        );
        assert_eq!(
            legacy_text_for("overview-toggle").unwrap(),
            "overview-toggle"
        );
        assert_eq!(
            legacy_text_for("spawn kitty --hold").unwrap(),
            "spawn:kitty --hold"
        );
    }
}