Skip to main content

actionqueue_cli/
args.rs

1//! CLI argument model and parsing.
2//!
3//! This module defines deterministic, side-effect-free argument parsing for the
4//! ActionQueue CLI control-plane commands.
5
6use std::path::PathBuf;
7
8/// Root CLI command being invoked.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Command {
11    /// Start daemon runtime bootstrap flow.
12    Daemon(DaemonArgs),
13    /// Submit a task through CLI control-plane semantics.
14    Submit(SubmitArgs),
15    /// Return aggregate runtime stats.
16    Stats(StatsArgs),
17}
18
19/// Arguments for the `daemon` command.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct DaemonArgs {
22    /// Optional data directory override.
23    pub data_dir: Option<PathBuf>,
24    /// Optional HTTP API bind address (`IP:PORT`).
25    pub bind: Option<String>,
26    /// Optional metrics bind address (`IP:PORT`).
27    pub metrics_bind: Option<String>,
28    /// Explicit control-endpoint enablement.
29    pub enable_control: bool,
30    /// Emit JSON success payload on stdout when true.
31    pub json: bool,
32}
33
34/// Arguments for the `submit` command.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SubmitArgs {
37    /// Optional data directory override.
38    pub data_dir: Option<PathBuf>,
39    /// Task identifier string (UUID format).
40    pub task_id: String,
41    /// Optional path to payload bytes.
42    pub payload_path: Option<PathBuf>,
43    /// Optional payload content type.
44    pub content_type: Option<String>,
45    /// Run policy string (`once` or `repeat:N:SECONDS`).
46    pub run_policy: String,
47    /// Optional constraints JSON (inline or `@path`).
48    pub constraints: Option<String>,
49    /// Optional metadata JSON (inline or `@path`).
50    pub metadata: Option<String>,
51    /// Emit JSON success payload on stdout when true.
52    pub json: bool,
53}
54
55/// Stats output format.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum StatsOutputFormat {
58    /// Human-readable text output.
59    Text,
60    /// Machine-readable JSON output.
61    Json,
62}
63
64/// Arguments for the `stats` command.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct StatsArgs {
67    /// Optional data directory override.
68    pub data_dir: Option<PathBuf>,
69    /// Requested output format.
70    pub format: StatsOutputFormat,
71}
72
73/// Parse command-line arguments into a typed command structure.
74pub fn parse_args(args: &[String]) -> Result<Command, String> {
75    if args.is_empty() {
76        return Err(String::from("No command provided. Use 'daemon', 'submit', or 'stats'."));
77    }
78
79    let command = &args[0];
80    match command.as_str() {
81        "daemon" => parse_daemon(&args[1..]),
82        "submit" => parse_submit(&args[1..]),
83        "stats" => parse_stats(&args[1..]),
84        _ => Err(format!("Unknown command: {command}. Use 'daemon', 'submit', or 'stats'.")),
85    }
86}
87
88fn require_value(iter: &mut std::slice::Iter<'_, String>, flag: &str) -> Result<String, String> {
89    iter.next().cloned().ok_or_else(|| format!("{flag} requires a value"))
90}
91
92fn parse_daemon(args: &[String]) -> Result<Command, String> {
93    let mut data_dir: Option<PathBuf> = None;
94    let mut bind: Option<String> = None;
95    let mut metrics_bind: Option<String> = None;
96    let mut enable_control = false;
97    let mut json = false;
98
99    let mut iter = args.iter();
100    while let Some(arg) = iter.next() {
101        match arg.as_str() {
102            "--data-dir" => data_dir = Some(PathBuf::from(require_value(&mut iter, "--data-dir")?)),
103            "--bind" => bind = Some(require_value(&mut iter, "--bind")?),
104            "--metrics-bind" => metrics_bind = Some(require_value(&mut iter, "--metrics-bind")?),
105            "--enable-control" => enable_control = true,
106            "--json" => json = true,
107            "--help" | "-h" => return Err(USAGE_DAEMON.to_string()),
108            unknown if unknown.starts_with('-') => {
109                return Err(format!("Unknown option: {unknown}"))
110            }
111            unexpected => return Err(format!("Unexpected argument: {unexpected}")),
112        }
113    }
114
115    Ok(Command::Daemon(DaemonArgs { data_dir, bind, metrics_bind, enable_control, json }))
116}
117
118fn parse_submit(args: &[String]) -> Result<Command, String> {
119    let mut data_dir: Option<PathBuf> = None;
120    let mut task_id: Option<String> = None;
121    let mut payload_path: Option<PathBuf> = None;
122    let mut content_type: Option<String> = None;
123    let mut run_policy: Option<String> = None;
124    let mut constraints: Option<String> = None;
125    let mut metadata: Option<String> = None;
126    let mut json = false;
127
128    let mut iter = args.iter();
129    while let Some(arg) = iter.next() {
130        match arg.as_str() {
131            "--data-dir" => data_dir = Some(PathBuf::from(require_value(&mut iter, "--data-dir")?)),
132            "--task-id" => task_id = Some(require_value(&mut iter, "--task-id")?),
133            "--payload" => {
134                payload_path = Some(PathBuf::from(require_value(&mut iter, "--payload")?))
135            }
136            "--content-type" => content_type = Some(require_value(&mut iter, "--content-type")?),
137            "--run-policy" => run_policy = Some(require_value(&mut iter, "--run-policy")?),
138            "--constraints" => constraints = Some(require_value(&mut iter, "--constraints")?),
139            "--metadata" => metadata = Some(require_value(&mut iter, "--metadata")?),
140            "--json" => json = true,
141            "--help" | "-h" => return Err(USAGE_SUBMIT.to_string()),
142            unknown if unknown.starts_with('-') => {
143                return Err(format!("Unknown option: {unknown}"))
144            }
145            unexpected => return Err(format!("Unexpected argument: {unexpected}")),
146        }
147    }
148
149    let task_id = task_id.ok_or_else(|| String::from("--task-id is required"))?;
150    let run_policy = run_policy.ok_or_else(|| String::from("--run-policy is required"))?;
151
152    Ok(Command::Submit(SubmitArgs {
153        data_dir,
154        task_id,
155        payload_path,
156        content_type,
157        run_policy,
158        constraints,
159        metadata,
160        json,
161    }))
162}
163
164fn parse_stats(args: &[String]) -> Result<Command, String> {
165    let mut data_dir: Option<PathBuf> = None;
166    let mut format = StatsOutputFormat::Text;
167
168    let mut iter = args.iter();
169    while let Some(arg) = iter.next() {
170        match arg.as_str() {
171            "--data-dir" => data_dir = Some(PathBuf::from(require_value(&mut iter, "--data-dir")?)),
172            "--format" => {
173                let raw = require_value(&mut iter, "--format")?;
174                format = match raw.as_str() {
175                    "text" => StatsOutputFormat::Text,
176                    "json" => StatsOutputFormat::Json,
177                    _ => {
178                        return Err(format!("--format must be 'text' or 'json' (received '{raw}')"))
179                    }
180                };
181            }
182            "--json" => format = StatsOutputFormat::Json,
183            "--help" | "-h" => return Err(USAGE_STATS.to_string()),
184            unknown if unknown.starts_with('-') => {
185                return Err(format!("Unknown option: {unknown}"))
186            }
187            unexpected => return Err(format!("Unexpected argument: {unexpected}")),
188        }
189    }
190
191    Ok(Command::Stats(StatsArgs { data_dir, format }))
192}
193
194/// Usage string for the daemon command.
195const USAGE_DAEMON: &str = r#"actionqueue-cli daemon [OPTIONS]
196
197Start the ActionQueue daemon bootstrap path.
198
199Options:
200    --data-dir <PATH>       Path to the data directory (default: ~/.actionqueue/data)
201    --bind <ADDRESS>        HTTP API bind address (default: 127.0.0.1:8787)
202    --metrics-bind <ADDR>   Metrics endpoint bind address (default: 127.0.0.1:9090)
203    --enable-control        Enable control endpoints (cancel, pause, resume)
204    --json                  Emit machine-readable JSON on stdout
205    --help, -h              Show this help message
206"#;
207
208/// Usage string for the submit command.
209const USAGE_SUBMIT: &str = r#"actionqueue-cli submit [OPTIONS]
210
211Submit a task for execution through CLI control-plane semantics.
212
213Options:
214    --data-dir <PATH>      Path to the data directory (default: ~/.actionqueue/data)
215    --task-id <ID>         Task identifier (required)
216    --payload <PATH>       Path to payload file (optional)
217    --content-type <MIME>  Payload content type (optional)
218    --run-policy <POLICY>  Run policy: "once" or "repeat:N:SECONDS" (required)
219    --constraints <JSON>   Constraints as JSON string or @path (optional)
220    --metadata <JSON>      Metadata as JSON string or @path (optional)
221    --json                 Emit machine-readable JSON on stdout
222    --help, -h             Show this help message
223"#;
224
225/// Usage string for the stats command.
226const USAGE_STATS: &str = r#"actionqueue-cli stats [OPTIONS]
227
228Show deterministic system statistics from authoritative storage projection.
229
230Options:
231    --data-dir <PATH>      Path to the data directory (default: ~/.actionqueue/data)
232    --format <FORMAT>      Output format: "text" or "json" (default: text)
233    --json                 Shortcut for --format json
234    --help, -h             Show this help message
235"#;
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn parse_daemon_with_json_and_control() {
243        let args = ["daemon".to_string(), "--enable-control".to_string(), "--json".to_string()];
244        let parsed = parse_args(&args).expect("daemon args should parse");
245
246        match parsed {
247            Command::Daemon(daemon) => {
248                assert!(daemon.enable_control);
249                assert!(daemon.json);
250                assert_eq!(daemon.data_dir, None);
251            }
252            _ => panic!("expected daemon command"),
253        }
254    }
255
256    #[test]
257    fn parse_submit_requires_task_and_policy() {
258        let args = ["submit".to_string(), "--run-policy".to_string(), "once".to_string()];
259        let error = parse_args(&args).expect_err("submit parse should fail without task-id");
260        assert!(error.contains("--task-id is required"));
261    }
262
263    #[test]
264    fn parse_submit_full_surface() {
265        let args = [
266            "submit".to_string(),
267            "--data-dir".to_string(),
268            "/tmp/actionqueue".to_string(),
269            "--task-id".to_string(),
270            "123e4567-e89b-12d3-a456-426614174000".to_string(),
271            "--payload".to_string(),
272            "/tmp/payload.bin".to_string(),
273            "--content-type".to_string(),
274            "application/octet-stream".to_string(),
275            "--run-policy".to_string(),
276            "repeat:3:60".to_string(),
277            "--constraints".to_string(),
278            "{}".to_string(),
279            "--metadata".to_string(),
280            "{}".to_string(),
281            "--json".to_string(),
282        ];
283
284        let parsed = parse_args(&args).expect("submit args should parse");
285        match parsed {
286            Command::Submit(submit) => {
287                assert_eq!(submit.data_dir, Some(PathBuf::from("/tmp/actionqueue")));
288                assert_eq!(submit.task_id, "123e4567-e89b-12d3-a456-426614174000");
289                assert_eq!(submit.payload_path, Some(PathBuf::from("/tmp/payload.bin")));
290                assert_eq!(submit.content_type.as_deref(), Some("application/octet-stream"));
291                assert_eq!(submit.run_policy, "repeat:3:60");
292                assert_eq!(submit.constraints.as_deref(), Some("{}"));
293                assert_eq!(submit.metadata.as_deref(), Some("{}"));
294                assert!(submit.json);
295            }
296            _ => panic!("expected submit command"),
297        }
298    }
299
300    #[test]
301    fn parse_stats_json_shortcut() {
302        let args = ["stats".to_string(), "--json".to_string()];
303        let parsed = parse_args(&args).expect("stats args should parse");
304        match parsed {
305            Command::Stats(stats) => assert_eq!(stats.format, StatsOutputFormat::Json),
306            _ => panic!("expected stats command"),
307        }
308    }
309
310    #[test]
311    fn parse_stats_rejects_invalid_format() {
312        let args = ["stats".to_string(), "--format".to_string(), "yaml".to_string()];
313        let error = parse_args(&args).expect_err("invalid stats format must fail");
314        assert!(error.contains("--format must be 'text' or 'json'"));
315    }
316}