use std::fs;
use std::io::{ErrorKind, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use lifeloop::protocol::{
FrameAdmissionDirective, ProtocolAdapter, ProtocolPayload, RenderRequest, render_hook_payload,
};
use lifeloop::{
AcceptablePlacement, FailureClass, IntegrationMode, LifecycleEventKind, LifecycleReceipt,
PayloadEnvelope, PayloadReceipt, PlacementClass, PlacementOutcome, ReceiptStatus,
RenewalAutomationState, RenewalAutomationStatus, RequirementLevel, RetryClass, SCHEMA_VERSION,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::renewal;
use super::{CliError, MAX_STDIN_BYTES};
const CLIENT_ID: &str = "ccd";
static RECEIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
struct HostHookArgs {
path: PathBuf,
state_dir: Option<PathBuf>,
host: String,
hook: String,
client_cmd: Option<String>,
client_profile: Option<String>,
reset_path: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PendingRenewal {
schema_version: String,
adapter_id: String,
reset_path: String,
continuation_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
renewal_lease_id: Option<String>,
thread_id: String,
prepared_at_epoch_s: u64,
#[serde(skip_serializing_if = "Option::is_none")]
reset_prepare_receipt_path: Option<String>,
}
pub fn run<I: Iterator<Item = String>>(args: I, output: Option<&str>) -> Result<(), CliError> {
let mut raw_args: Vec<String> = args.collect();
let local_output = take_output_flag(&mut raw_args)?;
let selected_output = match (output, local_output.as_deref()) {
(Some(_), Some(_)) => {
return Err(CliError::Usage(
"host-hook: --output was provided more than once".into(),
));
}
(Some(value), None) | (None, Some(value)) => value,
(None, None) => "hook-protocol",
};
match selected_output {
"hook-protocol" => {}
other => {
return Err(CliError::Usage(format!(
"host-hook: unsupported --output `{other}` (expected: hook-protocol)"
)));
}
}
let args = HostHookArgs::parse(raw_args.into_iter())?;
let host_input = read_optional_json_stdin()?;
let rendered = run_host_hook(&args, &host_input)?;
println!(
"{}",
serde_json::to_string(&rendered).unwrap_or_else(|_| "{}".to_string())
);
Ok(())
}
impl HostHookArgs {
fn parse<I: Iterator<Item = String>>(mut args: I) -> Result<Self, CliError> {
let mut parsed = Self {
path: PathBuf::from("."),
state_dir: None,
host: String::new(),
hook: String::new(),
client_cmd: None,
client_profile: None,
reset_path: "wrapper".to_string(),
};
while let Some(arg) = args.next() {
match arg.as_str() {
"--path" => parsed.path = PathBuf::from(require_value(&arg, args.next())?),
"--state-dir" => {
parsed.state_dir = Some(PathBuf::from(require_value(&arg, args.next())?));
}
"--host" => parsed.host = require_value(&arg, args.next())?,
"--hook" => parsed.hook = require_value(&arg, args.next())?,
"--client-cmd" => parsed.client_cmd = Some(require_value(&arg, args.next())?),
"--profile" => parsed.client_profile = Some(require_value(&arg, args.next())?),
"--reset-path" => {
parsed.reset_path = require_value(&arg, args.next())?;
if !matches!(parsed.reset_path.as_str(), "native" | "wrapper" | "manual") {
return Err(CliError::Usage(
"host-hook: --reset-path must be native, wrapper, or manual".into(),
));
}
}
other => {
return Err(CliError::Usage(format!(
"host-hook: unknown flag `{other}`"
)));
}
}
}
if parsed.host.is_empty() {
return Err(CliError::Usage("host-hook: --host <id> is required".into()));
}
if parsed.hook.is_empty() {
return Err(CliError::Usage(
"host-hook: --hook <hook> is required".into(),
));
}
Ok(parsed)
}
fn state_dir(&self) -> PathBuf {
self.state_dir
.clone()
.unwrap_or_else(|| renewal::default_state_dir(&self.path))
}
}
fn run_host_hook(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
match (args.host.as_str(), args.hook.as_str()) {
("codex", "on-agent-end") => codex_stop(args, host_input),
("codex", "on-session-start") => codex_session_start(args),
_ => Ok(json!({})),
}
}
fn codex_stop(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
if host_input
.get("stop_hook_active")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Ok(json!({}));
}
let Some(client_cmd) = args.client_cmd.as_deref() else {
return Ok(json!({}));
};
let state_dir = args.state_dir();
fs::create_dir_all(&state_dir).map_err(|err| {
CliError::Input(format!(
"host-hook: failed to create renewal state dir {}: {err}",
state_dir.display()
))
})?;
let boundary = match run_ccd_json(
client_cmd,
ccd_start_boundary_args(args),
"ccd start boundary probe",
) {
Ok(boundary) => boundary,
Err(err) => {
write_failed_status(
args,
&state_dir,
None,
FailureClass::TransportError,
RetryClass::SafeRetry,
err.message(),
)?;
return Err(err);
}
};
if boundary
.pointer("/session_boundary/action")
.and_then(Value::as_str)
!= Some("renew")
{
let mut status = renewal_status(
args,
RenewalAutomationState::NoRenewal,
"boundary probe completed without a renewal action",
);
status.pending_token_present = pending_path(&state_dir).is_file();
renewal::write_status(&state_dir, &status)?;
return Ok(json!({}));
}
renewal::write_status(
&state_dir,
&renewal_status(
args,
RenewalAutomationState::PrepareStarted,
"renewal boundary observed; reset prepare is starting",
),
)?;
let directive = FrameAdmissionDirective::block(
"CCD renewal is prepared. Restart this Codex session through the Lifeloop CCD renewal profile; the continuation token is stored out of band.",
);
let block_payload = render_codex_hook(LifecycleEventKind::FrameEnded, &[], Some(&directive))?;
let receipt_path = write_reset_prepare_receipt(args, host_input, &state_dir)?;
let mut status = renewal_status(
args,
RenewalAutomationState::ReceiptWritten,
"reset-prepare receipt written",
);
status.reset_prepare_receipt_path = Some(receipt_path.display().to_string());
renewal::write_status(&state_dir, &status)?;
let prepare = match run_ccd_json(
client_cmd,
ccd_prepare_args(args, &receipt_path),
"ccd session renew prepare",
) {
Ok(prepare) => prepare,
Err(err) => {
write_failed_status(
args,
&state_dir,
None,
FailureClass::TransportError,
RetryClass::SafeRetry,
err.message(),
)?;
return Err(err);
}
};
let token = match required_json_string(
&prepare,
"/continuation/token",
"host-hook: renewal continuation payload lost; CCD prepare did not return continuation.token",
) {
Ok(token) => token,
Err(err) => {
write_failed_status(
args,
&state_dir,
None,
FailureClass::PayloadRejected,
FailureClass::PayloadRejected.default_retry(),
err.message(),
)?;
return Err(err);
}
};
let thread_id = match required_json_string(
&prepare,
"/renewal/thread_id",
"host-hook: CCD prepare did not return renewal.thread_id",
) {
Ok(thread_id) => thread_id,
Err(err) => {
write_failed_status(
args,
&state_dir,
None,
FailureClass::IdentityUnavailable,
FailureClass::IdentityUnavailable.default_retry(),
err.message(),
)?;
return Err(err);
}
};
let pending = PendingRenewal {
schema_version: SCHEMA_VERSION.to_string(),
adapter_id: args.host.clone(),
reset_path: args.reset_path.clone(),
continuation_token: token.to_string(),
renewal_lease_id: prepare
.pointer("/renewal/renewal_lease_id")
.and_then(Value::as_str)
.map(str::to_owned),
thread_id: thread_id.to_string(),
prepared_at_epoch_s: epoch_s(),
reset_prepare_receipt_path: Some(receipt_path.display().to_string()),
};
renewal::write_status(
&state_dir,
&status_from_pending(
args,
&state_dir,
&pending,
RenewalAutomationState::LeaseCreated,
"renewal lease created; continuation token is not yet committed",
),
)?;
if let Err(err) = write_pending(&state_dir, &pending) {
write_failed_status(
args,
&state_dir,
Some(&pending),
FailureClass::StateConflict,
FailureClass::StateConflict.default_retry(),
err.message(),
)?;
return Err(err);
}
renewal::write_status(
&state_dir,
&status_from_pending(
args,
&state_dir,
&pending,
RenewalAutomationState::PendingContinuation,
"renewal lease prepared; continuation token is stored out of band",
),
)?;
Ok(block_payload)
}
fn codex_session_start(args: &HostHookArgs) -> Result<Value, CliError> {
let Some(client_cmd) = args.client_cmd.as_deref() else {
return Ok(json!({}));
};
let state_dir = args.state_dir();
let pending_path = pending_path(&state_dir);
if !pending_path.is_file() {
return Ok(json!({}));
}
let pending = match read_pending(&pending_path) {
Ok(pending) => pending,
Err(err) => {
write_failed_status(
args,
&state_dir,
None,
FailureClass::StateConflict,
FailureClass::StateConflict.default_retry(),
err.message(),
)?;
return Err(err);
}
};
if pending.adapter_id != args.host {
let err = CliError::Validation(format!(
"host-hook: pending renewal adapter `{}` does not match hook host `{}`",
pending.adapter_id, args.host
));
write_failed_status(
args,
&state_dir,
Some(&pending),
FailureClass::StateConflict,
FailureClass::StateConflict.default_retry(),
err.message(),
)?;
return Err(err);
}
if pending.thread_id.trim().is_empty() {
let err =
CliError::Validation("host-hook: pending renewal is missing thread binding".into());
write_failed_status(
args,
&state_dir,
Some(&pending),
FailureClass::IdentityUnavailable,
FailureClass::IdentityUnavailable.default_retry(),
err.message(),
)?;
return Err(err);
}
let body = format!(
"CCD renewal continuation restored for thread `{}`.",
pending.thread_id
);
let envelope = PayloadEnvelope {
schema_version: SCHEMA_VERSION.to_string(),
payload_id: "pay-lifeloop-renewal-continuation".into(),
client_id: "ccd".into(),
payload_kind: "lifeloop.renewal.continuation".into(),
format: "text/plain".into(),
content_encoding: "identity".into(),
byte_size: body.len() as u64,
body: Some(body),
body_ref: None,
content_digest: None,
acceptable_placements: vec![AcceptablePlacement {
placement: PlacementClass::DeveloperEquivalentFrame,
requirement: RequirementLevel::Required,
}],
idempotency_key: None,
expires_at_epoch_s: None,
redaction: None,
metadata: serde_json::Map::new(),
};
let payloads = [ProtocolPayload::new(
&envelope,
PlacementClass::DeveloperEquivalentFrame,
)];
let rendered = render_codex_hook(LifecycleEventKind::SessionStarted, &payloads, None)?;
let continuation = match run_ccd_json(
client_cmd,
ccd_start_continuation_args(args, &pending.continuation_token),
"ccd start renewal continuation",
) {
Ok(continuation) => continuation,
Err(err) => {
write_failed_status(
args,
&state_dir,
Some(&pending),
FailureClass::TransportError,
RetryClass::SafeRetry,
err.message(),
)?;
return Err(err);
}
};
if let Err(err) = ensure_thread_binding(&pending, &continuation) {
write_failed_status(
args,
&state_dir,
Some(&pending),
FailureClass::StateConflict,
FailureClass::StateConflict.default_retry(),
err.message(),
)?;
return Err(err);
}
if let Err(err) = fs::remove_file(&pending_path) {
let cli_err = CliError::Input(format!(
"host-hook: failed to clear pending renewal state {}: {err}",
pending_path.display()
));
write_failed_status(
args,
&state_dir,
Some(&pending),
FailureClass::StateConflict,
FailureClass::StateConflict.default_retry(),
cli_err.message(),
)?;
return Err(cli_err);
}
let mut status = status_from_pending(
args,
&state_dir,
&pending,
RenewalAutomationState::Fulfilled,
"renewal continuation fulfilled and thread binding preserved",
);
status.pending_token_present = false;
status.pending_path = None;
status.fulfilled_at_epoch_s = Some(epoch_s());
renewal::write_status(&state_dir, &status)?;
Ok(rendered)
}
fn renewal_status(
args: &HostHookArgs,
state: RenewalAutomationState,
message: &str,
) -> RenewalAutomationStatus {
let mut status = renewal::base_status(state, &args.host, CLIENT_ID, epoch_s(), message);
status.reset_path = Some(args.reset_path.clone());
status
}
fn status_from_pending(
args: &HostHookArgs,
state_dir: &Path,
pending: &PendingRenewal,
state: RenewalAutomationState,
message: &str,
) -> RenewalAutomationStatus {
let mut status = renewal_status(args, state, message);
status.reset_path = Some(pending.reset_path.clone());
status.thread_id = Some(pending.thread_id.clone());
status.renewal_lease_id = pending.renewal_lease_id.clone();
status.prepared_at_epoch_s = Some(pending.prepared_at_epoch_s);
status.pending_token_present = pending_path(state_dir).is_file();
if status.pending_token_present {
status.pending_path = Some(pending_path(state_dir).display().to_string());
}
status.reset_prepare_receipt_path = pending.reset_prepare_receipt_path.clone();
status
}
fn write_failed_status(
args: &HostHookArgs,
state_dir: &Path,
pending: Option<&PendingRenewal>,
failure_class: FailureClass,
retry_class: RetryClass,
message: &str,
) -> Result<(), CliError> {
let mut status = if let Some(pending) = pending {
status_from_pending(
args,
state_dir,
pending,
RenewalAutomationState::Failed,
message,
)
} else {
renewal_status(args, RenewalAutomationState::Failed, message)
};
status.failure_class = Some(failure_class);
status.retry_class = Some(retry_class);
status.pending_token_present = pending_path(state_dir).is_file();
if status.pending_token_present {
status.pending_path = Some(pending_path(state_dir).display().to_string());
}
renewal::write_status(state_dir, &status)
}
fn render_codex_hook(
event: LifecycleEventKind,
payloads: &[ProtocolPayload<'_>],
directive: Option<&FrameAdmissionDirective>,
) -> Result<Value, CliError> {
let req = RenderRequest {
adapter: ProtocolAdapter::Codex,
adapter_id: "codex",
adapter_version: "0.1.0",
integration_mode: IntegrationMode::NativeHook,
event,
frame: None,
payloads,
directive,
};
render_hook_payload(&req)
.map(|payload| payload.body)
.map_err(|err| {
CliError::Validation(format!("host-hook: failed to render Codex hook: {err}"))
})
}
fn ccd_start_boundary_args(args: &HostHookArgs) -> Vec<String> {
let mut cmd = vec![
"--output".into(),
"json".into(),
"start".into(),
"--refresh".into(),
"--fields".into(),
"command,ok,session_boundary,thread".into(),
"--path".into(),
args.path.display().to_string(),
];
append_profile(args, &mut cmd);
cmd
}
fn ccd_prepare_args(args: &HostHookArgs, receipt_path: &Path) -> Vec<String> {
let mut cmd = vec![
"--output".into(),
"json".into(),
"session".into(),
"renew".into(),
"prepare".into(),
"--path".into(),
args.path.display().to_string(),
"--adapter".into(),
args.host.clone(),
"--reset-path".into(),
args.reset_path.clone(),
"--lifeloop-receipt".into(),
receipt_path.display().to_string(),
];
append_profile(args, &mut cmd);
cmd
}
fn ccd_start_continuation_args(args: &HostHookArgs, token: &str) -> Vec<String> {
let mut cmd = vec![
"--output".into(),
"json".into(),
"start".into(),
"--refresh".into(),
"--continuation".into(),
token.to_string(),
"--fields".into(),
"command,ok,activation,thread".into(),
"--path".into(),
args.path.display().to_string(),
];
append_profile(args, &mut cmd);
cmd
}
fn append_profile(args: &HostHookArgs, cmd: &mut Vec<String>) {
if let Some(profile) = &args.client_profile {
cmd.push("--profile".into());
cmd.push(profile.clone());
}
}
fn run_ccd_json(program: &str, args: Vec<String>, label: &str) -> Result<Value, CliError> {
let output = Command::new(program)
.args(&args)
.output()
.map_err(|err| CliError::Input(format!("host-hook: failed to spawn {label}: {err}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CliError::Validation(format!(
"host-hook: {label} failed: {}",
stderr.trim()
)));
}
let value: Value = serde_json::from_slice(&output.stdout).map_err(|err| {
CliError::Input(format!("host-hook: {label} returned invalid JSON: {err}"))
})?;
if value.get("ok").and_then(Value::as_bool) == Some(false) {
let detail = value
.get("error")
.or_else(|| value.get("message"))
.and_then(Value::as_str)
.unwrap_or("ok=false");
return Err(CliError::Validation(format!(
"host-hook: {label} failed: {detail}"
)));
}
Ok(value)
}
fn ensure_thread_binding(pending: &PendingRenewal, continuation: &Value) -> Result<(), CliError> {
if continuation
.pointer("/thread/active_session_matches")
.and_then(Value::as_bool)
!= Some(true)
{
return Err(CliError::Validation(
"host-hook: CCD renewal continuation did not preserve active thread binding".into(),
));
}
let expected = pending.thread_id.as_str();
let actual = continuation
.pointer("/thread/thread_id")
.or_else(|| continuation.pointer("/activation/thread_id"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
CliError::Validation(
"host-hook: CCD renewal continuation did not report a thread binding".into(),
)
})?;
if actual != expected {
return Err(CliError::Validation(format!(
"host-hook: CCD renewal continuation changed thread binding from `{expected}` to `{actual}`"
)));
}
Ok(())
}
fn write_reset_prepare_receipt(
args: &HostHookArgs,
host_input: &Value,
state_dir: &Path,
) -> Result<PathBuf, CliError> {
let now = epoch_s();
let id = unique_suffix();
let payload_id = format!("pay-renewal-reset-prepare-{id}");
let receipt = LifecycleReceipt {
schema_version: SCHEMA_VERSION.to_string(),
receipt_id: format!("lfr-renewal-reset-prepare-{id}"),
idempotency_key: Some(format!("idem-renewal-reset-prepare-{id}")),
client_id: "ccd".into(),
adapter_id: args.host.clone(),
invocation_id: format!("inv-renewal-reset-prepare-{id}"),
event: LifecycleEventKind::SessionEnding,
event_id: format!("evt-renewal-reset-prepare-{id}"),
sequence: None,
parent_receipt_id: None,
integration_mode: IntegrationMode::NativeHook,
status: ReceiptStatus::Observed,
at_epoch_s: now,
harness_session_id: host_input
.get("session_id")
.and_then(Value::as_str)
.map(str::to_owned),
harness_run_id: host_input
.get("run_id")
.and_then(Value::as_str)
.map(str::to_owned),
harness_task_id: host_input
.get("turn_id")
.and_then(Value::as_str)
.map(str::to_owned),
payload_receipts: vec![PayloadReceipt {
payload_id,
payload_kind: "lifeloop.renewal.reset_prepare".into(),
placement: PlacementClass::ReceiptOnly,
status: PlacementOutcome::Delivered,
byte_size: 0,
content_digest: None,
}],
telemetry_summary: serde_json::Map::new(),
capability_degradations: Vec::new(),
failure_class: None,
retry_class: None,
warnings: Vec::new(),
};
receipt.validate().map_err(|err| {
CliError::Validation(format!("host-hook: generated invalid receipt: {err}"))
})?;
let path = state_dir.join(format!("reset-prepare-{id}.json"));
let json = serde_json::to_string_pretty(&receipt)
.map_err(|err| CliError::Input(format!("host-hook: failed to serialize receipt: {err}")))?;
fs::write(&path, json).map_err(|err| {
CliError::Input(format!(
"host-hook: failed to write reset-prepare receipt {}: {err}",
path.display()
))
})?;
Ok(path)
}
fn write_pending(state_dir: &Path, pending: &PendingRenewal) -> Result<(), CliError> {
let path = pending_path(state_dir);
if path.exists() {
return Err(CliError::Validation(format!(
"host-hook: pending renewal already exists at {}; refusing to overwrite continuation token",
path.display()
)));
}
let json = serde_json::to_string_pretty(pending).map_err(|err| {
CliError::Input(format!(
"host-hook: failed to serialize pending renewal: {err}"
))
})?;
let pending_file_name = pending_file_name();
let temp_path = state_dir.join(format!("{pending_file_name}.{}.tmp", unique_suffix()));
let mut open_options = fs::OpenOptions::new();
open_options.write(true).create_new(true);
#[cfg(unix)]
open_options.mode(0o600);
let mut file = open_options.open(&temp_path).map_err(|err| {
CliError::Input(format!(
"host-hook: failed to create pending renewal temp file {}: {err}",
temp_path.display()
))
})?;
if let Err(err) = file.write_all(json.as_bytes()) {
let _ = fs::remove_file(&temp_path);
return Err(CliError::Input(format!(
"host-hook: failed to write pending renewal temp file {}: {err}",
temp_path.display()
)));
}
if let Err(err) = file.sync_all() {
let _ = fs::remove_file(&temp_path);
return Err(CliError::Input(format!(
"host-hook: failed to sync pending renewal temp file {}: {err}",
temp_path.display()
)));
}
drop(file);
match fs::hard_link(&temp_path, &path) {
Ok(()) => {
let _ = fs::remove_file(&temp_path);
Ok(())
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
let _ = fs::remove_file(&temp_path);
Err(CliError::Validation(format!(
"host-hook: pending renewal already exists at {}; refusing to overwrite continuation token",
path.display()
)))
}
Err(err) => {
let _ = fs::remove_file(&temp_path);
Err(CliError::Input(format!(
"host-hook: failed to commit pending renewal {}: {err}",
path.display()
)))
}
}
}
fn read_pending(path: &Path) -> Result<PendingRenewal, CliError> {
let raw = fs::read_to_string(path).map_err(|err| {
CliError::Input(format!(
"host-hook: failed to read pending renewal {}: {err}",
path.display()
))
})?;
serde_json::from_str(&raw).map_err(|err| {
CliError::Input(format!(
"host-hook: invalid pending renewal {}: {err}",
path.display()
))
})
}
fn pending_path(state_dir: &Path) -> PathBuf {
renewal::pending_path(state_dir, CLIENT_ID)
.expect("static host-hook client id must produce a safe pending file name")
}
fn pending_file_name() -> String {
renewal::pending_file_name(CLIENT_ID)
.expect("static host-hook client id must produce a safe pending file name")
}
fn read_optional_json_stdin() -> Result<Value, CliError> {
let mut buf = Vec::new();
std::io::stdin()
.lock()
.take(MAX_STDIN_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|err| CliError::Input(format!("host-hook: failed to read stdin: {err}")))?;
if buf.len() as u64 > MAX_STDIN_BYTES {
return Err(CliError::Input(format!(
"host-hook: stdin exceeds {MAX_STDIN_BYTES} bytes"
)));
}
if buf.iter().all(|b| b.is_ascii_whitespace()) {
return Ok(json!({}));
}
serde_json::from_slice(&buf)
.map_err(|err| CliError::Input(format!("host-hook: invalid hook JSON on stdin: {err}")))
}
fn epoch_s() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn unique_suffix() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let seq = RECEIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{}-{nanos}-{seq}", std::process::id())
}
fn required_json_string<'a>(
value: &'a Value,
pointer: &str,
message: &str,
) -> Result<&'a str, CliError> {
value
.pointer(pointer)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| CliError::Validation(message.into()))
}
fn take_output_flag(args: &mut Vec<String>) -> Result<Option<String>, CliError> {
let mut found = None;
let mut index = 0;
while index < args.len() {
if args[index] == "--output" {
if index + 1 >= args.len() {
return Err(CliError::Usage("flag `--output` requires a value".into()));
}
let value = args.remove(index + 1);
args.remove(index);
if found.replace(value).is_some() {
return Err(CliError::Usage(
"host-hook: --output was provided more than once".into(),
));
}
continue;
}
if let Some(value) = args[index].strip_prefix("--output=") {
let value = value.to_string();
args.remove(index);
if found.replace(value).is_some() {
return Err(CliError::Usage(
"host-hook: --output was provided more than once".into(),
));
}
continue;
}
index += 1;
}
Ok(found)
}
fn require_value(flag: &str, value: Option<String>) -> Result<String, CliError> {
value.ok_or_else(|| CliError::Usage(format!("flag `{flag}` requires a value")))
}