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///
8/// NOTE: This enum is nearly identical to `DiscordCommand` and `TelegramCommand`.
9/// A shared command enum should be considered to reduce duplication.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "command", rename_all = "snake_case")]
12pub enum ChatCommand {
13    /// Run a workflow: `/run <name> [--shadow] [--var key=value]`
14    Run {
15        workflow: String,
16        shadow: bool,
17        variables: HashMap<String, String>,
18    },
19    /// Workflow command: `/workflow run <name>`, `/workflow list`, `/workflow status <id>`
20    Workflow {
21        action: WorkflowAction,
22    },
23    /// List workflows: `/workflows`
24    Workflows,
25    /// Show status: `/status`
26    Status,
27    /// Show audit log: `/audit [workflow_id]`
28    Audit { workflow_id: Option<String> },
29    /// Show help: `/help`
30    Help,
31    /// Stop a running workflow: `/stop <execution_id>`
32    Stop { execution_id: String },
33    /// Approve a breakpoint: `/approve <execution_id>`
34    Approve { execution_id: String },
35    /// Deny a breakpoint: `/deny <execution_id>`
36    Deny { execution_id: String },
37    /// List scheduled jobs: `/schedule`
38    Schedule,
39    /// Show skills: `/skills`
40    Skills,
41    /// Unknown command
42    Unknown { text: String },
43}
44
45/// Sub-actions for the `/workflow` command.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(tag = "action", rename_all = "snake_case")]
48pub enum WorkflowAction {
49    /// `/workflow run <name> [--shadow]`
50    Run {
51        workflow: String,
52        shadow: bool,
53        variables: HashMap<String, String>,
54    },
55    /// `/workflow list`
56    List,
57    /// `/workflow status <execution_id>`
58    Status { execution_id: String },
59    /// `/workflow cancel <execution_id>`
60    Cancel { execution_id: String },
61}
62
63/// Parse a chat message into a command.
64pub fn parse_command(text: &str) -> ChatCommand {
65    let text = text.trim();
66
67    // Must start with /
68    if !text.starts_with('/') {
69        return ChatCommand::Unknown {
70            text: text.to_string(),
71        };
72    }
73
74    let parts: Vec<&str> = text.split_whitespace().collect();
75    let cmd = parts.first().map(|s| s.to_lowercase()).unwrap_or_default();
76
77    match cmd.as_str() {
78        "/run" => parse_run_args(&parts),
79        "/workflow" | "/wf" => parse_workflow_command(&parts),
80        "/workflows" => ChatCommand::Workflows,
81        "/status" => ChatCommand::Status,
82        "/audit" => ChatCommand::Audit {
83            workflow_id: parts.get(1).map(|s| s.to_string()),
84        },
85        "/help" => ChatCommand::Help,
86        "/stop" => {
87            let execution_id = parts.get(1).unwrap_or(&"").to_string();
88            if execution_id.is_empty() {
89                return ChatCommand::Unknown {
90                    text: "Usage: /stop <execution_id>".to_string(),
91                };
92            }
93            ChatCommand::Stop { execution_id }
94        }
95        "/approve" => {
96            let execution_id = parts.get(1).unwrap_or(&"").to_string();
97            if execution_id.is_empty() {
98                return ChatCommand::Unknown {
99                    text: "Usage: /approve <execution_id>".to_string(),
100                };
101            }
102            ChatCommand::Approve { execution_id }
103        }
104        "/deny" => {
105            let execution_id = parts.get(1).unwrap_or(&"").to_string();
106            if execution_id.is_empty() {
107                return ChatCommand::Unknown {
108                    text: "Usage: /deny <execution_id>".to_string(),
109                };
110            }
111            ChatCommand::Deny { execution_id }
112        }
113        "/schedule" | "/schedules" => ChatCommand::Schedule,
114        "/skills" => ChatCommand::Skills,
115        _ => ChatCommand::Unknown {
116            text: text.to_string(),
117        },
118    }
119}
120
121/// Parse `/run <workflow> [--shadow] [--var key=value]`
122fn parse_run_args(parts: &[&str]) -> ChatCommand {
123    let workflow = parts.get(1).unwrap_or(&"").to_string();
124    if workflow.is_empty() {
125        return ChatCommand::Help;
126    }
127    let shadow = parts.contains(&"--shadow");
128    let variables = extract_variables(parts);
129    ChatCommand::Run {
130        workflow,
131        shadow,
132        variables,
133    }
134}
135
136/// Parse `/workflow <subcommand> [args]`
137fn parse_workflow_command(parts: &[&str]) -> ChatCommand {
138    let sub = parts
139        .get(1)
140        .map(|s| s.to_lowercase())
141        .unwrap_or_default();
142
143    match sub.as_str() {
144        "run" => {
145            let workflow = parts.get(2).unwrap_or(&"").to_string();
146            if workflow.is_empty() {
147                return ChatCommand::Help;
148            }
149            let shadow = parts.contains(&"--shadow");
150            let variables = extract_variables(parts);
151            ChatCommand::Workflow {
152                action: WorkflowAction::Run {
153                    workflow,
154                    shadow,
155                    variables,
156                },
157            }
158        }
159        "list" | "ls" => ChatCommand::Workflow {
160            action: WorkflowAction::List,
161        },
162        "status" => {
163            let execution_id = parts.get(2).unwrap_or(&"").to_string();
164            ChatCommand::Workflow {
165                action: WorkflowAction::Status { execution_id },
166            }
167        }
168        "cancel" | "stop" => {
169            let execution_id = parts.get(2).unwrap_or(&"").to_string();
170            ChatCommand::Workflow {
171                action: WorkflowAction::Cancel { execution_id },
172            }
173        }
174        // `/workflow <name>` — shorthand for `/workflow run <name>`
175        name if !name.is_empty() => {
176            let shadow = parts.contains(&"--shadow");
177            let variables = extract_variables(parts);
178            ChatCommand::Workflow {
179                action: WorkflowAction::Run {
180                    workflow: name.to_string(),
181                    shadow,
182                    variables,
183                },
184            }
185        }
186        _ => ChatCommand::Workflows,
187    }
188}
189
190/// Extract `--var key=value` pairs from command parts.
191fn extract_variables(parts: &[&str]) -> HashMap<String, String> {
192    let mut variables = HashMap::new();
193    for (i, part) in parts.iter().enumerate() {
194        if *part == "--var" {
195            if let Some(kv) = parts.get(i + 1) {
196                if let Some((k, v)) = kv.split_once('=') {
197                    variables.insert(k.to_string(), v.to_string());
198                }
199            }
200        }
201    }
202    variables
203}
204
205/// Format help text.
206pub fn help_text() -> String {
207    r#"⚡ *MUR Commander* — Available Commands:
208
209`/run <workflow> [--shadow]` — Execute a workflow
210`/workflow run <name>` — Execute a workflow (alias)
211`/workflow list` — List available workflows
212`/workflow status <id>` — Check execution status
213`/workflow cancel <id>` — Cancel a running workflow
214`/workflows` — List available workflows
215`/status` — Show daemon status
216`/audit [workflow_id]` — View audit log
217`/stop <execution_id>` — Stop running workflow
218`/approve <execution_id>` — Approve a breakpoint
219`/deny <execution_id>` — Deny a breakpoint
220`/schedule` — List scheduled jobs
221`/skills` — List installed skills
222`/help` — Show this help"#
223        .to_string()
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_parse_run() {
232        let cmd = parse_command("/run deploy --shadow --var env=staging");
233        match cmd {
234            ChatCommand::Run {
235                workflow,
236                shadow,
237                variables,
238            } => {
239                assert_eq!(workflow, "deploy");
240                assert!(shadow);
241                assert_eq!(variables.get("env").unwrap(), "staging");
242            }
243            _ => panic!("Expected Run command"),
244        }
245    }
246
247    #[test]
248    fn test_parse_workflows() {
249        assert!(matches!(
250            parse_command("/workflows"),
251            ChatCommand::Workflows
252        ));
253    }
254
255    #[test]
256    fn test_parse_unknown() {
257        let cmd = parse_command("hello");
258        assert!(matches!(cmd, ChatCommand::Unknown { .. }));
259    }
260
261    #[test]
262    fn test_parse_help() {
263        assert!(matches!(parse_command("/help"), ChatCommand::Help));
264    }
265
266    #[test]
267    fn test_parse_audit_with_filter() {
268        let cmd = parse_command("/audit deploy-wf");
269        match cmd {
270            ChatCommand::Audit { workflow_id } => {
271                assert_eq!(workflow_id.unwrap(), "deploy-wf");
272            }
273            _ => panic!("Expected Audit command"),
274        }
275    }
276
277    #[test]
278    fn test_parse_approve() {
279        let cmd = parse_command("/approve exec-123");
280        match cmd {
281            ChatCommand::Approve { execution_id } => {
282                assert_eq!(execution_id, "exec-123");
283            }
284            _ => panic!("Expected Approve command"),
285        }
286    }
287
288    #[test]
289    fn test_parse_deny() {
290        let cmd = parse_command("/deny exec-456");
291        match cmd {
292            ChatCommand::Deny { execution_id } => {
293                assert_eq!(execution_id, "exec-456");
294            }
295            _ => panic!("Expected Deny command"),
296        }
297    }
298
299    #[test]
300    fn test_parse_schedule() {
301        assert!(matches!(
302            parse_command("/schedule"),
303            ChatCommand::Schedule
304        ));
305        assert!(matches!(
306            parse_command("/schedules"),
307            ChatCommand::Schedule
308        ));
309    }
310
311    #[test]
312    fn test_parse_skills() {
313        assert!(matches!(parse_command("/skills"), ChatCommand::Skills));
314    }
315
316    #[test]
317    fn test_parse_workflow_run() {
318        let cmd = parse_command("/workflow run deploy --shadow");
319        match cmd {
320            ChatCommand::Workflow {
321                action: WorkflowAction::Run { workflow, shadow, .. },
322            } => {
323                assert_eq!(workflow, "deploy");
324                assert!(shadow);
325            }
326            _ => panic!("Expected Workflow Run command"),
327        }
328    }
329
330    #[test]
331    fn test_parse_workflow_list() {
332        let cmd = parse_command("/workflow list");
333        match cmd {
334            ChatCommand::Workflow {
335                action: WorkflowAction::List,
336            } => {}
337            _ => panic!("Expected Workflow List command"),
338        }
339    }
340
341    #[test]
342    fn test_parse_workflow_status() {
343        let cmd = parse_command("/workflow status exec-789");
344        match cmd {
345            ChatCommand::Workflow {
346                action: WorkflowAction::Status { execution_id },
347            } => {
348                assert_eq!(execution_id, "exec-789");
349            }
350            _ => panic!("Expected Workflow Status command"),
351        }
352    }
353
354    #[test]
355    fn test_parse_workflow_cancel() {
356        let cmd = parse_command("/workflow cancel exec-789");
357        match cmd {
358            ChatCommand::Workflow {
359                action: WorkflowAction::Cancel { execution_id },
360            } => {
361                assert_eq!(execution_id, "exec-789");
362            }
363            _ => panic!("Expected Workflow Cancel command"),
364        }
365    }
366
367    #[test]
368    fn test_parse_workflow_shorthand() {
369        // `/workflow deploy` should be treated as `/workflow run deploy`
370        let cmd = parse_command("/workflow deploy");
371        match cmd {
372            ChatCommand::Workflow {
373                action: WorkflowAction::Run { workflow, shadow, .. },
374            } => {
375                assert_eq!(workflow, "deploy");
376                assert!(!shadow);
377            }
378            _ => panic!("Expected Workflow Run shorthand, got {:?}", cmd),
379        }
380    }
381
382    #[test]
383    fn test_parse_wf_alias() {
384        // `/wf list` should work same as `/workflow list`
385        let cmd = parse_command("/wf list");
386        match cmd {
387            ChatCommand::Workflow {
388                action: WorkflowAction::List,
389            } => {}
390            _ => panic!("Expected Workflow List from /wf alias"),
391        }
392    }
393}