Skip to main content

zeph_commands/handlers/
loop_cmd.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `/loop` slash command handler.
5//!
6//! Syntax:
7//! - `/loop <prompt> every <N> <unit>` — start a repeating loop
8//! - `/loop stop`                       — cancel the active loop
9//!
10//! Units: `s`, `sec`, `secs`, `second`, `seconds`,
11//!        `m`, `min`, `mins`, `minute`, `minutes`,
12//!        `h`, `hr`, `hrs`, `hour`, `hours`.
13
14use std::future::Future;
15use std::pin::Pin;
16
17use crate::context::CommandContext;
18use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
19
20/// Start or stop a repeating prompt loop.
21pub struct LoopCommand;
22
23impl CommandHandler<CommandContext<'_>> for LoopCommand {
24    fn name(&self) -> &'static str {
25        "/loop"
26    }
27
28    fn description(&self) -> &'static str {
29        "Repeat a prompt on a fixed interval, or stop the active loop"
30    }
31
32    fn args_hint(&self) -> &'static str {
33        "<prompt> every <N> <unit> | stop | status"
34    }
35
36    fn category(&self) -> SlashCategory {
37        SlashCategory::Advanced
38    }
39
40    fn requires_auth(&self) -> bool {
41        true
42    }
43
44    fn handle<'a>(
45        &'a self,
46        ctx: &'a mut CommandContext<'_>,
47        args: &'a str,
48    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
49        use tracing::Instrument as _;
50        let span = tracing::info_span!("commands.loop.handle");
51        Box::pin(
52            async move {
53                let msg = ctx.agent.handle_loop(args).await?;
54                Ok(CommandOutput::message_or_silent(msg))
55            }
56            .instrument(span),
57        )
58    }
59}
60
61/// Parse `/loop <prompt> every <N> <unit>` — returns `(prompt, interval_secs)`.
62///
63/// Returns `Err` with a user-visible message on any syntax or value error.
64///
65/// # Errors
66///
67/// Returns `Err` when the syntax is invalid or `N` is not a positive integer.
68pub fn parse_loop_args(args: &str) -> Result<(String, u64), CommandError> {
69    // Find " every " as the separator between prompt and interval spec.
70    // We search from the right so prompts that contain the word "every" work correctly.
71    let sep = " every ";
72    let sep_pos = args.rfind(sep).ok_or_else(|| {
73        CommandError::new(
74            "Usage: /loop <prompt> every <N> <unit>  (e.g. /loop check logs every 10 minutes)",
75        )
76    })?;
77
78    let prompt = args[..sep_pos].trim().to_owned();
79    if prompt.is_empty() {
80        return Err(CommandError::new("Prompt must not be empty."));
81    }
82
83    let interval_str = args[sep_pos + sep.len()..].trim();
84    let (n_str, unit) = interval_str.split_once(' ').ok_or_else(|| {
85        CommandError::new("Expected format: every <N> <unit>  (e.g. every 5 minutes)")
86    })?;
87
88    let n: u64 = n_str
89        .parse()
90        .map_err(|_| CommandError::new(format!("Expected a positive integer, got '{n_str}'")))?;
91    if n == 0 {
92        return Err(CommandError::new("Interval must be greater than zero."));
93    }
94
95    let multiplier: u64 = match unit.trim() {
96        "s" | "sec" | "secs" | "second" | "seconds" => 1,
97        "m" | "min" | "mins" | "minute" | "minutes" => 60,
98        "h" | "hr" | "hrs" | "hour" | "hours" => 3600,
99        other => {
100            return Err(CommandError::new(format!(
101                "Unknown time unit '{other}'. Use: s/sec/seconds, m/min/minutes, h/hr/hours"
102            )));
103        }
104    };
105
106    Ok((prompt, n * multiplier))
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn parse_seconds() {
115        let (prompt, secs) = parse_loop_args("check logs every 10 seconds").unwrap();
116        assert_eq!(prompt, "check logs");
117        assert_eq!(secs, 10);
118    }
119
120    #[test]
121    fn parse_minutes() {
122        let (prompt, secs) = parse_loop_args("summarize recent activity every 5 minutes").unwrap();
123        assert_eq!(prompt, "summarize recent activity");
124        assert_eq!(secs, 300);
125    }
126
127    #[test]
128    fn parse_hours() {
129        let (prompt, secs) = parse_loop_args("daily report every 1 hour").unwrap();
130        assert_eq!(prompt, "daily report");
131        assert_eq!(secs, 3600);
132    }
133
134    #[test]
135    fn parse_short_units() {
136        let (_, s) = parse_loop_args("ping every 30 s").unwrap();
137        assert_eq!(s, 30);
138        let (_, m) = parse_loop_args("ping every 2 m").unwrap();
139        assert_eq!(m, 120);
140        let (_, h) = parse_loop_args("ping every 1 h").unwrap();
141        assert_eq!(h, 3600);
142    }
143
144    #[test]
145    fn parse_prompt_with_every_word() {
146        // "every" appears in the prompt too — rfind picks the last occurrence.
147        let (prompt, secs) = parse_loop_args("check every file in dir every 15 sec").unwrap();
148        assert_eq!(prompt, "check every file in dir");
149        assert_eq!(secs, 15);
150    }
151
152    #[test]
153    fn parse_missing_every() {
154        assert!(parse_loop_args("check logs 10 seconds").is_err());
155    }
156
157    #[test]
158    fn parse_empty_prompt() {
159        assert!(parse_loop_args("every 5 seconds").is_err());
160    }
161
162    #[test]
163    fn parse_zero_n() {
164        assert!(parse_loop_args("ping every 0 seconds").is_err());
165    }
166
167    #[test]
168    fn parse_bad_unit() {
169        assert!(parse_loop_args("ping every 5 fortnights").is_err());
170    }
171
172    #[test]
173    fn parse_bad_n() {
174        assert!(parse_loop_args("ping every abc seconds").is_err());
175    }
176}