use std::collections::HashMap;
use std::io::Write;
use crate::job::{Job, JobStatus};
use crate::user_options::UserOptions;
#[derive(Debug, Clone, PartialEq)]
pub struct JobTransition {
pub id: String,
pub name: String,
pub from: JobStatus,
pub to: JobStatus,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum TransitionKind {
Started,
Finished,
}
impl JobTransition {
fn kind(&self) -> Option<TransitionKind> {
match (&self.from, &self.to) {
(JobStatus::Pending, JobStatus::Running) => Some(TransitionKind::Started),
(
JobStatus::Running | JobStatus::Completing,
JobStatus::Completed
| JobStatus::Failed
| JobStatus::Timeout
| JobStatus::Cancelled,
) => Some(TransitionKind::Finished),
_ => None,
}
}
fn message(&self) -> Option<String> {
let name: String = self.name.chars().filter(|c| !c.is_control()).collect();
match self.kind()? {
TransitionKind::Started => Some(format!("Job {} ({}) started", self.id, name)),
TransitionKind::Finished => Some(format!(
"Job {} ({}) finished: {}",
self.id,
name,
self.to.to_string().to_uppercase()
)),
}
}
}
pub fn detect_transitions(old_jobs: &[Job], new_jobs: &[Job]) -> Vec<JobTransition> {
let old_status: HashMap<&str, &JobStatus> = old_jobs
.iter()
.map(|job| (job.id.as_str(), &job.status))
.collect();
new_jobs
.iter()
.filter_map(|job| {
let from = *old_status.get(job.id.as_str())?;
if *from == job.status {
return None;
}
Some(JobTransition {
id: job.id.clone(),
name: job.name.clone(),
from: from.clone(),
to: job.status.clone(),
})
})
.collect()
}
pub fn notify(
writer: &mut impl Write,
transitions: &[JobTransition],
options: &UserOptions,
) -> std::io::Result<()> {
if !options.notify_bell && !options.notify_desktop {
return Ok(());
}
let messages: Vec<String> = transitions
.iter()
.filter_map(JobTransition::message)
.collect();
if messages.is_empty() {
return Ok(());
}
if options.notify_bell {
writer.write_all(b"\x07")?;
}
if options.notify_desktop {
for message in &messages {
write!(writer, "\x1b]777;notify;stama;{}\x1b\\", message)?;
}
}
writer.flush()
}
#[cfg(test)]
mod tests {
use super::*;
fn job(id: &str, name: &str, status: JobStatus) -> Job {
Job::new(
id, name, status, "00:00:00", "main", 1, "/work", "cmd", None,
)
}
fn transition(from: JobStatus, to: JobStatus) -> JobTransition {
JobTransition {
id: "12345".to_string(),
name: "train_model".to_string(),
from,
to,
}
}
fn all_on() -> UserOptions {
UserOptions {
notify_bell: true,
notify_desktop: true,
..UserOptions::default()
}
}
#[test]
fn detects_started_and_finished_jobs() {
let old = vec![
job("1", "a", JobStatus::Pending),
job("2", "b", JobStatus::Running),
];
let new = vec![
job("1", "a", JobStatus::Running),
job("2", "b", JobStatus::Completed),
];
assert_eq!(
detect_transitions(&old, &new),
vec![
JobTransition {
id: "1".to_string(),
name: "a".to_string(),
from: JobStatus::Pending,
to: JobStatus::Running,
},
JobTransition {
id: "2".to_string(),
name: "b".to_string(),
from: JobStatus::Running,
to: JobStatus::Completed,
},
]
);
}
#[test]
fn unchanged_jobs_produce_no_transition() {
let old = vec![job("1", "a", JobStatus::Running)];
let new = vec![job("1", "a", JobStatus::Running)];
assert_eq!(detect_transitions(&old, &new), vec![]);
}
#[test]
fn first_seen_jobs_produce_no_transition() {
let old = vec![];
let new = vec![job("1", "a", JobStatus::Running)];
assert_eq!(detect_transitions(&old, &new), vec![]);
let old = vec![job("1", "a", JobStatus::Running)];
let new = vec![
job("1", "a", JobStatus::Running),
job("2", "b", JobStatus::Pending),
];
assert_eq!(detect_transitions(&old, &new), vec![]);
}
#[test]
fn disappeared_jobs_produce_no_transition() {
let old = vec![
job("1", "a", JobStatus::Running),
job("2", "b", JobStatus::Pending),
];
let new = vec![];
assert_eq!(detect_transitions(&old, &new), vec![]);
}
#[test]
fn bell_rings_once_per_batch() {
let options = UserOptions {
notify_bell: true,
..UserOptions::default()
};
let transitions = vec![
transition(JobStatus::Pending, JobStatus::Running),
transition(JobStatus::Running, JobStatus::Failed),
];
let mut out: Vec<u8> = Vec::new();
notify(&mut out, &transitions, &options).unwrap();
assert_eq!(out, b"\x07");
}
#[test]
fn desktop_notification_emits_osc_777_per_transition() {
let options = UserOptions {
notify_desktop: true,
..UserOptions::default()
};
let transitions = vec![
transition(JobStatus::Pending, JobStatus::Running),
transition(JobStatus::Running, JobStatus::Completed),
];
let mut out: Vec<u8> = Vec::new();
notify(&mut out, &transitions, &options).unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"\x1b]777;notify;stama;Job 12345 (train_model) started\x1b\\\
\x1b]777;notify;stama;Job 12345 (train_model) finished: COMPLETED\x1b\\"
);
}
#[test]
fn both_channels_emit_bell_then_escapes() {
let transitions = vec![transition(JobStatus::Running, JobStatus::Timeout)];
let mut out: Vec<u8> = Vec::new();
notify(&mut out, &transitions, &all_on()).unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"\x07\x1b]777;notify;stama;Job 12345 (train_model) finished: TIMEOUT\x1b\\"
);
}
#[test]
fn disabled_options_emit_nothing() {
let transitions = vec![transition(JobStatus::Pending, JobStatus::Running)];
let mut out: Vec<u8> = Vec::new();
notify(&mut out, &transitions, &UserOptions::default()).unwrap();
assert!(out.is_empty());
}
#[test]
fn non_notable_transitions_emit_nothing() {
let transitions = vec![
transition(JobStatus::Running, JobStatus::Completing),
transition(JobStatus::Unknown, JobStatus::Running),
transition(JobStatus::Pending, JobStatus::Cancelled),
];
let mut out: Vec<u8> = Vec::new();
notify(&mut out, &transitions, &all_on()).unwrap();
assert!(out.is_empty());
}
#[test]
fn completing_to_terminal_state_counts_as_finished() {
let transitions = vec![transition(JobStatus::Completing, JobStatus::Cancelled)];
let mut out: Vec<u8> = Vec::new();
notify(&mut out, &transitions, &all_on()).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("Job 12345 (train_model) finished: CANCELLED"));
}
#[test]
fn control_characters_are_stripped_from_the_job_name() {
let mut transition = transition(JobStatus::Pending, JobStatus::Running);
transition.name = "evil\x07\x1b]name".to_string();
let mut out: Vec<u8> = Vec::new();
notify(
&mut out,
&[transition],
&UserOptions {
notify_desktop: true,
..UserOptions::default()
},
)
.unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"\x1b]777;notify;stama;Job 12345 (evil]name) started\x1b\\"
);
}
}