pub mod approve;
pub mod dto;
pub mod list;
pub mod reject;
use clap::{Subcommand, ValueEnum};
use crate::error::CliError;
use crate::output::OutputSpec;
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
#[clap(rename_all = "kebab-case")]
pub enum StatusFilterArg {
Pending,
Approved,
Rejected,
}
#[derive(Subcommand, Debug)]
pub enum SpinoffAction {
List {
run_id: String,
#[arg(long, value_enum)]
status: Option<StatusFilterArg>,
},
Approve {
run_id: String,
proposal_id: String,
#[arg(long)]
issue_slug: Option<String>,
#[arg(long)]
idempotency_key: Option<String>,
#[arg(long)]
dry_run: bool,
},
Reject {
run_id: String,
proposal_id: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
idempotency_key: Option<String>,
#[arg(long)]
dry_run: bool,
},
}
pub fn dispatch(
action: SpinoffAction,
spec: &OutputSpec,
warnings: &[String],
) -> Result<(), CliError> {
match action {
SpinoffAction::List { run_id, status } => list::run(list::Args {
run_id,
status,
spec,
warnings,
}),
SpinoffAction::Approve {
run_id,
proposal_id,
issue_slug,
idempotency_key,
dry_run,
} => approve::run(approve::Args {
run_id,
proposal_id,
issue_slug,
idempotency_key,
dry_run,
spec,
warnings,
}),
SpinoffAction::Reject {
run_id,
proposal_id,
reason,
idempotency_key,
dry_run,
} => reject::run(reject::Args {
run_id,
proposal_id,
reason,
idempotency_key,
dry_run,
spec,
warnings,
}),
}
}
pub fn status_kebab(s: octl_core::SpinoffStatus) -> &'static str {
use octl_core::SpinoffStatus::{Approved, Proposed, Rejected};
match s {
Proposed => "pending",
Approved => "approved",
Rejected => "rejected",
}
}
pub fn status_arg_kebab(s: StatusFilterArg) -> &'static str {
match s {
StatusFilterArg::Pending => "pending",
StatusFilterArg::Approved => "approved",
StatusFilterArg::Rejected => "rejected",
}
}
const MAX_SLUG_BYTES: usize = 128;
const MAX_REASON_BYTES: usize = 1024;
pub fn require_safe_slug(value: &str, field: &str) -> Result<String, CliError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(CliError::user(
"invalid_value",
format!("--{field} must not be empty or whitespace-only"),
)
.with_invalid_value(value));
}
if trimmed.len() > MAX_SLUG_BYTES {
return Err(CliError::user(
"invalid_value",
format!("--{field} must be at most {MAX_SLUG_BYTES} bytes"),
)
.with_invalid_value(value));
}
let charset_ok = trimmed
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !charset_ok || trimmed.starts_with('-') || trimmed.ends_with('-') {
return Err(CliError::user(
"invalid_value",
format!("--{field} must be lowercase kebab-case ([a-z0-9-], no leading/trailing `-`)"),
)
.with_invalid_value(value));
}
Ok(trimmed.to_string())
}
pub fn validate_reason_like(value: &str, field: &str) -> Result<String, CliError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(CliError::user(
"invalid_value",
format!("--{field} must not be empty or whitespace-only"),
)
.with_invalid_value(value));
}
if trimmed.len() > MAX_REASON_BYTES {
return Err(CliError::user(
"invalid_value",
format!("--{field} must be at most {MAX_REASON_BYTES} bytes"),
)
.with_invalid_value(value));
}
if trimmed
.chars()
.any(|c| c.is_control() && c != '\t' && c != '\n')
{
return Err(CliError::user(
"invalid_value",
format!("--{field} must not contain control characters"),
)
.with_invalid_value(value));
}
Ok(trimmed.to_string())
}