use std::fs;
use std::path::{Path, PathBuf};
use lifeloop::{
FailureClass, RenewalAutomationState, RenewalAutomationStatus, RetryClass, SCHEMA_VERSION,
};
use serde_json::Value;
use super::{CliError, print_json};
pub const STATUS_FILE: &str = "renewal-status.json";
#[derive(Debug)]
struct RenewalStatusArgs {
path: PathBuf,
state_dir: Option<PathBuf>,
host: String,
client_id: String,
}
pub fn run<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
let action = args
.next()
.ok_or_else(|| CliError::Usage("renewal requires a subcommand: status".to_string()))?;
match action.as_str() {
"status" => {
let args = RenewalStatusArgs::parse(args)?;
let status = current_status(
&args.path,
args.state_dir.as_deref(),
&args.host,
&args.client_id,
epoch_s(),
)?;
print_json(&status)
}
other => Err(CliError::Usage(format!(
"renewal: unknown subcommand `{other}` (expected: status)"
))),
}
}
impl RenewalStatusArgs {
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(),
client_id: String::new(),
};
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())?,
"--client-id" => parsed.client_id = require_value(&arg, args.next())?,
other => {
return Err(CliError::Usage(format!(
"renewal status: unknown flag `{other}`"
)));
}
}
}
if parsed.host.is_empty() {
return Err(CliError::Usage(
"renewal status: --host <id> is required".into(),
));
}
if parsed.client_id.is_empty() {
return Err(CliError::Usage(
"renewal status: --client-id <id> is required".into(),
));
}
Ok(parsed)
}
}
pub fn write_status(state_dir: &Path, status: &RenewalAutomationStatus) -> Result<(), CliError> {
status
.validate()
.map_err(|err| CliError::Validation(format!("renewal status failed validation: {err}")))?;
fs::create_dir_all(state_dir).map_err(|err| {
CliError::Input(format!(
"renewal status: failed to create state dir {}: {err}",
state_dir.display()
))
})?;
let json = serde_json::to_string_pretty(status).map_err(|err| {
CliError::Input(format!("renewal status: failed to serialize status: {err}"))
})?;
fs::write(status_path(state_dir), json).map_err(|err| {
CliError::Input(format!(
"renewal status: failed to write status {}: {err}",
status_path(state_dir).display()
))
})
}
pub fn current_status(
path: &Path,
state_dir: Option<&Path>,
adapter_id: &str,
client_id: &str,
now: u64,
) -> Result<RenewalAutomationStatus, CliError> {
let state_dir = state_dir
.map(Path::to_path_buf)
.unwrap_or_else(|| default_state_dir(path));
let status_path = status_path(&state_dir);
let pending_path = pending_path(&state_dir, client_id)?;
let pending_exists = pending_path.is_file();
let mut status = if status_path.is_file() {
read_status(&status_path)?
} else if pending_exists {
status_from_pending_file(&pending_path, adapter_id, client_id, now)?
} else {
base_status(
RenewalAutomationState::NotAttempted,
adapter_id,
client_id,
now,
"renewal automation has not recorded a lifecycle decision",
)
};
if status.adapter_id != adapter_id {
return Err(CliError::Validation(format!(
"renewal status adapter `{}` does not match requested host `{adapter_id}`",
status.adapter_id
)));
}
if status.client_id != client_id {
return Err(CliError::Validation(format!(
"renewal status client `{}` does not match requested client `{client_id}`",
status.client_id
)));
}
status.pending_token_present = pending_exists;
if pending_exists {
status.pending_path = Some(pending_path.display().to_string());
} else if matches!(status.state, RenewalAutomationState::PendingContinuation) {
status.state = RenewalAutomationState::Failed;
status.failure_class = Some(FailureClass::StateConflict);
status.retry_class = Some(RetryClass::RetryAfterReread);
status.message = Some(
"pending renewal status exists, but the continuation token file is missing".into(),
);
status.pending_path = None;
}
status
.validate()
.map_err(|err| CliError::Validation(format!("renewal status failed validation: {err}")))?;
Ok(status)
}
pub fn base_status(
state: RenewalAutomationState,
adapter_id: &str,
client_id: &str,
now: u64,
message: &str,
) -> RenewalAutomationStatus {
RenewalAutomationStatus {
schema_version: SCHEMA_VERSION.to_string(),
state,
client_id: client_id.to_string(),
adapter_id: adapter_id.to_string(),
updated_at_epoch_s: now,
pending_token_present: false,
reset_path: None,
thread_id: None,
renewal_lease_id: None,
prepared_at_epoch_s: None,
fulfilled_at_epoch_s: None,
pending_path: None,
reset_prepare_receipt_path: None,
failure_class: None,
retry_class: None,
message: Some(message.to_string()),
}
}
pub fn status_path(state_dir: &Path) -> PathBuf {
state_dir.join(STATUS_FILE)
}
pub fn pending_path(state_dir: &Path, client_id: &str) -> Result<PathBuf, CliError> {
Ok(state_dir.join(pending_file_name(client_id)?))
}
pub fn pending_file_name(client_id: &str) -> Result<String, CliError> {
if !client_id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|| client_id.is_empty()
{
return Err(CliError::Usage(
"renewal status: --client-id must contain only ASCII letters, digits, '-', '_', or '.'"
.into(),
));
}
Ok(format!("{client_id}-renewal-pending.json"))
}
pub fn default_state_dir(path: &Path) -> PathBuf {
for candidate in path.ancestors() {
let git_dir = candidate.join(".git");
if git_dir.is_dir() {
return git_dir.join("lifeloop").join("renewal");
}
}
path.join(".lifeloop").join("renewal")
}
fn read_status(path: &Path) -> Result<RenewalAutomationStatus, CliError> {
let raw = fs::read_to_string(path).map_err(|err| {
CliError::Input(format!(
"renewal status: failed to read status {}: {err}",
path.display()
))
})?;
serde_json::from_str(&raw).map_err(|err| {
CliError::Input(format!(
"renewal status: invalid status {}: {err}",
path.display()
))
})
}
fn status_from_pending_file(
path: &Path,
adapter_id: &str,
client_id: &str,
now: u64,
) -> Result<RenewalAutomationStatus, CliError> {
let raw = fs::read_to_string(path).map_err(|err| {
CliError::Input(format!(
"renewal status: failed to read pending renewal {}: {err}",
path.display()
))
})?;
let pending: Value = serde_json::from_str(&raw).map_err(|err| {
CliError::Input(format!(
"renewal status: invalid pending renewal {}: {err}",
path.display()
))
})?;
let mut status = base_status(
RenewalAutomationState::PendingContinuation,
json_str(&pending, "adapter_id").unwrap_or(adapter_id),
client_id,
now,
"pending continuation token is stored out of band",
);
status.pending_token_present = true;
status.pending_path = Some(path.display().to_string());
status.reset_path = json_string(&pending, "reset_path");
status.thread_id = json_string(&pending, "thread_id");
status.renewal_lease_id = json_string(&pending, "renewal_lease_id");
status.prepared_at_epoch_s = pending.get("prepared_at_epoch_s").and_then(Value::as_u64);
status.reset_prepare_receipt_path = json_string(&pending, "reset_prepare_receipt_path");
Ok(status)
}
fn json_string(value: &Value, key: &str) -> Option<String> {
json_str(value, key).map(str::to_owned)
}
fn json_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
}
pub fn epoch_s() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn require_value(flag: &str, value: Option<String>) -> Result<String, CliError> {
value.ok_or_else(|| CliError::Usage(format!("flag `{flag}` requires a value")))
}