Skip to main content

mur_chat/
command.rs

1//! Command parser — parse chat messages into Commander commands.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Parsed command from a chat message.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(tag = "command", rename_all = "snake_case")]
9pub enum ChatCommand {
10    /// Run a workflow: `/run <name> [--shadow] [--var key=value]`
11    Run {
12        workflow: String,
13        shadow: bool,
14        variables: HashMap<String, String>,
15    },
16    /// List workflows: `/workflows`
17    Workflows,
18    /// Show status: `/status`
19    Status,
20    /// Show audit log: `/audit [workflow_id]`
21    Audit { workflow_id: Option<String> },
22    /// Show help: `/help`
23    Help,
24    /// Stop a running workflow: `/stop <execution_id>`
25    Stop { execution_id: String },
26    /// Approve a breakpoint: `/approve <execution_id>`
27    Approve { execution_id: String },
28    /// Deny a breakpoint: `/deny <execution_id>`
29    Deny { execution_id: String },
30    /// List scheduled jobs: `/schedule`
31    Schedule,
32    /// Show skills: `/skills`
33    Skills,
34    /// Unknown command
35    Unknown { text: String },
36}
37
38/// Parse a chat message into a command.
39pub fn parse_command(text: &str) -> ChatCommand {
40    let text = text.trim();
41
42    // Must start with /
43    if !text.starts_with('/') {
44        return ChatCommand::Unknown {
45            text: text.to_string(),
46        };
47    }
48
49    let parts: Vec<&str> = text.split_whitespace().collect();
50    let cmd = parts.first().map(|s| s.to_lowercase()).unwrap_or_default();
51
52    match cmd.as_str() {
53        "/run" => {
54            let workflow = parts.get(1).unwrap_or(&"").to_string();
55            if workflow.is_empty() {
56                return ChatCommand::Help;
57            }
58            let shadow = parts.contains(&"--shadow");
59            let mut variables = HashMap::new();
60            for (i, part) in parts.iter().enumerate() {
61                if *part == "--var" {
62                    if let Some(kv) = parts.get(i + 1) {
63                        if let Some((k, v)) = kv.split_once('=') {
64                            variables.insert(k.to_string(), v.to_string());
65                        }
66                    }
67                }
68            }
69            ChatCommand::Run {
70                workflow,
71                shadow,
72                variables,
73            }
74        }
75        "/workflows" | "/wf" => ChatCommand::Workflows,
76        "/status" => ChatCommand::Status,
77        "/audit" => ChatCommand::Audit {
78            workflow_id: parts.get(1).map(|s| s.to_string()),
79        },
80        "/help" => ChatCommand::Help,
81        "/stop" => ChatCommand::Stop {
82            execution_id: parts.get(1).unwrap_or(&"").to_string(),
83        },
84        "/approve" => ChatCommand::Approve {
85            execution_id: parts.get(1).unwrap_or(&"").to_string(),
86        },
87        "/deny" => ChatCommand::Deny {
88            execution_id: parts.get(1).unwrap_or(&"").to_string(),
89        },
90        "/schedule" | "/schedules" => ChatCommand::Schedule,
91        "/skills" => ChatCommand::Skills,
92        _ => ChatCommand::Unknown {
93            text: text.to_string(),
94        },
95    }
96}
97
98/// Format help text.
99pub fn help_text() -> String {
100    r#"⚡ *MUR Commander* — Available Commands:
101
102`/run <workflow> [--shadow]` — Execute a workflow
103`/workflows` — List available workflows
104`/status` — Show daemon status
105`/audit [workflow_id]` — View audit log
106`/stop <execution_id>` — Stop running workflow
107`/approve <execution_id>` — Approve a breakpoint
108`/deny <execution_id>` — Deny a breakpoint
109`/schedule` — List scheduled jobs
110`/skills` — List installed skills
111`/help` — Show this help"#
112        .to_string()
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_parse_run() {
121        let cmd = parse_command("/run deploy --shadow --var env=staging");
122        match cmd {
123            ChatCommand::Run {
124                workflow,
125                shadow,
126                variables,
127            } => {
128                assert_eq!(workflow, "deploy");
129                assert!(shadow);
130                assert_eq!(variables.get("env").unwrap(), "staging");
131            }
132            _ => panic!("Expected Run command"),
133        }
134    }
135
136    #[test]
137    fn test_parse_workflows() {
138        assert!(matches!(parse_command("/workflows"), ChatCommand::Workflows));
139        assert!(matches!(parse_command("/wf"), ChatCommand::Workflows));
140    }
141
142    #[test]
143    fn test_parse_unknown() {
144        let cmd = parse_command("hello");
145        assert!(matches!(cmd, ChatCommand::Unknown { .. }));
146    }
147
148    #[test]
149    fn test_parse_help() {
150        assert!(matches!(parse_command("/help"), ChatCommand::Help));
151    }
152
153    #[test]
154    fn test_parse_audit_with_filter() {
155        let cmd = parse_command("/audit deploy-wf");
156        match cmd {
157            ChatCommand::Audit { workflow_id } => {
158                assert_eq!(workflow_id.unwrap(), "deploy-wf");
159            }
160            _ => panic!("Expected Audit command"),
161        }
162    }
163
164    #[test]
165    fn test_parse_approve() {
166        let cmd = parse_command("/approve exec-123");
167        match cmd {
168            ChatCommand::Approve { execution_id } => {
169                assert_eq!(execution_id, "exec-123");
170            }
171            _ => panic!("Expected Approve command"),
172        }
173    }
174
175    #[test]
176    fn test_parse_deny() {
177        let cmd = parse_command("/deny exec-456");
178        match cmd {
179            ChatCommand::Deny { execution_id } => {
180                assert_eq!(execution_id, "exec-456");
181            }
182            _ => panic!("Expected Deny command"),
183        }
184    }
185
186    #[test]
187    fn test_parse_schedule() {
188        assert!(matches!(parse_command("/schedule"), ChatCommand::Schedule));
189        assert!(matches!(parse_command("/schedules"), ChatCommand::Schedule));
190    }
191
192    #[test]
193    fn test_parse_skills() {
194        assert!(matches!(parse_command("/skills"), ChatCommand::Skills));
195    }
196}