1use std::path::PathBuf;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Command {
11 Daemon(DaemonArgs),
13 Submit(SubmitArgs),
15 Stats(StatsArgs),
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct DaemonArgs {
22 pub data_dir: Option<PathBuf>,
24 pub bind: Option<String>,
26 pub metrics_bind: Option<String>,
28 pub enable_control: bool,
30 pub json: bool,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SubmitArgs {
37 pub data_dir: Option<PathBuf>,
39 pub task_id: String,
41 pub payload_path: Option<PathBuf>,
43 pub content_type: Option<String>,
45 pub run_policy: String,
47 pub constraints: Option<String>,
49 pub metadata: Option<String>,
51 pub json: bool,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum StatsOutputFormat {
58 Text,
60 Json,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct StatsArgs {
67 pub data_dir: Option<PathBuf>,
69 pub format: StatsOutputFormat,
71}
72
73pub 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
194const 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
208const 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
225const 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}