use serde_json::Value;
use shep_client::dogs::{ActionDone, ActionId};
use super::*;
use crate::terminal_safe::sanitise;
const RESULT_SHOWS: Duration = Duration::from_secs(5);
const EARLY_KEPT: Duration = Duration::from_secs(10);
const EARLY_CAP: usize = 16;
const ERROR_WIDTH: usize = 80;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Running {
dog: String,
label: String,
id: ActionId,
started: Instant,
}
#[derive(Debug, Default)]
pub(super) struct LongActions {
running: Vec<Running>,
result: Option<(String, Instant)>,
early: Vec<Early>,
}
#[derive(Debug)]
struct Early {
dog: String,
id: ActionId,
outcome: Result<Value, String>,
at: Instant,
}
impl LongActions {
fn start(&mut self, dog: &str, label: &str, id: ActionId, now: Instant) -> bool {
let label = sanitise(label).0;
self.forget_stale(now);
if let Some(at) = self
.early
.iter()
.position(|seen| seen.dog == dog && seen.id == id)
{
let seen = self.early.remove(at);
self.show(&label, seen.outcome, now);
return true;
}
self.running.push(Running {
dog: dog.to_owned(),
label,
id,
started: now,
});
false
}
fn finish(&mut self, dog: &str, payload: &Value, now: Instant) -> bool {
let Some((id, outcome)) = ActionDone::read(payload) else {
return false;
};
let Some(at) = self
.running
.iter()
.position(|item| item.dog == dog && item.id == id)
else {
self.forget_stale(now);
if self.early.len() >= EARLY_CAP {
self.early.remove(0);
}
self.early.push(Early {
dog: dog.to_owned(),
id,
outcome,
at: now,
});
return false;
};
let item = self.running.remove(at);
self.show(&item.label, outcome, now);
true
}
fn show(&mut self, label: &str, outcome: Result<Value, String>, now: Instant) {
let text = match outcome {
Ok(_) => format!("{label}: done"),
Err(error) => format!("{label}: failed, {}", one_line(&error)),
};
self.result = Some((text, now));
}
fn forget_stale(&mut self, now: Instant) {
self.early
.retain(|seen| now.saturating_duration_since(seen.at) < EARLY_KEPT);
}
fn lose_all(&mut self, now: Instant) {
self.early.clear();
if self.running.is_empty() {
return;
}
let text = self
.running
.drain(..)
.map(|item| format!("{}: result unknown", item.label))
.collect::<Vec<_>>()
.join(" ");
self.result = Some((text, now));
}
pub(super) fn tick(&mut self, now: Instant) {
self.forget_stale(now);
if self
.result
.as_ref()
.is_some_and(|(_, at)| now.saturating_duration_since(*at) >= RESULT_SHOWS)
{
self.result = None;
}
}
pub(super) fn result(&self) -> Option<&str> {
self.result.as_ref().map(|(text, _)| text.as_str())
}
pub(super) fn running_line(&self, now: Instant) -> Option<String> {
let first = self.running.first()?;
let spent = now.saturating_duration_since(first.started).as_secs();
let more = match self.running.len() - 1 {
0 => String::new(),
others => format!(" +{others} more"),
};
Some(format!(
"{}: {} running {spent}s{more}",
first.dog, first.label
))
}
}
fn one_line(error: &str) -> String {
let first = sanitise(error.lines().next().unwrap_or_default()).0;
let first = first.trim();
if first.chars().count() <= ERROR_WIDTH {
return first.to_owned();
}
let kept: String = first.chars().take(ERROR_WIDTH - 1).collect();
format!("{kept}\u{2026}")
}
impl App {
pub(super) fn start_long_action(&mut self, dog: &str, label: &str, id: ActionId) -> Effect {
let now = self.now;
if self.long_actions.start(dog, label, id, now) {
self.pull_after_action_of(dog)
} else {
Effect::None
}
}
pub(super) fn on_long_action_done(&mut self, dog: &str, payload: &Value) -> Option<Effect> {
let now = self.now;
if !self.long_actions.finish(dog, payload, now) {
return None;
}
Some(self.pull_after_action_of(dog))
}
pub(super) fn lose_long_actions(&mut self) {
let now = self.now;
self.long_actions.lose_all(now);
}
pub(crate) fn long_action_result(&self) -> Option<&str> {
self.long_actions.result()
}
pub(crate) fn long_actions_running(&self) -> Option<String> {
self.long_actions.running_line(self.now)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_error_is_cut_to_its_first_line_cleaned_and_capped() {
assert_eq!(one_line("disk full\nat line 2"), "disk full");
assert_eq!(
one_line("\u{1b}[31mdisk\u{7} full\u{1b}[0m"),
"[31mdisk full[0m"
);
let long = "x".repeat(200);
let cut = one_line(&long);
assert_eq!(cut.chars().count(), ERROR_WIDTH);
assert!(cut.ends_with('\u{2026}'));
}
#[test]
fn a_result_that_came_before_the_reply_ends_the_action_at_once() {
let now = Instant::now();
let mut actions = LongActions::default();
let (id, _body) = ActionId::accept();
let payload = serde_json::json!({"id": id.as_str(), "result": null});
assert!(!actions.finish("paddock", &payload, now));
assert!(actions.start("paddock", "pause", id, now));
assert_eq!(actions.result(), Some("pause: done"));
assert!(actions.running_line(now).is_none());
}
}