use std::fmt;
use uuid::Uuid;
use crate::output::OutputFormat;
pub const EXIT_LIVE_JOB: u8 = 75;
#[derive(Debug)]
pub enum LiveJob {
StillRunning { job_id: Uuid, waited_seconds: u64 },
Interrupted { job_id: Uuid },
Detached { job_id: Uuid, cause: String },
Duplicate { job_id: Uuid, detail: String },
}
impl LiveJob {
pub fn job_id(&self) -> Uuid {
match self {
Self::StillRunning { job_id, .. }
| Self::Interrupted { job_id }
| Self::Detached { job_id, .. }
| Self::Duplicate { job_id, .. } => *job_id,
}
}
pub fn outcome(&self) -> &'static str {
match self {
Self::StillRunning { .. } => "still_running",
Self::Interrupted { .. } => "interrupted",
Self::Detached { .. } => "detached",
Self::Duplicate { .. } => "duplicate",
}
}
fn headline(&self) -> String {
let id = self.job_id();
match self {
Self::StillRunning { waited_seconds, .. } => {
format!("still running after {waited_seconds}s — job {id}")
}
Self::Interrupted { .. } => format!("interrupted — job {id} is still running"),
Self::Detached { .. } => {
format!("submitted job {id} — this command could not finish following it")
}
Self::Duplicate { .. } => format!("already submitted — job {id}"),
}
}
fn explanation(&self) -> Vec<String> {
match self {
Self::StillRunning { .. } => vec![
"Nothing failed. The server's long-poll window closed while the job was \
still running — the job was not cancelled and is still being worked on."
.into(),
"It will be billed once, whether or not you keep waiting. Re-running this \
command would start a second job."
.into(),
],
Self::Interrupted { .. } => vec![
"The submission had already gone out when the interrupt arrived, so the \
job was not cancelled and is still being worked on."
.into(),
"It will be billed once, whether or not you wait for it. Re-running this \
command would start a second job."
.into(),
],
Self::Detached { cause, .. } => vec![
"The submission itself succeeded, so the job exists and is unaffected by \
whatever went wrong here."
.into(),
"It will be billed once. Re-running this command would start a second job.".into(),
format!("Cause: {cause}"),
],
Self::Duplicate { detail, .. } => vec![
detail.clone(),
],
}
}
fn follow_ups(&self) -> Vec<(String, &'static str)> {
let id = self.job_id();
let mut steps = vec![
(format!("nolgia wait {id}"), "keep waiting for it"),
(format!("nolgia status {id}"), "check it once"),
];
if matches!(self, Self::Duplicate { .. }) {
steps.reverse();
steps.push((
"nolgia gen ... --idempotency-key <new-value>".to_string(),
"deliberately run it again as a separate job",
));
}
steps
}
pub fn render_text(&self) -> String {
let mut out = self.headline();
for line in self.explanation() {
out.push_str("\n ");
out.push_str(&line);
}
let steps = self.follow_ups();
let width = steps.iter().map(|(c, _)| c.len()).max().unwrap_or(0);
for (command, note) in steps {
out.push_str(&format!("\n {command:width$} # {note}"));
}
out
}
fn render_json(&self) -> serde_json::Value {
serde_json::json!({
"job_id": self.job_id().to_string(),
"outcome": self.outcome(),
"billed_twice": false,
"message": self.headline(),
"follow_up": self.follow_ups()
.into_iter()
.map(|(command, _)| command)
.collect::<Vec<_>>(),
})
}
pub fn report(&self, format: OutputFormat) {
eprintln!("{}", self.render_text());
if format == OutputFormat::Json {
println!(
"{}",
serde_json::to_string_pretty(&self.render_json())
.unwrap_or_else(|_| self.job_id().to_string())
);
}
}
}
impl fmt::Display for LiveJob {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render_text())
}
}
impl std::error::Error for LiveJob {}
pub fn announce(job_id: Uuid, timeout_seconds: u64) {
eprintln!(
"submitted job {job_id} — waiting up to {timeout_seconds}s (Ctrl-C is safe: it does not cancel the job)"
);
}
pub async fn guard<T>(
job_id: Uuid,
work: impl std::future::Future<Output = anyhow::Result<T>>,
) -> anyhow::Result<T> {
let result = tokio::select! {
biased;
result = work => result,
_ = tokio::signal::ctrl_c() => Err(LiveJob::Interrupted { job_id }.into()),
};
result.map_err(|err| match err.downcast::<LiveJob>() {
Ok(live) => live.into(),
Err(err) => LiveJob::Detached {
job_id,
cause: format!("{err:#}"),
}
.into(),
})
}
pub fn find_job_id(detail: &str) -> Option<Uuid> {
const UUID_LEN: usize = 36;
let bytes = detail.as_bytes();
(0..bytes.len().saturating_sub(UUID_LEN - 1))
.filter(|start| detail.is_char_boundary(*start))
.find_map(|start| {
let end = start + UUID_LEN;
detail
.is_char_boundary(end)
.then(|| Uuid::parse_str(&detail[start..end]).ok())
.flatten()
})
}
#[cfg(test)]
mod tests {
use super::*;
const REAL_409_DETAIL: &str = "this exact request was already submitted as job \
184166c4-0ecd-453c-b907-66cf511ae241 less than 5m0s ago and has not been billed \
twice — check it with GET /jobs/184166c4-0ecd-453c-b907-66cf511ae241. To run it \
again anyway, resubmit with a different Idempotency-Key header.";
#[test]
fn finds_the_job_id_in_the_real_409_detail() {
assert_eq!(
find_job_id(REAL_409_DETAIL),
Some(Uuid::parse_str("184166c4-0ecd-453c-b907-66cf511ae241").expect("valid uuid"))
);
}
#[test]
fn finds_a_job_id_glued_to_punctuation() {
let id = Uuid::parse_str("233d0d6f-859a-4aa2-8b33-3cc420ca3932").expect("valid uuid");
for detail in [
"GET /jobs/233d0d6f-859a-4aa2-8b33-3cc420ca3932.",
"(233d0d6f-859a-4aa2-8b33-3cc420ca3932)",
"233d0d6f-859a-4aa2-8b33-3cc420ca3932",
] {
assert_eq!(find_job_id(detail), Some(id), "failed on {detail:?}");
}
}
#[test]
fn finds_nothing_when_there_is_no_job_id() {
assert_eq!(find_job_id("job did not finish before timeout"), None);
assert_eq!(find_job_id(""), None);
assert_eq!(find_job_id("184166c4-0ecd-453c-b907"), None);
}
#[test]
fn tolerates_non_ascii_detail() {
assert_eq!(find_job_id("déjà vu — 🎬 no id here"), None);
assert!(find_job_id("看 184166c4-0ecd-453c-b907-66cf511ae241 好").is_some());
}
#[test]
fn a_wait_timeout_never_reads_as_a_failure() {
let text = LiveJob::StillRunning {
job_id: Uuid::nil(),
waited_seconds: 300,
}
.render_text();
assert!(!text.contains("Error:"), "{text}");
let headline = text.lines().next().expect("a headline");
let lowered = headline.to_lowercase();
assert!(
!lowered.contains("error") && !lowered.contains("fail"),
"the headline must not suggest failure: {headline}"
);
assert!(
headline.starts_with("still running after 300s — job "),
"{headline}"
);
assert!(text.contains("Nothing failed."), "{text}");
assert!(text.contains("would start a second job"), "{text}");
}
#[test]
fn every_ending_names_the_job_and_offers_a_way_to_follow_it() {
let job_id = Uuid::parse_str("184166c4-0ecd-453c-b907-66cf511ae241").expect("valid uuid");
for live in [
LiveJob::StillRunning {
job_id,
waited_seconds: 300,
},
LiveJob::Interrupted { job_id },
LiveJob::Detached {
job_id,
cause: "connection closed before message completed".into(),
},
LiveJob::Duplicate {
job_id,
detail: REAL_409_DETAIL.into(),
},
] {
let text = live.render_text();
assert!(text.contains(&job_id.to_string()), "{text}");
assert!(text.contains(&format!("nolgia status {job_id}")), "{text}");
assert!(text.contains(&format!("nolgia wait {job_id}")), "{text}");
assert_eq!(live.render_json()["job_id"], job_id.to_string());
assert_eq!(live.render_json()["billed_twice"], false);
}
}
#[test]
fn a_duplicate_offers_the_deliberate_second_take() {
let text = LiveJob::Duplicate {
job_id: Uuid::nil(),
detail: REAL_409_DETAIL.into(),
}
.render_text();
assert!(text.contains("--idempotency-key"), "{text}");
assert!(text.contains("has not been billed twice"), "{text}");
}
}