use std::ffi::OsString;
use std::fmt;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::ExitCode;
use std::str::FromStr;
use clap::error::{ContextKind, ContextValue, ErrorKind};
use clap::{
ArgGroup, Args, ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
};
use serde_json::json;
mod init;
mod render;
mod style;
mod templates;
use self::style::Style;
use self::templates::TemplateKind;
use crate::protocol::{
ConfidenceValue, EmptyArgs, ErrorBody, ErrorCode, EventsArgs, ListArgs, LogsArgs, NoteArgs,
PostArgs, PostTrigger, Request, RequestEnvelope, RequestId, ResponseEnvelope, RunArgs,
RunReferenceArgs, RunTrigger, ShowArgs, StopArgs, TicketReferenceArgs, VerdictArgs,
VerdictValue,
};
const TICKET_STATES_HELP: &str = "Ticket states:
ready Nothing is stopping it - but it runs only once a trigger is
queued for it and the gates are open. `sloop run` queues one.
held Prevented from running by an operator; release with `sloop ready`.
blocked Waiting for every ticket in `blocked_by` to be merged.
claimed Owned by an active run, including recovery.
merged Terminal: completed work was integrated into the default branch.
failed Terminal: the run did not succeed; `sloop retry` returns it to
ready, and `sloop run` is what starts it again.
needs_review Terminal: the run could not be merged; inspect manually.";
const SHOW_LONG_ABOUT: &str =
"Show the daemon, tickets, runs, and projects - sloop's one read verb.
REF_OR_PATTERN is resolved in this order; first match wins:
1. nothing -> the dashboard: daemon state plus recent tickets
2. an exact ref -> the full detail view for that thing
3. anything else -> a filter: matching tickets, newest first
A ref is an exact ticket id (TICK-12), run alias (TICK-12-r1), run-id prefix
(749048f4), ticket name, or project id. An exact match always wins over pattern
interpretation.
A pattern matches ticket ids and names case-insensitively. Plain text is a
substring; regex metacharacters make it an unanchored regex, like grep. Quote
regexes in your shell: 'log.*'. A pattern always renders the list view, even
when it matches one ticket.
A run's detail is its stage table: one row per stage execution, suffixed `#N`
past the first attempt, with advisory failures marked `advisory` and any
panel's seats listed under their stage. `sloop logs <run> --stage <name>#<N>`
reads the output behind one of those rows.
--follow streams events for the shown scope. Ticket and run scopes exit when
they settle; dashboard, pattern, and project scopes run until interrupted.
--follow --quiet suppresses the stream and only returns the outcome.
EXIT CODES:
0 shown successfully, or followed scope merged
1 followed scope reached any other terminal outcome, or daemon error
2 usage error, invalid pattern, or deprecated wait-alias timeout";
const SHOW_EXAMPLES: &str = "Examples:
sloop show dashboard: daemon plus recent tickets
sloop show -5 dashboard with the 5 newest tickets
sloop show TICK-12 everything about one ticket, including runs
sloop show verdict tickets mentioning \"verdict\"
sloop show 'flow|merge' -5 5 newest tickets matching a regex
sloop show TICK-12 --follow watch a ticket until it settles
sloop show TICK-12 -f -q block silently; exit code is the outcome";
#[derive(Debug, Parser)]
#[command(
name = "sloop",
version,
about = "Schedule coding agents",
color = ColorChoice::Never
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
#[arg(long, global = true)]
pub json: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputMode {
Json,
Human(Style),
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init,
Template {
#[arg(value_name = "KIND")]
kind: TemplateKind,
},
Daemon(DaemonCliArgs),
Post(PostCliArgs),
#[command(hide = true)]
Run(RunCliArgs),
#[command(hide = true)]
Retry { ticket: String },
#[command(hide = true)]
Hold { ticket: String },
#[command(hide = true)]
Ready { ticket: String },
#[command(hide = true)]
List(ListCliArgs),
#[command(hide = true)]
Status,
#[command(hide = true)]
Pause,
#[command(hide = true)]
Resume,
#[command(hide = true)]
Stop {
#[arg(long)]
force: bool,
},
#[command(hide = true)]
Cancel {
run: String,
},
#[command(
about = "Show daemon state, details, or filtered tickets; optionally follow live",
long_about = SHOW_LONG_ABOUT,
after_help = SHOW_EXAMPLES
)]
Show(ShowCliArgs),
#[command(
long_about = LOGS_LONG_ABOUT,
after_help = "See `sloop show --help` for derived state and live activity."
)]
Logs {
run: String,
#[arg(long, value_name = "STAGE[#ATTEMPT]")]
stage: Option<String>,
#[arg(long, value_name = "N")]
tail: Option<u32>,
#[arg(long)]
follow: bool,
},
#[command(hide = true)]
Watch {
r#ref: Option<String>,
#[arg(long, default_value_t = 20)]
tail: u32,
},
#[command(hide = true)]
Wait {
run: String,
#[arg(long, default_value_t = 3600)]
timeout: u64,
},
#[command(hide = true)]
Reindex,
#[command(hide = true)]
Brief,
#[command(hide = true)]
Note {
#[arg(required = true, trailing_var_arg = true, value_name = "TEXT")]
text: Vec<String>,
},
#[command(hide = true, long_about = VERDICT_LONG_ABOUT)]
Verdict {
verdict: VerdictCliValue,
#[arg(long, value_name = "TEXT")]
reason: Option<String>,
#[arg(long)]
confidence: Option<ConfidenceCliValue>,
},
}
const VERDICT_LONG_ABOUT: &str = "\
Report the current stage's verdict.
Two callers may use it, each exactly once per stage execution:
- the worker on a stage whose flow declares `result_check: reported`. It
is the only thing that can pass such a stage; one that exits without
reporting fails with `no verdict reported`.
- a panel reviewer, when the stage's check is `result_check: { panel: ... }`.
Its credential names the seat the report lands on, so no argument chooses
one, and `--reason` is required.
The first report for an execution is final; a second is refused. A `return_to`
edge that re-enters the stage starts a fresh execution, which is owed its own
report.
`--confidence` takes `low`, `medium`, or `high` and defaults to `medium`. It is
recorded as evidence and shown by `sloop show`, and is never weighted into a
panel's quorum: a `fail` at low confidence counts exactly as much as one at
high.";
const LOGS_LONG_ABOUT: &str = "\
Show output from a run — stdout and stderr, in capture order.
A bare read shows the last 64 entries, the way `tail` does: on a live run that
is the part worth reading. `--tail N` widens or narrows that window, and
`--follow` streams the run from its first entry instead. Whenever a window
hides output, the last line of the page says so — a page that says nothing
showed everything.
`--stage` narrows to one stage, named exactly as its flow names it. Add
`#<attempt>` to narrow further to a single execution: `--stage build#2` is the
second pass a `return_to` edge sent the walk through, and `--stage build` is
every pass together. The suffix is the same label `sloop show` prints in its
stage table, so a row read there is a selector that can be pasted back.
A stage the run's flow does not define is an error listing the ones it does,
rather than an empty page.";
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum VerdictCliValue {
Pass,
Fail,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ConfidenceCliValue {
Low,
Medium,
High,
}
#[derive(Debug, Args)]
pub struct DaemonCliArgs {
#[command(subcommand)]
action: Option<DaemonAction>,
#[arg(long, hide = true)]
foreground: bool,
}
#[derive(Debug, Subcommand)]
enum DaemonAction {
Restart,
}
#[derive(Debug, Args)]
#[command(
after_help = "Ticket files need `name`, `blocked_by`, and a non-empty body. Run \
`sloop template ticket` for a commented example of every frontmatter field, or \
`sloop template flow` for the flow grammar that `--flow` selects.",
group(
ArgGroup::new("trigger")
.args(["auto", "at", "manual", "hold"])
.multiple(false)
)
)]
pub struct PostCliArgs {
file: PathBuf,
#[arg(long, value_name = "PROJECT")]
project: Option<String>,
#[arg(long, value_name = "FLOW")]
flow: Option<String>,
#[arg(long)]
auto: bool,
#[arg(long, value_name = "TIME")]
at: Option<LocalTime>,
#[arg(long)]
manual: bool,
#[arg(long)]
hold: bool,
}
#[derive(Debug, Args)]
pub struct ListCliArgs {
#[arg(long, short = 'n', value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
limit: Option<u32>,
}
#[derive(Debug, Default, Args)]
pub struct ShowCliArgs {
#[arg(value_name = "REF_OR_PATTERN")]
reference: Option<String>,
#[arg(long, short = 'f')]
follow: bool,
#[arg(long, short = 'q', requires = "follow")]
quiet: bool,
#[arg(long, short = 'n', value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
limit: Option<u32>,
}
#[derive(Debug, Args)]
#[command(group(
ArgGroup::new("trigger")
.args(["at", "every", "overnight"])
.multiple(false)
))]
pub struct RunCliArgs {
ticket: Option<String>,
#[arg(long, value_name = "PROJECT", conflicts_with = "ticket")]
project: Option<String>,
#[arg(long, value_name = "TIME")]
at: Option<LocalTime>,
#[arg(long, value_name = "DURATION")]
every: Option<DurationMs>,
#[arg(long)]
overnight: bool,
#[arg(long, value_delimiter = ',', value_name = "TICKETS")]
only: Option<Vec<String>>,
}
impl Cli {
pub fn into_request(self) -> Result<Request, RequestConstructionError> {
self.command
.ok_or_else(|| {
RequestConstructionError(
"bare `sloop` prints help and has no daemon request".into(),
)
})?
.try_into()
}
}
fn logs_args(run: String, stage: Option<String>, tail: Option<u32>, follow: bool) -> LogsArgs {
LogsArgs {
run,
stage,
tail: tail.or((!follow).then_some(crate::run_log::PAGE_LIMIT as u32)),
after: None,
}
}
impl TryFrom<Command> for Request {
type Error = RequestConstructionError;
fn try_from(command: Command) -> Result<Self, Self::Error> {
let empty = EmptyArgs::default;
Ok(match command {
Command::Init => Self::Init(empty()),
Command::Template { .. } => {
return Err(RequestConstructionError(
"template is printed locally and has no daemon request".into(),
));
}
Command::Daemon(args) => match args.action {
Some(DaemonAction::Restart) => Self::Restart(empty()),
None => Self::Daemon(empty()),
},
Command::Post(args) => Self::Post(args.try_into()?),
Command::Run(args) => Self::Run(args.into()),
Command::Retry { ticket } => Self::Retry(TicketReferenceArgs { ticket }),
Command::Hold { ticket } => Self::Hold(TicketReferenceArgs { ticket }),
Command::Ready { ticket } => Self::Ready(TicketReferenceArgs { ticket }),
Command::List(args) => Self::List(ListArgs { limit: args.limit }),
Command::Status => Self::Status(empty()),
Command::Pause => Self::Pause(empty()),
Command::Resume => Self::Resume(empty()),
Command::Stop { force } => Self::Stop(StopArgs { force }),
Command::Cancel { run } => Self::Cancel(RunReferenceArgs { run }),
Command::Logs {
run,
stage,
tail,
follow,
} => Self::Logs(logs_args(run, stage, tail, follow)),
Command::Watch { r#ref, tail } => Self::Events(EventsArgs {
after: None,
tail: Some(tail),
limit: None,
scope: r#ref,
}),
Command::Wait { run, .. } => Self::Wait(RunReferenceArgs { run }),
Command::Reindex => Self::Reindex(empty()),
Command::Brief => Self::Brief(empty()),
Command::Show(args) => Self::Show(ShowArgs {
reference: args.reference,
limit: args.limit,
}),
Command::Note { text } => Self::Note(NoteArgs {
text: text.join(" "),
}),
Command::Verdict {
verdict,
reason,
confidence,
} => Self::Verdict(VerdictArgs {
verdict: match verdict {
VerdictCliValue::Pass => VerdictValue::Pass,
VerdictCliValue::Fail => VerdictValue::Fail,
},
reason,
confidence: confidence.map(|confidence| match confidence {
ConfidenceCliValue::Low => ConfidenceValue::Low,
ConfidenceCliValue::Medium => ConfidenceValue::Medium,
ConfidenceCliValue::High => ConfidenceValue::High,
}),
}),
})
}
}
impl TryFrom<PostCliArgs> for PostArgs {
type Error = RequestConstructionError;
fn try_from(args: PostCliArgs) -> Result<Self, Self::Error> {
let file = args
.file
.into_os_string()
.into_string()
.map_err(|_| RequestConstructionError("ticket path must be valid UTF-8".into()))?;
let trigger = if let Some(time) = args.at {
PostTrigger::At { time: time.0 }
} else if args.manual {
PostTrigger::Manual
} else if args.hold {
PostTrigger::Hold
} else {
PostTrigger::Auto
};
Ok(Self {
file,
project: args.project,
flow: args.flow,
trigger,
})
}
}
impl From<RunCliArgs> for RunArgs {
fn from(args: RunCliArgs) -> Self {
let trigger = if let Some(time) = args.at {
RunTrigger::At { local_time: time.0 }
} else if let Some(interval) = args.every {
RunTrigger::Every {
interval_ms: interval.0,
}
} else if args.overnight {
RunTrigger::Overnight
} else {
RunTrigger::Now
};
Self {
ticket: args.ticket,
project: args.project,
trigger,
only: args.only.unwrap_or_default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestConstructionError(String);
impl fmt::Display for RequestConstructionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for RequestConstructionError {}
#[derive(Debug, Clone)]
struct LocalTime(String);
impl FromStr for LocalTime {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let (hour, minute) = value
.split_once(':')
.ok_or_else(|| "time must use HH:MM".to_owned())?;
if hour.len() != 2 || minute.len() != 2 {
return Err("time must use HH:MM".into());
}
let hour: u8 = hour.parse().map_err(|_| "hour must be numeric")?;
let minute: u8 = minute.parse().map_err(|_| "minute must be numeric")?;
if hour > 23 || minute > 59 {
return Err("time must be between 00:00 and 23:59".into());
}
Ok(Self(value.to_owned()))
}
}
#[derive(Debug, Clone, Copy)]
struct DurationMs(u64);
impl FromStr for DurationMs {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let digits = value.chars().take_while(char::is_ascii_digit).count();
let (amount, unit) = value.split_at(digits);
if amount.is_empty() || unit.is_empty() {
return Err("duration must include a positive number and unit (ms, s, m, or h)".into());
}
let amount: u64 = amount
.parse()
.map_err(|_| "duration amount is too large".to_owned())?;
if amount == 0 {
return Err("duration must be greater than zero".into());
}
let multiplier = match unit {
"ms" => 1,
"s" => 1_000,
"m" => 60_000,
"h" => 3_600_000,
_ => return Err("duration unit must be ms, s, m, or h".into()),
};
amount
.checked_mul(multiplier)
.map(Self)
.ok_or_else(|| "duration is too large".into())
}
}
pub fn run<I, T, O, E>(args: I, stdout: &mut O, stderr: &mut E) -> ExitCode
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
O: Write,
E: Write,
{
let mut args: Vec<OsString> = args.into_iter().map(Into::into).collect();
let expanded_help = args.iter().any(|arg| arg == "--all")
&& args
.iter()
.any(|arg| arg == "--help" || arg == "-h" || arg == "help");
if expanded_help {
args.retain(|arg| arg != "--all");
}
expand_limit_shorthand(&mut args);
let mut command = Cli::command();
if expanded_help {
let subcommands: Vec<String> = command
.get_subcommands()
.map(|subcommand| subcommand.get_name().to_owned())
.collect();
for subcommand in subcommands {
command = command.mut_subcommand(subcommand, |subcommand| subcommand.hide(false));
}
command = command.after_help(TICKET_STATES_HELP);
} else {
command = command.after_help(
"Read state with `sloop show` and raw output with `sloop logs`.\nRun `sloop show --help` for the complete read model or `sloop --help --all` for every command.",
);
}
match command
.clone()
.try_get_matches_from(&args)
.and_then(|matches| Cli::from_arg_matches(&matches))
{
Ok(cli) => {
let mode = if cli.json {
OutputMode::Json
} else {
OutputMode::Human(Style::detect())
};
match cli.command {
Some(subcommand) => run_command(subcommand, mode, stdout, stderr),
None => {
let help = command.render_help().to_string();
let help = help.trim_end();
write_plain_or(
mode,
stdout,
help,
&ResponseEnvelope::success(None, json!({"kind": "help", "text": help})),
)
}
}
}
Err(error) => {
let mode = if args.iter().any(|arg| arg == "--json") {
OutputMode::Json
} else {
OutputMode::Human(Style::detect())
};
match error.kind() {
ErrorKind::DisplayHelp => write_plain_or(
mode,
stdout,
error.to_string().trim_end(),
&ResponseEnvelope::success(
None,
json!({"kind": "help", "text": error.to_string().trim_end()}),
),
),
ErrorKind::DisplayVersion => write_plain_or(
mode,
stdout,
concat!("sloop ", env!("CARGO_PKG_VERSION")),
&ResponseEnvelope::success(
None,
json!({"kind": "version", "version": env!("CARGO_PKG_VERSION")}),
),
),
_ => write_cli_error(
mode,
stderr,
augment_unknown_subcommand(&error, error.to_string().trim_end().to_owned()),
),
}
}
}
}
fn expand_limit_shorthand(args: &mut [OsString]) {
let verb = args
.iter()
.skip(1)
.position(|arg| arg != "--json")
.map(|offset| offset + 1);
let Some(verb) = verb.filter(|index| args[*index] == "show" || args[*index] == "list") else {
return;
};
for argument in &mut args[verb + 1..] {
let Some(digits) = argument.to_str().and_then(|arg| arg.strip_prefix('-')) else {
continue;
};
if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
*argument = OsString::from(format!("--limit={digits}"));
}
}
}
fn subcommand_synonym(attempted: &str) -> Option<&'static str> {
match attempted {
"tickets" | "ls" | "queue" => Some("list"),
"ps" => Some("status"),
"start" => Some("run"),
"kill" | "abort" => Some("cancel"),
_ => None,
}
}
fn augment_unknown_subcommand(error: &clap::Error, rendered: String) -> String {
if error.kind() != ErrorKind::InvalidSubcommand
|| error.get(ContextKind::SuggestedSubcommand).is_some()
{
return rendered;
}
let Some(attempted) = invalid_subcommand(error) else {
return rendered;
};
let Some(verb) = subcommand_synonym(&attempted) else {
return rendered;
};
let tip = format!(
"\n\n tip: `{attempted}` is not a verb; did you mean `{verb}`? run `sloop {verb}`"
);
match rendered.find("\n\nUsage:") {
Some(index) => {
let mut augmented = rendered;
augmented.insert_str(index, &tip);
augmented
}
None => rendered + &tip,
}
}
fn invalid_subcommand(error: &clap::Error) -> Option<String> {
match error.get(ContextKind::InvalidSubcommand) {
Some(ContextValue::String(value)) => Some(value.clone()),
_ => None,
}
}
fn run_command(
command: Command,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
match command {
Command::Init => run_init(mode, stdout, stderr),
Command::Template { kind } => run_template(kind, mode, stdout),
Command::Daemon(args) if args.foreground && args.action.is_none() => {
match crate::daemon::serve_current_repository() {
Ok(()) | Err(crate::daemon::DaemonError::AlreadyRunning) => ExitCode::SUCCESS,
Err(_) => ExitCode::FAILURE,
}
}
Command::Daemon(args) => {
let (request, report_started) = match args.action {
Some(DaemonAction::Restart) => (Request::Restart(EmptyArgs::default()), false),
None => (Request::Daemon(EmptyArgs::default()), true),
};
run_daemon_request(request, report_started, mode, stdout, stderr)
}
Command::Stop { force } => run_stop_request(force, mode, stdout, stderr),
Command::Wait { run, timeout } => {
if !write_deprecation(stderr, "wait", "show --follow --quiet") {
return ExitCode::FAILURE;
}
run_wait(run, timeout, mode, stdout, stderr)
}
Command::Watch { r#ref, tail } => {
if !write_deprecation(stderr, "watch", "show --follow") {
return ExitCode::FAILURE;
}
run_watch(r#ref, tail, mode, stdout, stderr)
}
Command::Logs {
run,
stage,
tail,
follow,
} => run_logs(
logs_args(run, stage, tail, follow),
follow,
mode,
stdout,
stderr,
),
Command::List(args) => {
if !write_deprecation(stderr, "list", "show") {
return ExitCode::FAILURE;
}
run_daemon_request(
Request::List(ListArgs { limit: args.limit }),
false,
mode,
stdout,
stderr,
)
}
Command::Status => {
if !write_deprecation(stderr, "status", "show") {
return ExitCode::FAILURE;
}
run_show(ShowCliArgs::default(), mode, stdout, stderr)
}
Command::Show(args) => run_show(args, mode, stdout, stderr),
command @ (Command::Post(_)
| Command::Run(_)
| Command::Retry { .. }
| Command::Hold { .. }
| Command::Ready { .. }
| Command::Pause
| Command::Resume
| Command::Cancel { .. }
| Command::Reindex) => match Request::try_from(command) {
Ok(request) => run_daemon_request(request, false, mode, stdout, stderr),
Err(error) => write_cli_error(mode, stderr, error.to_string()),
},
command @ (Command::Brief | Command::Note { .. } | Command::Verdict { .. }) => {
match Request::try_from(command) {
Ok(request) => run_worker_request(request, mode, stdout, stderr),
Err(error) => write_cli_error(mode, stderr, error.to_string()),
}
}
}
}
fn write_plain_or(
mode: OutputMode,
output: &mut impl Write,
text: &str,
envelope: &ResponseEnvelope,
) -> ExitCode {
match mode {
OutputMode::Json => write_response(mode, None, output, envelope, ExitCode::SUCCESS),
OutputMode::Human(_) => {
if writeln!(output, "{text}").is_err() {
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
}
}
fn run_template(kind: TemplateKind, mode: OutputMode, stdout: &mut impl Write) -> ExitCode {
let text = kind.text();
match mode {
OutputMode::Json => write_response(
mode,
Some("template"),
stdout,
&ResponseEnvelope::success(None, json!({"kind": kind.as_str(), "template": text})),
ExitCode::SUCCESS,
),
OutputMode::Human(_) => {
if stdout.write_all(text.as_bytes()).is_err() {
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
}
}
fn run_init(mode: OutputMode, stdout: &mut impl Write, stderr: &mut impl Write) -> ExitCode {
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(error) => {
return write_response(
mode,
Some("init"),
stderr,
&ResponseEnvelope::failure(
None,
ErrorBody {
code: ErrorCode::Internal,
message: format!("cannot read current directory: {error}"),
details: json!({}),
},
),
ExitCode::FAILURE,
);
}
};
match self::init::init(&cwd) {
Ok(outcome) => write_response(
mode,
Some("init"),
stdout,
&ResponseEnvelope::success(
None,
json!({
"repository_root": outcome.repository_root.to_string_lossy(),
"created": outcome.created,
"existing": outcome.existing,
}),
),
ExitCode::SUCCESS,
),
Err(error) => {
let code = match error {
self::init::InitError::Conflict { .. } => ErrorCode::Conflict,
self::init::InitError::Io { .. } => ErrorCode::Internal,
};
write_response(
mode,
Some("init"),
stderr,
&ResponseEnvelope::failure(
None,
ErrorBody {
code,
message: error.to_string(),
details: json!({}),
},
),
ExitCode::FAILURE,
)
}
}
}
fn write_deprecation(stderr: &mut impl Write, old: &str, new: &str) -> bool {
writeln!(
stderr,
"note: 'sloop {old}' is now 'sloop {new}'; this alias will be removed in a future release"
)
.is_ok()
}
fn run_show(
args: ShowCliArgs,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
let request_args = ShowArgs {
reference: args.reference,
limit: args.limit,
};
let worker_environment =
std::env::var_os("SLOOP_SOCKET").is_some() || std::env::var_os("SLOOP_TOKEN").is_some();
if worker_environment {
if args.follow {
return write_cli_error(
mode,
stderr,
"workers cannot follow operator activity; worker `show` only reads its own ticket"
.into(),
);
}
return run_worker_request(Request::Show(request_args), mode, stdout, stderr);
}
if args.follow {
return follow_show(request_args, 20, None, args.quiet, mode, stdout, stderr);
}
match crate::daemon::request(Request::Show(request_args)) {
Ok(result) if result.response.ok => write_response(
mode,
Some("show"),
stdout,
&result.response,
ExitCode::SUCCESS,
),
Ok(result) => {
let exit = if result.response.error.as_ref().map(|error| error.code)
== Some(ErrorCode::InvalidArguments)
{
ExitCode::from(2)
} else {
ExitCode::FAILURE
};
write_response(mode, Some("show"), stderr, &result.response, exit)
}
Err(error) => write_response(
mode,
Some("show"),
stderr,
&ResponseEnvelope::failure(None, error.error_body()),
ExitCode::FAILURE,
),
}
}
fn run_wait(
run: String,
timeout_secs: u64,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
match crate::daemon::request(Request::Wait(RunReferenceArgs { run: run.clone() })) {
Ok(result) if result.response.ok => {}
Ok(result) => {
return write_response(
mode,
Some("wait"),
stderr,
&result.response,
ExitCode::FAILURE,
);
}
Err(error) => {
return write_response(
mode,
Some("wait"),
stderr,
&ResponseEnvelope::failure(None, error.error_body()),
ExitCode::FAILURE,
);
}
}
follow_show(
ShowArgs {
reference: Some(run),
limit: None,
},
0,
Some(timeout_secs),
true,
mode,
stdout,
stderr,
)
}
fn run_watch(
scope: Option<String>,
tail: u32,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
follow_show(
ShowArgs {
reference: scope,
limit: None,
},
tail,
None,
false,
mode,
stdout,
stderr,
)
}
fn follow_show(
show: ShowArgs,
tail: u32,
timeout_secs: Option<u64>,
quiet: bool,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
let initial = match crate::daemon::request(Request::Show(show.clone())) {
Ok(result) if result.response.ok => result.response.data.unwrap_or_default(),
Ok(result) => {
let exit = if result.response.error.as_ref().map(|error| error.code)
== Some(ErrorCode::InvalidArguments)
{
ExitCode::from(2)
} else {
ExitCode::FAILURE
};
return write_response(mode, Some("show"), stderr, &result.response, exit);
}
Err(error) => {
return write_response(
mode,
Some("show"),
stderr,
&ResponseEnvelope::failure(None, error.error_body()),
ExitCode::FAILURE,
);
}
};
let settling_kind = matches!(initial["kind"].as_str(), Some("ticket" | "run"));
let deadline = timeout_secs
.map(|seconds| std::time::Instant::now() + std::time::Duration::from_secs(seconds));
let mut cursor: Option<i64> = None;
let mut settled_outcome: Option<bool> = None;
loop {
let args = match cursor {
Some(after) => EventsArgs {
after: Some(after),
tail: None,
limit: None,
scope: show.reference.clone(),
},
None => EventsArgs {
after: None,
tail: Some(tail),
limit: None,
scope: show.reference.clone(),
},
};
match crate::daemon::request(Request::Events(args)) {
Ok(result) if result.response.ok => {
let data = result.response.data.unwrap_or_default();
let events = data["events"].as_array().cloned().unwrap_or_default();
for event in &events {
if quiet {
continue;
}
let written = match mode {
OutputMode::Json => serde_json::to_writer(&mut *stdout, event)
.map_err(|_| ())
.and_then(|()| stdout.write_all(b"\n").map_err(|_| ())),
OutputMode::Human(_) => {
writeln!(stdout, "{}", format_event(event)).map_err(|_| ())
}
};
if written.is_err() {
return ExitCode::FAILURE;
}
}
if !quiet && stdout.flush().is_err() {
return ExitCode::FAILURE;
}
let next = data["next_cursor"].as_i64();
let advanced = next.is_some() && next != cursor;
if let Some(next) = next {
cursor = Some(next);
}
let caught_up = next == data["latest"].as_i64();
if advanced && !caught_up {
continue;
}
if caught_up && let Some(merged) = settled_outcome {
return if merged {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
};
}
if settling_kind && caught_up {
match crate::daemon::request(Request::Show(show.clone())) {
Ok(result) if result.response.ok => {
let shown = result.response.data.unwrap_or_default();
let value = &shown["value"];
let terminal = match shown["kind"].as_str() {
Some("ticket") => matches!(
value["state"].as_str(),
Some("merged" | "failed" | "needs_review")
),
Some("run") => value["terminal"] == json!(true),
_ => false,
};
if terminal {
settled_outcome = Some(value["state"] == "merged");
continue;
}
}
Ok(result) => {
return write_response(
mode,
Some("show"),
stderr,
&result.response,
ExitCode::FAILURE,
);
}
Err(error) => {
return write_response(
mode,
Some("show"),
stderr,
&ResponseEnvelope::failure(None, error.error_body()),
ExitCode::FAILURE,
);
}
}
}
}
Ok(result) => {
return write_response(
mode,
Some("show"),
stderr,
&result.response,
ExitCode::FAILURE,
);
}
Err(error) => {
return write_response(
mode,
Some("show"),
stderr,
&ResponseEnvelope::failure(None, error.error_body()),
ExitCode::FAILURE,
);
}
}
if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
let reference = show.reference.as_deref().unwrap_or("dashboard");
return write_cli_error(mode, stderr, format!("timed out following `{reference}`"));
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
fn run_logs(
args: LogsArgs,
follow: bool,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
if !follow {
return run_daemon_request(Request::Logs(args), false, mode, stdout, stderr);
}
follow_logs(args, mode, stdout, stderr)
}
fn follow_logs(
mut args: LogsArgs,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
let mut written = false;
loop {
match crate::daemon::request(Request::Logs(args.clone())) {
Ok(mut result) => {
if !result.response.ok {
return write_response(
mode,
Some("logs"),
stderr,
&result.response,
ExitCode::FAILURE,
);
}
if written && let Some(data) = result.response.data.as_mut() {
data["note"] = serde_json::Value::Null;
}
let data = result.response.data.clone().unwrap_or_default();
let complete = data["complete"] == json!(true);
let settled = complete && data["terminal"] == json!(true);
let entries = data["entries"].as_array().map_or(0, Vec::len);
if entries > 0 || (settled && !written) {
if write_envelope(mode, Some("logs"), stdout, &result.response).is_err()
|| stdout.flush().is_err()
{
return ExitCode::FAILURE;
}
written = true;
}
if let Some(next) = data["next_cursor"].as_u64() {
args.after = Some(next);
}
args.tail = None;
if settled {
return ExitCode::SUCCESS;
}
if !complete {
continue;
}
}
Err(error) => {
return write_response(
mode,
Some("logs"),
stderr,
&ResponseEnvelope::failure(None, error.error_body()),
ExitCode::FAILURE,
);
}
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
fn format_event(event: &serde_json::Value) -> String {
let time = event["occurred_at_ms"]
.as_i64()
.and_then(crate::clock::format_timestamp)
.unwrap_or_default();
let run = event["run"].as_str().unwrap_or("?");
let ticket = event["ticket"].as_str().unwrap_or("?");
let data = &event["data"];
match event["kind"].as_str().unwrap_or("?") {
"run_claimed" => {
let attempt = data["attempt"].as_i64().unwrap_or(1);
format!("{time} {ticket} claimed by {run} (attempt {attempt})")
}
"run_started" => format!("{time} {run} started on {ticket}"),
"run_finished" => {
let outcome = data["outcome"].as_str().unwrap_or("?");
let state = data["ticket_state"].as_str().unwrap_or("?");
format!("{time} {run} finished: {outcome} ({ticket} -> {state})")
}
"run_aborted" => format!("{time} {run} aborted before launch ({ticket} back to ready)"),
"run_worktree_cleaned" => format!("{time} {run} worktree and branch removed"),
"daemon_restart_requested" => {
let active = data["active_runs"].as_u64().unwrap_or(0);
let noun = if active == 1 { "run" } else { "runs" };
format!("{time} daemon draining for restart ({active} {noun} active)")
}
kind => format!("{time} {kind} run={run} ticket={ticket}"),
}
}
fn run_stop_request(
force: bool,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
match crate::daemon::request_running(Request::Stop(StopArgs { force })) {
Ok(Some(response)) if response.ok => {
write_response(mode, Some("stop"), stdout, &response, ExitCode::SUCCESS)
}
Ok(Some(response)) => {
write_response(mode, Some("stop"), stderr, &response, ExitCode::FAILURE)
}
Ok(None) => write_response(
mode,
Some("stop"),
stdout,
&ResponseEnvelope::success(None, json!({"stopping": false, "running": false})),
ExitCode::SUCCESS,
),
Err(error) => write_response(
mode,
Some("stop"),
stderr,
&ResponseEnvelope::failure(None, error.error_body()),
ExitCode::FAILURE,
),
}
}
fn run_daemon_request(
request: Request,
report_started: bool,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
let verb = request.verb();
match crate::daemon::request(request) {
Ok(mut result) => {
if report_started {
let data = result
.response
.data
.as_mut()
.and_then(serde_json::Value::as_object_mut);
if let Some(data) = data {
data.insert("started".into(), result.started.into());
}
}
if result.response.ok {
write_response(
mode,
Some(verb),
stdout,
&result.response,
ExitCode::SUCCESS,
)
} else {
write_response(
mode,
Some(verb),
stderr,
&result.response,
ExitCode::FAILURE,
)
}
}
Err(error) => write_response(
mode,
Some(verb),
stderr,
&ResponseEnvelope::failure(None, error.error_body()),
ExitCode::FAILURE,
),
}
}
fn run_worker_request(
request: Request,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
let verb = request.verb();
let socket = std::env::var_os("SLOOP_SOCKET");
let token = std::env::var("SLOOP_TOKEN").ok();
let (Some(socket), Some(token)) = (socket, token) else {
return write_response(
mode,
Some(verb),
stderr,
&ResponseEnvelope::failure(
None,
ErrorBody {
code: ErrorCode::Unauthorized,
message: "worker verbs require SLOOP_SOCKET and SLOOP_TOKEN from a run".into(),
details: json!({}),
},
),
ExitCode::FAILURE,
);
};
let envelope = RequestEnvelope::new(
RequestId::new(format!("req-{}", std::process::id())),
request,
Some(token),
);
match worker_exchange(&socket, &envelope) {
Ok(reply) => {
let ok = serde_json::from_str::<ResponseEnvelope>(&reply)
.map(|response| response.ok)
.unwrap_or(false);
let written = if ok {
writeln!(stdout, "{}", reply.trim_end())
} else {
writeln!(stderr, "{}", reply.trim_end())
};
if written.is_err() {
return ExitCode::FAILURE;
}
if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
Err(message) => write_response(
mode,
Some(verb),
stderr,
&ResponseEnvelope::failure(
None,
ErrorBody {
code: ErrorCode::DaemonUnavailable,
message,
details: json!({}),
},
),
ExitCode::FAILURE,
),
}
}
fn worker_exchange(socket: &std::ffi::OsStr, envelope: &RequestEnvelope) -> Result<String, String> {
let mut stream = UnixStream::connect(socket)
.map_err(|error| format!("cannot connect to worker socket: {error}"))?;
let encoded = envelope
.encode()
.map_err(|error| format!("cannot encode request: {error}"))?;
stream
.write_all(encoded.as_bytes())
.and_then(|()| stream.write_all(b"\n"))
.map_err(|error| format!("cannot send request: {error}"))?;
let mut reply = String::new();
BufReader::new(stream)
.read_line(&mut reply)
.map_err(|error| format!("cannot read response: {error}"))?;
if reply.trim_end().is_empty() {
return Err("the daemon closed the connection without replying".into());
}
Ok(reply)
}
fn write_cli_error(mode: OutputMode, output: &mut impl Write, message: String) -> ExitCode {
write_response(
mode,
None,
output,
&ResponseEnvelope::failure(
None,
ErrorBody {
code: ErrorCode::InvalidArguments,
message,
details: json!({}),
},
),
ExitCode::from(2),
)
}
fn write_response(
mode: OutputMode,
verb: Option<&str>,
output: &mut impl Write,
response: &ResponseEnvelope,
success: ExitCode,
) -> ExitCode {
if write_envelope(mode, verb, output, response).is_err() {
return ExitCode::FAILURE;
}
success
}
fn write_envelope(
mode: OutputMode,
verb: Option<&str>,
output: &mut impl Write,
response: &ResponseEnvelope,
) -> Result<(), ()> {
match mode {
OutputMode::Json => serde_json::to_writer(&mut *output, response)
.map_err(|_| ())
.and_then(|()| output.write_all(b"\n").map_err(|_| ())),
OutputMode::Human(style) => output
.write_all(self::render::render(verb, response, style).as_bytes())
.map_err(|_| ()),
}
}
#[cfg(test)]
mod tests {
use clap::{Parser, ValueEnum};
use serde_json::{Value, json};
use super::{Cli, expand_limit_shorthand, subcommand_synonym};
use crate::protocol::{Capability, Request};
fn error_envelope(args: &[&str]) -> Value {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut argv = vec!["sloop", "--json"];
argv.extend_from_slice(args);
super::run(argv, &mut stdout, &mut stderr);
serde_json::from_slice(&stderr).expect("stderr carries a JSON envelope")
}
#[test]
fn the_limit_shorthand_expands_only_for_read_arguments() {
let expanded = |argv: &[&str]| {
let mut args: Vec<std::ffi::OsString> =
argv.iter().map(std::ffi::OsString::from).collect();
expand_limit_shorthand(&mut args);
args.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>()
};
assert_eq!(
expanded(&["sloop", "list", "-2"]),
["sloop", "list", "--limit=2"]
);
assert_eq!(
expanded(&["sloop", "--json", "list", "-2"]),
["sloop", "--json", "list", "--limit=2"]
);
assert_eq!(expanded(&["sloop", "list", "-0"])[2], "--limit=0");
assert_eq!(
expanded(&["sloop", "show", "log", "-2"]),
["sloop", "show", "log", "--limit=2"]
);
for untouched in [
["sloop", "list", "-abc"].as_slice(),
["sloop", "list", "-n"].as_slice(),
["sloop", "post", "list", "-2"].as_slice(),
["sloop", "watch", "-2"].as_slice(),
] {
assert_eq!(expanded(untouched), untouched, "{untouched:?}");
}
}
#[test]
fn unknown_subcommand_suggests_the_tickets_synonym() {
let envelope = error_envelope(&["tickets"]);
assert_eq!(envelope["error"]["code"], "invalid_arguments");
let message = envelope["error"]["message"]
.as_str()
.expect("error message");
assert!(
message.contains("did you mean `list`") && message.contains("sloop list"),
"synonym remedy missing from: {message}"
);
}
#[test]
fn unknown_subcommand_suggests_a_near_miss_spelling() {
let envelope = error_envelope(&["statuss"]);
let message = envelope["error"]["message"]
.as_str()
.expect("error message");
assert!(
message.contains("status"),
"near-miss suggestion missing from: {message}"
);
}
#[test]
fn synonym_table_only_points_at_real_verbs() {
use clap::CommandFactory;
let verbs: Vec<String> = Cli::command()
.get_subcommands()
.map(|subcommand| subcommand.get_name().to_owned())
.collect();
for attempted in ["tickets", "ls", "queue", "ps", "start", "kill", "abort"] {
let verb = subcommand_synonym(attempted).expect("synonym maps to a verb");
assert!(
verbs.iter().any(|known| known == verb),
"synonym `{attempted}` points at unknown verb `{verb}`"
);
}
assert!(subcommand_synonym("definitely-not-a-verb").is_none());
}
#[test]
fn parses_every_documented_verb() {
let commands: &[&[&str]] = &[
&["sloop", "init"],
&["sloop", "template", "ticket"],
&["sloop", "template", "flow"],
&["sloop", "template", "project"],
&["sloop", "template", "config"],
&["sloop", "daemon"],
&["sloop", "post", "ticket.md", "--auto"],
&["sloop", "post", "ticket.md", "--at", "03:00"],
&["sloop", "post", "ticket.md", "--manual"],
&["sloop", "post", "ticket.md", "--hold"],
&["sloop", "run"],
&["sloop", "run", "T1", "--at", "03:00"],
&["sloop", "run", "--every", "30m", "--only", "T1,T7"],
&["sloop", "run", "--overnight"],
&["sloop", "retry", "T1"],
&["sloop", "hold", "T1"],
&["sloop", "ready", "T1"],
&["sloop", "list"],
&["sloop", "status"],
&["sloop", "pause"],
&["sloop", "resume"],
&["sloop", "cancel", "R1"],
&["sloop", "logs", "R1"],
&["sloop", "logs", "R1", "--stage", "test"],
&["sloop", "logs", "R1", "--tail", "50"],
&[
"sloop", "logs", "R1", "--stage", "test", "--tail", "5", "--follow",
],
&["sloop", "watch"],
&["sloop", "watch", "--tail", "50"],
&["sloop", "watch", "T1"],
&["sloop", "watch", "T1-r2", "--tail", "50"],
&["sloop", "reindex"],
&["sloop", "brief"],
&["sloop", "show", "T1"],
&["sloop", "note", "work", "in", "progress"],
&["sloop", "verdict", "fail", "--reason", "changes requested"],
];
for command in commands {
Cli::try_parse_from(*command).unwrap_or_else(|error| {
panic!("failed to parse {command:?}: {error}");
});
}
}
#[test]
fn every_documented_verb_constructs_a_typed_request() {
let commands: &[&[&str]] = &[
&["sloop", "init"],
&["sloop", "daemon"],
&["sloop", "post", "ticket.md", "--auto"],
&["sloop", "run"],
&["sloop", "retry", "T1"],
&["sloop", "hold", "T1"],
&["sloop", "ready", "T1"],
&["sloop", "list"],
&["sloop", "status"],
&["sloop", "pause"],
&["sloop", "resume"],
&["sloop", "cancel", "R1"],
&["sloop", "logs", "R1"],
&["sloop", "logs", "R1", "--stage", "test", "--tail", "5"],
&["sloop", "watch"],
&["sloop", "watch", "T1"],
&["sloop", "reindex"],
&["sloop", "brief"],
&["sloop", "show", "T1"],
&["sloop", "note", "working"],
&["sloop", "verdict", "pass"],
];
for command in commands {
Cli::try_parse_from(*command)
.unwrap()
.into_request()
.unwrap_or_else(|error| panic!("failed to construct {command:?}: {error}"));
}
}
#[test]
fn run_options_become_protocol_arguments() {
let request = Cli::try_parse_from(["sloop", "run", "--every", "30m", "--only", "T1,T7"])
.unwrap()
.into_request()
.unwrap();
assert_eq!(
serde_json::to_value(request).unwrap(),
json!({
"verb": "run",
"args": {
"trigger": {"kind": "every", "interval_ms": 1_800_000},
"only": ["T1", "T7"]
}
})
);
}
#[test]
fn hold_becomes_a_distinct_post_trigger() {
let request = Cli::try_parse_from(["sloop", "post", "ticket.md", "--hold"])
.unwrap()
.into_request()
.unwrap();
assert_eq!(
serde_json::to_value(request).unwrap(),
json!({
"verb": "post",
"args": {
"file": "ticket.md",
"trigger": {"kind": "hold"}
}
})
);
}
#[test]
fn worker_request_text_and_capability_are_preserved() {
let request = Cli::try_parse_from(["sloop", "note", "work", "in", "progress"])
.unwrap()
.into_request()
.unwrap();
assert_eq!(request.capability(), Capability::Worker);
assert_eq!(
serde_json::to_value(request).unwrap(),
json!({"verb": "note", "args": {"text": "work in progress"}})
);
}
#[test]
fn verdict_becomes_a_worker_request() {
let request =
Cli::try_parse_from(["sloop", "verdict", "fail", "--reason", "changes requested"])
.unwrap()
.into_request()
.unwrap();
assert_eq!(request.capability(), Capability::Worker);
assert_eq!(
serde_json::to_value(request).unwrap(),
json!({
"verb": "verdict",
"args": {"verdict": "fail", "reason": "changes requested"}
})
);
}
#[test]
fn operator_requests_are_classified_separately() {
let request = Cli::try_parse_from(["sloop", "status"])
.unwrap()
.into_request()
.unwrap();
assert_eq!(request.capability(), Capability::Operator);
assert!(matches!(request, Request::Status(_)));
}
fn stdout_of(args: &[&str]) -> String {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut argv = vec!["sloop"];
argv.extend_from_slice(args);
let code = super::run(argv, &mut stdout, &mut stderr);
assert_eq!(
format!("{code:?}"),
format!("{:?}", std::process::ExitCode::SUCCESS),
"stderr: {}",
String::from_utf8_lossy(&stderr)
);
String::from_utf8(stdout).expect("stdout is UTF-8")
}
#[test]
fn every_template_kind_prints_verbatim_without_a_daemon() {
for kind in ["ticket", "flow", "project", "config"] {
let printed = stdout_of(&["template", kind]);
let expected = super::TemplateKind::from_str(kind, false)
.expect("kind is accepted")
.text();
assert_eq!(printed, expected, "`sloop template {kind}` was rewritten");
}
}
#[test]
fn template_json_mode_wraps_the_text_in_an_envelope() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
super::run(
["sloop", "--json", "template", "flow"],
&mut stdout,
&mut stderr,
);
let envelope: Value = serde_json::from_slice(&stdout).expect("stdout carries an envelope");
assert_eq!(envelope["ok"], true);
assert_eq!(envelope["data"]["kind"], "flow");
assert_eq!(
envelope["data"]["template"].as_str(),
Some(super::TemplateKind::Flow.text())
);
}
#[test]
fn an_unknown_template_kind_lists_the_valid_kinds() {
let envelope = error_envelope(&["template", "readme"]);
assert_eq!(envelope["error"]["code"], "invalid_arguments");
let message = envelope["error"]["message"]
.as_str()
.expect("error message");
for kind in ["ticket", "flow", "project", "config"] {
assert!(message.contains(kind), "`{kind}` missing from: {message}");
}
}
#[test]
fn template_never_becomes_a_daemon_request() {
let error = Cli::try_parse_from(["sloop", "template", "ticket"])
.unwrap()
.into_request()
.expect_err("template has no daemon request");
assert!(error.to_string().contains("printed locally"), "{error}");
}
#[test]
fn post_help_points_at_the_ticket_template() {
let help = stdout_of(&["post", "--help"]);
assert!(help.contains("sloop template ticket"), "{help}");
assert!(help.contains("sloop template flow"), "{help}");
}
#[test]
fn invalid_time_and_duration_fail_during_cli_parsing() {
assert!(Cli::try_parse_from(["sloop", "run", "--at", "25:00"]).is_err());
assert!(Cli::try_parse_from(["sloop", "run", "--every", "later"]).is_err());
assert!(Cli::try_parse_from(["sloop", "run", "--every", "0m"]).is_err());
}
#[test]
fn run_accepts_only_one_trigger_mode() {
let result = Cli::try_parse_from(["sloop", "run", "--at", "03:00", "--every", "30m"]);
assert!(result.is_err());
}
#[test]
fn post_defaults_to_auto_and_accepts_only_one_explicit_trigger_mode() {
let request = Cli::try_parse_from(["sloop", "post", "ticket.md"])
.unwrap()
.into_request()
.unwrap();
assert_eq!(
serde_json::to_value(request).unwrap(),
json!({
"verb": "post",
"args": {
"file": "ticket.md",
"trigger": {"kind": "auto"}
}
})
);
assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--auto", "--manual"]).is_err());
assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--manual", "--hold"]).is_err());
}
}