1use std::ffi::OsString;
2use std::fmt;
3use std::io::{BufRead, BufReader, Write};
4use std::os::unix::net::UnixStream;
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::str::FromStr;
8
9use clap::error::ErrorKind;
10use clap::{ArgGroup, Args, ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand};
11use serde_json::json;
12
13use crate::protocol::{
14 EmptyArgs, ErrorBody, ErrorCode, NoteArgs, PostActivation, PostArgs, Request, RequestEnvelope,
15 RequestId, ResponseEnvelope, RunActivation, RunArgs, RunReferenceArgs, ShowArgs, StopArgs,
16 TicketReferenceArgs,
17};
18
19#[derive(Debug, Parser)]
20#[command(
21 name = "sloop",
22 version,
23 about = "Schedule coding agents",
24 color = ColorChoice::Never
25)]
26pub struct Cli {
27 #[command(subcommand)]
28 pub command: Command,
29 #[arg(long, global = true)]
31 pub json: bool,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum OutputMode {
38 Json,
39 Human,
40}
41
42#[derive(Debug, Subcommand)]
43pub enum Command {
44 Init,
46 Daemon(DaemonCliArgs),
48 Post(PostCliArgs),
50 #[command(hide = true)]
52 Run(RunCliArgs),
53 #[command(hide = true)]
55 Retry { ticket: String },
56 #[command(hide = true)]
58 Hold { ticket: String },
59 #[command(hide = true)]
61 Ready { ticket: String },
62 List,
64 Status,
66 #[command(hide = true)]
68 Pause,
69 #[command(hide = true)]
71 Resume,
72 #[command(hide = true)]
74 Stop {
75 #[arg(long)]
77 force: bool,
78 },
79 #[command(hide = true)]
81 Cancel { run: String },
82 #[command(hide = true)]
84 Logs { run: String },
85 #[command(hide = true)]
87 Wait {
88 run: String,
89 #[arg(long, default_value_t = 3600)]
91 timeout: u64,
92 },
93 #[command(hide = true)]
95 Reindex,
96 Brief,
98 #[command(hide = true)]
100 Show { r#ref: String },
101 #[command(hide = true)]
103 Note {
104 #[arg(required = true, trailing_var_arg = true)]
105 text: Vec<String>,
106 },
107}
108
109#[derive(Debug, Args)]
110pub struct DaemonCliArgs {
111 #[arg(long, hide = true)]
112 foreground: bool,
113}
114
115#[derive(Debug, Args)]
116#[command(group(
117 ArgGroup::new("activation")
118 .args(["auto", "at", "manual", "hold"])
119 .multiple(false)
120))]
121pub struct PostCliArgs {
122 file: PathBuf,
124 #[arg(long, value_name = "PROJECT")]
126 project: Option<String>,
127 #[arg(long, value_name = "FLOW")]
129 flow: Option<String>,
130 #[arg(long)]
132 auto: bool,
133 #[arg(long, value_name = "TIME")]
135 at: Option<LocalTime>,
136 #[arg(long)]
138 manual: bool,
139 #[arg(long)]
141 hold: bool,
142}
143
144#[derive(Debug, Args)]
145#[command(group(
146 ArgGroup::new("activation")
147 .args(["at", "every", "overnight"])
148 .multiple(false)
149))]
150pub struct RunCliArgs {
151 ticket: Option<String>,
153 #[arg(long, value_name = "PROJECT", conflicts_with = "ticket")]
155 project: Option<String>,
156 #[arg(long, value_name = "TIME")]
158 at: Option<LocalTime>,
159 #[arg(long, value_name = "DURATION")]
161 every: Option<DurationMs>,
162 #[arg(long)]
164 overnight: bool,
165 #[arg(long, value_delimiter = ',', value_name = "TICKETS")]
167 only: Option<Vec<String>>,
168}
169
170impl Cli {
171 pub fn into_request(self) -> Result<Request, RequestConstructionError> {
172 self.command.try_into()
173 }
174}
175
176impl TryFrom<Command> for Request {
177 type Error = RequestConstructionError;
178
179 fn try_from(command: Command) -> Result<Self, Self::Error> {
180 let empty = EmptyArgs::default;
181 Ok(match command {
182 Command::Init => Self::Init(empty()),
183 Command::Daemon(_) => Self::Daemon(empty()),
184 Command::Post(args) => Self::Post(args.try_into()?),
185 Command::Run(args) => Self::Run(args.into()),
186 Command::Retry { ticket } => Self::Retry(TicketReferenceArgs { ticket }),
187 Command::Hold { ticket } => Self::Hold(TicketReferenceArgs { ticket }),
188 Command::Ready { ticket } => Self::Ready(TicketReferenceArgs { ticket }),
189 Command::List => Self::List(empty()),
190 Command::Status => Self::Status(empty()),
191 Command::Pause => Self::Pause(empty()),
192 Command::Resume => Self::Resume(empty()),
193 Command::Stop { force } => Self::Stop(StopArgs { force }),
194 Command::Cancel { run } => Self::Cancel(RunReferenceArgs { run }),
195 Command::Logs { run } => Self::Logs(RunReferenceArgs { run }),
196 Command::Wait { run, .. } => Self::Wait(RunReferenceArgs { run }),
197 Command::Reindex => Self::Reindex(empty()),
198 Command::Brief => Self::Brief(empty()),
199 Command::Show { r#ref } => Self::Show(ShowArgs { reference: r#ref }),
200 Command::Note { text } => Self::Note(NoteArgs {
201 text: text.join(" "),
202 }),
203 })
204 }
205}
206
207impl TryFrom<PostCliArgs> for PostArgs {
208 type Error = RequestConstructionError;
209
210 fn try_from(args: PostCliArgs) -> Result<Self, Self::Error> {
211 let file = args
212 .file
213 .into_os_string()
214 .into_string()
215 .map_err(|_| RequestConstructionError("ticket path must be valid UTF-8".into()))?;
216 let activation = if let Some(time) = args.at {
217 PostActivation::At { time: time.0 }
218 } else if args.manual {
219 PostActivation::Manual
220 } else if args.hold {
221 PostActivation::Hold
222 } else {
223 PostActivation::Auto
224 };
225
226 Ok(Self {
227 file,
228 project: args.project,
229 flow: args.flow,
230 activation,
231 })
232 }
233}
234
235impl From<RunCliArgs> for RunArgs {
236 fn from(args: RunCliArgs) -> Self {
237 let activation = if let Some(time) = args.at {
238 RunActivation::At { local_time: time.0 }
239 } else if let Some(interval) = args.every {
240 RunActivation::Every {
241 interval_ms: interval.0,
242 }
243 } else if args.overnight {
244 RunActivation::Overnight
245 } else {
246 RunActivation::Now
247 };
248
249 Self {
250 ticket: args.ticket,
251 project: args.project,
252 activation,
253 only: args.only.unwrap_or_default(),
254 }
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct RequestConstructionError(String);
260
261impl fmt::Display for RequestConstructionError {
262 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
263 formatter.write_str(&self.0)
264 }
265}
266
267impl std::error::Error for RequestConstructionError {}
268
269#[derive(Debug, Clone)]
270struct LocalTime(String);
271
272impl FromStr for LocalTime {
273 type Err = String;
274
275 fn from_str(value: &str) -> Result<Self, Self::Err> {
276 let (hour, minute) = value
277 .split_once(':')
278 .ok_or_else(|| "time must use HH:MM".to_owned())?;
279 if hour.len() != 2 || minute.len() != 2 {
280 return Err("time must use HH:MM".into());
281 }
282 let hour: u8 = hour.parse().map_err(|_| "hour must be numeric")?;
283 let minute: u8 = minute.parse().map_err(|_| "minute must be numeric")?;
284 if hour > 23 || minute > 59 {
285 return Err("time must be between 00:00 and 23:59".into());
286 }
287 Ok(Self(value.to_owned()))
288 }
289}
290
291#[derive(Debug, Clone, Copy)]
292struct DurationMs(u64);
293
294impl FromStr for DurationMs {
295 type Err = String;
296
297 fn from_str(value: &str) -> Result<Self, Self::Err> {
298 let digits = value.chars().take_while(char::is_ascii_digit).count();
299 let (amount, unit) = value.split_at(digits);
300 if amount.is_empty() || unit.is_empty() {
301 return Err("duration must include a positive number and unit (ms, s, m, or h)".into());
302 }
303 let amount: u64 = amount
304 .parse()
305 .map_err(|_| "duration amount is too large".to_owned())?;
306 if amount == 0 {
307 return Err("duration must be greater than zero".into());
308 }
309 let multiplier = match unit {
310 "ms" => 1,
311 "s" => 1_000,
312 "m" => 60_000,
313 "h" => 3_600_000,
314 _ => return Err("duration unit must be ms, s, m, or h".into()),
315 };
316 amount
317 .checked_mul(multiplier)
318 .map(Self)
319 .ok_or_else(|| "duration is too large".into())
320 }
321}
322
323pub fn run<I, T, O, E>(args: I, stdout: &mut O, stderr: &mut E) -> ExitCode
324where
325 I: IntoIterator<Item = T>,
326 T: Into<OsString> + Clone,
327 O: Write,
328 E: Write,
329{
330 let mut args: Vec<OsString> = args.into_iter().map(Into::into).collect();
331 let expanded_help = args.iter().any(|arg| arg == "--all")
332 && args
333 .iter()
334 .any(|arg| arg == "--help" || arg == "-h" || arg == "help");
335 if expanded_help {
336 args.retain(|arg| arg != "--all");
337 }
338
339 let mut command = Cli::command();
340 if expanded_help {
341 let subcommands: Vec<String> = command
342 .get_subcommands()
343 .map(|subcommand| subcommand.get_name().to_owned())
344 .collect();
345 for subcommand in subcommands {
346 command = command.mut_subcommand(subcommand, |subcommand| subcommand.hide(false));
347 }
348 } else {
349 command = command.after_help("Run `sloop --help --all` to see every command.");
350 }
351
352 match command
353 .try_get_matches_from(&args)
354 .and_then(|matches| Cli::from_arg_matches(&matches))
355 {
356 Ok(cli) => {
357 let mode = if cli.json {
358 OutputMode::Json
359 } else {
360 OutputMode::Human
361 };
362 run_command(cli.command, mode, stdout, stderr)
363 }
364 Err(error) => {
367 let mode = if args.iter().any(|arg| arg == "--json") {
368 OutputMode::Json
369 } else {
370 OutputMode::Human
371 };
372 match error.kind() {
373 ErrorKind::DisplayHelp => write_plain_or(
374 mode,
375 stdout,
376 error.to_string().trim_end(),
377 &ResponseEnvelope::success(
378 None,
379 json!({"kind": "help", "text": error.to_string().trim_end()}),
380 ),
381 ),
382 ErrorKind::DisplayVersion => write_plain_or(
383 mode,
384 stdout,
385 concat!("sloop ", env!("CARGO_PKG_VERSION")),
386 &ResponseEnvelope::success(
387 None,
388 json!({"kind": "version", "version": env!("CARGO_PKG_VERSION")}),
389 ),
390 ),
391 _ => write_cli_error(mode, stderr, error.to_string().trim_end().to_owned()),
392 }
393 }
394 }
395}
396
397fn run_command(
398 command: Command,
399 mode: OutputMode,
400 stdout: &mut impl Write,
401 stderr: &mut impl Write,
402) -> ExitCode {
403 match command {
404 Command::Init => run_init(mode, stdout, stderr),
405 Command::Daemon(args) if args.foreground => match crate::daemon::serve_current_project() {
406 Ok(()) | Err(crate::daemon::DaemonError::AlreadyRunning) => ExitCode::SUCCESS,
407 Err(_) => ExitCode::FAILURE,
408 },
409 Command::Daemon(_) => run_daemon_request(
410 Request::Daemon(EmptyArgs::default()),
411 true,
412 mode,
413 stdout,
414 stderr,
415 ),
416 Command::Stop { force } => run_stop_request(force, mode, stdout, stderr),
417 Command::Wait { run, timeout } => run_wait(run, timeout, mode, stdout, stderr),
418 command @ (Command::Post(_)
419 | Command::Run(_)
420 | Command::Retry { .. }
421 | Command::Hold { .. }
422 | Command::Ready { .. }
423 | Command::List
424 | Command::Status
425 | Command::Pause
426 | Command::Resume
427 | Command::Cancel { .. }
428 | Command::Logs { .. }) => match Request::try_from(command) {
429 Ok(request) => run_daemon_request(request, false, mode, stdout, stderr),
430 Err(error) => write_cli_error(mode, stderr, error.to_string()),
431 },
432 command @ Command::Show { .. } => match Request::try_from(command) {
433 Ok(request)
434 if std::env::var_os("SLOOP_SOCKET").is_some()
435 || std::env::var_os("SLOOP_TOKEN").is_some() =>
436 {
437 run_worker_request(request, mode, stdout, stderr)
438 }
439 Ok(request) => run_daemon_request(request, false, mode, stdout, stderr),
440 Err(error) => write_cli_error(mode, stderr, error.to_string()),
441 },
442 command @ (Command::Brief | Command::Note { .. }) => match Request::try_from(command) {
443 Ok(request) => run_worker_request(request, mode, stdout, stderr),
444 Err(error) => write_cli_error(mode, stderr, error.to_string()),
445 },
446 command => match Request::try_from(command) {
447 Ok(request) => write_response(
448 mode,
449 Some(request.verb()),
450 stdout,
451 &ResponseEnvelope::success(
452 None,
453 json!({"verb": request.verb(), "implemented": false}),
454 ),
455 ExitCode::SUCCESS,
456 ),
457 Err(error) => write_cli_error(mode, stderr, error.to_string()),
458 },
459 }
460}
461
462fn write_plain_or(
465 mode: OutputMode,
466 output: &mut impl Write,
467 text: &str,
468 envelope: &ResponseEnvelope,
469) -> ExitCode {
470 match mode {
471 OutputMode::Json => write_response(mode, None, output, envelope, ExitCode::SUCCESS),
472 OutputMode::Human => {
473 if writeln!(output, "{text}").is_err() {
474 return ExitCode::FAILURE;
475 }
476 ExitCode::SUCCESS
477 }
478 }
479}
480
481fn run_init(mode: OutputMode, stdout: &mut impl Write, stderr: &mut impl Write) -> ExitCode {
482 let cwd = match std::env::current_dir() {
483 Ok(cwd) => cwd,
484 Err(error) => {
485 return write_response(
486 mode,
487 Some("init"),
488 stderr,
489 &ResponseEnvelope::failure(
490 None,
491 ErrorBody {
492 code: ErrorCode::Internal,
493 message: format!("cannot read current directory: {error}"),
494 details: json!({}),
495 },
496 ),
497 ExitCode::FAILURE,
498 );
499 }
500 };
501 match crate::init::init(&cwd) {
502 Ok(outcome) => write_response(
503 mode,
504 Some("init"),
505 stdout,
506 &ResponseEnvelope::success(
507 None,
508 json!({
509 "repository_root": outcome.repository_root.to_string_lossy(),
510 "created": outcome.created,
511 "existing": outcome.existing,
512 }),
513 ),
514 ExitCode::SUCCESS,
515 ),
516 Err(error) => {
517 let code = match error {
518 crate::init::InitError::Conflict { .. } => ErrorCode::Conflict,
519 crate::init::InitError::Io { .. } => ErrorCode::Internal,
520 };
521 write_response(
522 mode,
523 Some("init"),
524 stderr,
525 &ResponseEnvelope::failure(
526 None,
527 ErrorBody {
528 code,
529 message: error.to_string(),
530 details: json!({}),
531 },
532 ),
533 ExitCode::FAILURE,
534 )
535 }
536 }
537}
538
539fn run_wait(
543 run: String,
544 timeout_secs: u64,
545 mode: OutputMode,
546 stdout: &mut impl Write,
547 stderr: &mut impl Write,
548) -> ExitCode {
549 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
550 loop {
551 let result = crate::daemon::request(Request::Wait(RunReferenceArgs { run: run.clone() }));
552 match result {
553 Ok(result) if result.response.ok => {
554 let data = result.response.data.clone().unwrap_or_default();
555 if data["terminal"] == serde_json::Value::Bool(true) {
556 return if data["state"] == "merged" {
557 write_response(
558 mode,
559 Some("wait"),
560 stdout,
561 &result.response,
562 ExitCode::SUCCESS,
563 )
564 } else {
565 write_response(
566 mode,
567 Some("wait"),
568 stderr,
569 &result.response,
570 ExitCode::FAILURE,
571 )
572 };
573 }
574 }
575 Ok(result) => {
576 return write_response(
577 mode,
578 Some("wait"),
579 stderr,
580 &result.response,
581 ExitCode::FAILURE,
582 );
583 }
584 Err(error) => {
585 return write_response(
586 mode,
587 Some("wait"),
588 stderr,
589 &ResponseEnvelope::failure(None, error.error_body()),
590 ExitCode::FAILURE,
591 );
592 }
593 }
594 if std::time::Instant::now() >= deadline {
595 return write_cli_error(mode, stderr, format!("timed out waiting for run `{run}`"));
596 }
597 std::thread::sleep(std::time::Duration::from_millis(500));
598 }
599}
600
601fn run_stop_request(
604 force: bool,
605 mode: OutputMode,
606 stdout: &mut impl Write,
607 stderr: &mut impl Write,
608) -> ExitCode {
609 match crate::daemon::request_running(Request::Stop(StopArgs { force })) {
610 Ok(Some(response)) if response.ok => {
611 write_response(mode, Some("stop"), stdout, &response, ExitCode::SUCCESS)
612 }
613 Ok(Some(response)) => {
614 write_response(mode, Some("stop"), stderr, &response, ExitCode::FAILURE)
615 }
616 Ok(None) => write_response(
617 mode,
618 Some("stop"),
619 stdout,
620 &ResponseEnvelope::success(None, json!({"stopping": false, "running": false})),
621 ExitCode::SUCCESS,
622 ),
623 Err(error) => write_response(
624 mode,
625 Some("stop"),
626 stderr,
627 &ResponseEnvelope::failure(None, error.error_body()),
628 ExitCode::FAILURE,
629 ),
630 }
631}
632
633fn run_daemon_request(
634 request: Request,
635 report_started: bool,
636 mode: OutputMode,
637 stdout: &mut impl Write,
638 stderr: &mut impl Write,
639) -> ExitCode {
640 let verb = request.verb();
641 match crate::daemon::request(request) {
642 Ok(mut result) => {
643 if report_started {
644 let data = result
645 .response
646 .data
647 .as_mut()
648 .and_then(serde_json::Value::as_object_mut);
649 if let Some(data) = data {
650 data.insert("started".into(), result.started.into());
651 }
652 }
653 if result.response.ok {
654 write_response(
655 mode,
656 Some(verb),
657 stdout,
658 &result.response,
659 ExitCode::SUCCESS,
660 )
661 } else {
662 write_response(
663 mode,
664 Some(verb),
665 stderr,
666 &result.response,
667 ExitCode::FAILURE,
668 )
669 }
670 }
671 Err(error) => write_response(
672 mode,
673 Some(verb),
674 stderr,
675 &ResponseEnvelope::failure(None, error.error_body()),
676 ExitCode::FAILURE,
677 ),
678 }
679}
680
681fn run_worker_request(
687 request: Request,
688 mode: OutputMode,
689 stdout: &mut impl Write,
690 stderr: &mut impl Write,
691) -> ExitCode {
692 let verb = request.verb();
693 let socket = std::env::var_os("SLOOP_SOCKET");
694 let token = std::env::var("SLOOP_TOKEN").ok();
695 let (Some(socket), Some(token)) = (socket, token) else {
696 return write_response(
697 mode,
698 Some(verb),
699 stderr,
700 &ResponseEnvelope::failure(
701 None,
702 ErrorBody {
703 code: ErrorCode::Unauthorized,
704 message: "worker verbs require SLOOP_SOCKET and SLOOP_TOKEN from a run".into(),
705 details: json!({}),
706 },
707 ),
708 ExitCode::FAILURE,
709 );
710 };
711
712 let envelope = RequestEnvelope::new(
713 RequestId::new(format!("req-{}", std::process::id())),
714 request,
715 Some(token),
716 );
717 match worker_exchange(&socket, &envelope) {
718 Ok(reply) => {
719 let ok = serde_json::from_str::<ResponseEnvelope>(&reply)
720 .map(|response| response.ok)
721 .unwrap_or(false);
722 let written = if ok {
723 writeln!(stdout, "{}", reply.trim_end())
724 } else {
725 writeln!(stderr, "{}", reply.trim_end())
726 };
727 if written.is_err() {
728 return ExitCode::FAILURE;
729 }
730 if ok {
731 ExitCode::SUCCESS
732 } else {
733 ExitCode::FAILURE
734 }
735 }
736 Err(message) => write_response(
737 mode,
738 Some(verb),
739 stderr,
740 &ResponseEnvelope::failure(
741 None,
742 ErrorBody {
743 code: ErrorCode::DaemonUnavailable,
744 message,
745 details: json!({}),
746 },
747 ),
748 ExitCode::FAILURE,
749 ),
750 }
751}
752
753fn worker_exchange(socket: &std::ffi::OsStr, envelope: &RequestEnvelope) -> Result<String, String> {
754 let mut stream = UnixStream::connect(socket)
755 .map_err(|error| format!("cannot connect to worker socket: {error}"))?;
756 let encoded = envelope
757 .encode()
758 .map_err(|error| format!("cannot encode request: {error}"))?;
759 stream
760 .write_all(encoded.as_bytes())
761 .and_then(|()| stream.write_all(b"\n"))
762 .map_err(|error| format!("cannot send request: {error}"))?;
763
764 let mut reply = String::new();
765 BufReader::new(stream)
766 .read_line(&mut reply)
767 .map_err(|error| format!("cannot read response: {error}"))?;
768 if reply.trim_end().is_empty() {
769 return Err("the daemon closed the connection without replying".into());
770 }
771 Ok(reply)
772}
773
774fn write_cli_error(mode: OutputMode, output: &mut impl Write, message: String) -> ExitCode {
775 write_response(
776 mode,
777 None,
778 output,
779 &ResponseEnvelope::failure(
780 None,
781 ErrorBody {
782 code: ErrorCode::InvalidArguments,
783 message,
784 details: json!({}),
785 },
786 ),
787 ExitCode::from(2),
788 )
789}
790
791fn write_response(
792 mode: OutputMode,
793 verb: Option<&str>,
794 output: &mut impl Write,
795 response: &ResponseEnvelope,
796 success: ExitCode,
797) -> ExitCode {
798 let written = match mode {
799 OutputMode::Json => serde_json::to_writer(&mut *output, response)
800 .map_err(|_| ())
801 .and_then(|()| output.write_all(b"\n").map_err(|_| ())),
802 OutputMode::Human => output
803 .write_all(crate::render::render(verb, response).as_bytes())
804 .map_err(|_| ()),
805 };
806 if written.is_err() {
807 return ExitCode::FAILURE;
808 }
809 success
810}
811
812#[cfg(test)]
813mod tests {
814 use clap::Parser;
815 use serde_json::{Value, json};
816
817 use super::Cli;
818 use crate::protocol::{Capability, Request};
819
820 #[test]
821 fn parses_every_documented_verb() {
822 let commands: &[&[&str]] = &[
823 &["sloop", "init"],
824 &["sloop", "daemon"],
825 &["sloop", "post", "ticket.md", "--auto"],
826 &["sloop", "post", "ticket.md", "--at", "03:00"],
827 &["sloop", "post", "ticket.md", "--manual"],
828 &["sloop", "post", "ticket.md", "--hold"],
829 &["sloop", "run"],
830 &["sloop", "run", "T1", "--at", "03:00"],
831 &["sloop", "run", "--every", "30m", "--only", "T1,T7"],
832 &["sloop", "run", "--overnight"],
833 &["sloop", "retry", "T1"],
834 &["sloop", "hold", "T1"],
835 &["sloop", "ready", "T1"],
836 &["sloop", "list"],
837 &["sloop", "status"],
838 &["sloop", "pause"],
839 &["sloop", "resume"],
840 &["sloop", "cancel", "R1"],
841 &["sloop", "logs", "R1"],
842 &["sloop", "reindex"],
843 &["sloop", "brief"],
844 &["sloop", "show", "T1"],
845 &["sloop", "note", "work", "in", "progress"],
846 ];
847
848 for command in commands {
849 Cli::try_parse_from(*command).unwrap_or_else(|error| {
850 panic!("failed to parse {command:?}: {error}");
851 });
852 }
853 }
854
855 #[test]
856 fn every_documented_verb_constructs_a_typed_request() {
857 let commands: &[&[&str]] = &[
858 &["sloop", "init"],
859 &["sloop", "daemon"],
860 &["sloop", "post", "ticket.md", "--auto"],
861 &["sloop", "run"],
862 &["sloop", "retry", "T1"],
863 &["sloop", "hold", "T1"],
864 &["sloop", "ready", "T1"],
865 &["sloop", "list"],
866 &["sloop", "status"],
867 &["sloop", "pause"],
868 &["sloop", "resume"],
869 &["sloop", "cancel", "R1"],
870 &["sloop", "logs", "R1"],
871 &["sloop", "reindex"],
872 &["sloop", "brief"],
873 &["sloop", "show", "T1"],
874 &["sloop", "note", "working"],
875 ];
876
877 for command in commands {
878 Cli::try_parse_from(*command)
879 .unwrap()
880 .into_request()
881 .unwrap_or_else(|error| panic!("failed to construct {command:?}: {error}"));
882 }
883 }
884
885 #[test]
886 fn run_options_become_protocol_arguments() {
887 let request = Cli::try_parse_from(["sloop", "run", "--every", "30m", "--only", "T1,T7"])
888 .unwrap()
889 .into_request()
890 .unwrap();
891
892 assert_eq!(
893 serde_json::to_value(request).unwrap(),
894 json!({
895 "verb": "run",
896 "args": {
897 "activation": {"kind": "every", "interval_ms": 1_800_000},
898 "only": ["T1", "T7"]
899 }
900 })
901 );
902 }
903
904 #[test]
905 fn hold_becomes_a_distinct_post_activation() {
906 let request = Cli::try_parse_from(["sloop", "post", "ticket.md", "--hold"])
907 .unwrap()
908 .into_request()
909 .unwrap();
910
911 assert_eq!(
912 serde_json::to_value(request).unwrap(),
913 json!({
914 "verb": "post",
915 "args": {
916 "file": "ticket.md",
917 "activation": {"kind": "hold"}
918 }
919 })
920 );
921 }
922
923 #[test]
924 fn worker_request_text_and_capability_are_preserved() {
925 let request = Cli::try_parse_from(["sloop", "note", "work", "in", "progress"])
926 .unwrap()
927 .into_request()
928 .unwrap();
929
930 assert_eq!(request.capability(), Capability::Worker);
931 assert_eq!(
932 serde_json::to_value(request).unwrap(),
933 json!({"verb": "note", "args": {"text": "work in progress"}})
934 );
935 }
936
937 #[test]
938 fn operator_requests_are_classified_separately() {
939 let request = Cli::try_parse_from(["sloop", "status"])
940 .unwrap()
941 .into_request()
942 .unwrap();
943
944 assert_eq!(request.capability(), Capability::Operator);
945 assert!(matches!(request, Request::Status(_)));
946 }
947
948 #[test]
949 fn invalid_time_and_duration_fail_during_cli_parsing() {
950 assert!(Cli::try_parse_from(["sloop", "run", "--at", "25:00"]).is_err());
951 assert!(Cli::try_parse_from(["sloop", "run", "--every", "later"]).is_err());
952 assert!(Cli::try_parse_from(["sloop", "run", "--every", "0m"]).is_err());
953 }
954
955 #[test]
956 fn run_accepts_only_one_activation_mode() {
957 let result = Cli::try_parse_from(["sloop", "run", "--at", "03:00", "--every", "30m"]);
958 assert!(result.is_err());
959 }
960
961 #[test]
962 fn post_defaults_to_auto_and_accepts_only_one_explicit_activation_mode() {
963 let request = Cli::try_parse_from(["sloop", "post", "ticket.md"])
964 .unwrap()
965 .into_request()
966 .unwrap();
967 assert_eq!(
968 serde_json::to_value(request).unwrap(),
969 json!({
970 "verb": "post",
971 "args": {
972 "file": "ticket.md",
973 "activation": {"kind": "auto"}
974 }
975 })
976 );
977 assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--auto", "--manual"]).is_err());
978 assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--manual", "--hold"]).is_err());
979 }
980
981 #[test]
982 fn response_envelope_stays_valid_json_with_the_json_flag() {
983 let mut stdout = Vec::new();
984 let mut stderr = Vec::new();
985 let status = super::run(["sloop", "--json", "reindex"], &mut stdout, &mut stderr);
986
987 assert_eq!(status, std::process::ExitCode::SUCCESS);
988 let response: Value = serde_json::from_slice(&stdout).unwrap();
989 assert_eq!(response["ok"], true);
990 assert_eq!(response["data"]["verb"], "reindex");
991 assert!(stderr.is_empty());
992 }
993
994 #[test]
995 fn output_is_human_text_by_default() {
996 let mut stdout = Vec::new();
997 let mut stderr = Vec::new();
998 let status = super::run(["sloop", "reindex"], &mut stdout, &mut stderr);
999
1000 assert_eq!(status, std::process::ExitCode::SUCCESS);
1001 let text = String::from_utf8(stdout).unwrap();
1002 assert!(serde_json::from_str::<Value>(&text).is_err(), "{text}");
1003 assert!(text.contains("reindex"), "{text}");
1004 assert!(stderr.is_empty());
1005 }
1006}