use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
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" | "--adapter" => 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 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 {
git_state_dir(path)
.or_else(|| gitdir_state_dir(path))
.unwrap_or_else(|| path.join(".lifeloop").join("renewal"))
}
fn git_state_dir(path: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--git-path", "lifeloop/renewal"])
.current_dir(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
let resolved = PathBuf::from(trimmed);
if resolved.is_absolute() {
Some(resolved)
} else {
Some(path.join(resolved))
}
}
fn gitdir_state_dir(path: &Path) -> Option<PathBuf> {
for candidate in path.ancestors() {
let dot_git = candidate.join(".git");
if dot_git.is_dir() {
return Some(dot_git.join("lifeloop").join("renewal"));
}
if dot_git.is_file() {
let raw = fs::read_to_string(&dot_git).ok()?;
let gitdir = raw.trim().strip_prefix("gitdir:")?.trim();
if gitdir.is_empty() {
return None;
}
let resolved = PathBuf::from(gitdir);
let gitdir = if resolved.is_absolute() {
resolved
} else {
candidate.join(resolved)
};
return Some(gitdir.join("lifeloop").join("renewal"));
}
}
None
}
pub fn read_status_if_present(
state_dir: &Path,
) -> Result<Option<RenewalAutomationStatus>, CliError> {
let path = status_path(state_dir);
if !path.is_file() {
return Ok(None);
}
read_status(&path).map(Some)
}
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")))
}