Skip to main content

sloop/
cli.rs

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    /// Emit JSON envelopes instead of human-readable output.
30    #[arg(long, global = true)]
31    pub json: bool,
32}
33
34/// How responses are written. Envelopes are always produced internally;
35/// `Human` translates them at the final write.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum OutputMode {
38    Json,
39    Human,
40}
41
42#[derive(Debug, Subcommand)]
43pub enum Command {
44    /// Scaffold a Sloop project.
45    Init,
46    /// Ensure the daemon is running.
47    Daemon(DaemonCliArgs),
48    /// Register a ticket file.
49    Post(PostCliArgs),
50    /// Enqueue a run.
51    #[command(hide = true)]
52    Run(RunCliArgs),
53    /// Make a failed ticket ready to run again.
54    #[command(hide = true)]
55    Retry { ticket: String },
56    /// Prevent a ready ticket from being dispatched.
57    #[command(hide = true)]
58    Hold { ticket: String },
59    /// Release a held ticket for dispatch.
60    #[command(hide = true)]
61    Ready { ticket: String },
62    /// List tickets and why they are not running.
63    List,
64    /// Show daemon state.
65    Status,
66    /// Stop spawning new agents.
67    #[command(hide = true)]
68    Pause,
69    /// Resume spawning agents.
70    #[command(hide = true)]
71    Resume,
72    /// Stop the daemon.
73    #[command(hide = true)]
74    Stop {
75        /// Cancel active runs instead of refusing to stop.
76        #[arg(long)]
77        force: bool,
78    },
79    /// Cancel a run and preserve its worktree.
80    #[command(hide = true)]
81    Cancel { run: String },
82    /// Show output from a run.
83    #[command(hide = true)]
84    Logs { run: String },
85    /// Block until a run reaches a terminal state.
86    #[command(hide = true)]
87    Wait {
88        run: String,
89        /// Give up after this many seconds.
90        #[arg(long, default_value_t = 3600)]
91        timeout: u64,
92    },
93    /// Rebuild local state from committed files and Git.
94    #[command(hide = true)]
95    Reindex,
96    /// Show the current worker's assignment.
97    Brief,
98    /// Read a reference available to the current worker.
99    #[command(hide = true)]
100    Show { r#ref: String },
101    /// Append an advisory note to the current run.
102    #[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    /// Markdown ticket to register.
123    file: PathBuf,
124    /// Project receiving the ticket; defaults to `default`.
125    #[arg(long, value_name = "PROJECT")]
126    project: Option<String>,
127    /// Flow the ticket binds to; defaults to the repository's default flow.
128    #[arg(long, value_name = "FLOW")]
129    flow: Option<String>,
130    /// Queue one run for the next available opportunity (default).
131    #[arg(long)]
132    auto: bool,
133    /// Queue one run for the next occurrence of a local time.
134    #[arg(long, value_name = "TIME")]
135    at: Option<LocalTime>,
136    /// Register the ticket without creating a run.
137    #[arg(long)]
138    manual: bool,
139    /// Register the ticket as held without creating a run.
140    #[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    /// Run a specific ticket instead of selecting ready work.
152    ticket: Option<String>,
153    /// Select ready work from one project.
154    #[arg(long, value_name = "PROJECT", conflicts_with = "ticket")]
155    project: Option<String>,
156    /// Start at a local time, such as 03:00.
157    #[arg(long, value_name = "TIME")]
158    at: Option<LocalTime>,
159    /// Recur at an interval, such as 30m.
160    #[arg(long, value_name = "DURATION")]
161    every: Option<DurationMs>,
162    /// Run according to the configured overnight window.
163    #[arg(long)]
164    overnight: bool,
165    /// Restrict selection to the comma-separated ticket IDs.
166    #[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        // Parsing failed, so the flag is read from the raw arguments: an
365        // agent asking for `--json --help` still gets an envelope.
366        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
462/// Writes plain text in human mode or the given envelope in JSON mode; used
463/// for help and version, which have no verb-shaped payload.
464fn 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
539/// Polls the daemon until the run is terminal. The exit code is the outcome
540/// (`0` only for `merged`), so scripts and CI can gate on a run directly.
541/// Client-side wall-clock polling; the daemon stays stateless.
542fn 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
601/// `stop` is the one operator verb that must never resurrect a daemon: an
602/// unreachable socket already means the desired state.
603fn 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
681/// Sends a worker verb over the per-run socket injected by the agent adapter.
682/// Worker verbs never resurrect a daemon: without a run's `SLOOP_SOCKET` and
683/// `SLOOP_TOKEN` there is no state worth talking to, so they fail loudly. The
684/// daemon's reply envelope is written verbatim; agents are the only callers
685/// and the envelope is the API.
686fn 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}