use mecha_slack::blocks;
use serde_json::Value;
use super::actions::Action;
pub fn is_triggers_command(text: &str) -> bool {
text.trim().eq_ignore_ascii_case("triggers")
}
pub struct Row {
pub name: String,
pub enabled: bool,
pub schedule: String,
pub running: bool,
pub last: Option<String>,
}
pub async fn listing() -> (String, Option<Vec<Value>>) {
tokio::task::spawn_blocking(read_listing)
.await
.unwrap_or_else(|e| (format!("The trigger listing was lost: {e}"), None))
}
fn read_listing() -> (String, Option<Vec<Value>>) {
let Some(store) = mecha_core::trigger::TriggerStore::open_existing_default() else {
return (
"No triggers — `mecha trigger add` creates one.".to_string(),
None,
);
};
let (triggers, problems) = match store.list() {
Ok(listed) => listed,
Err(e) => return (format!("The trigger store could not be read: {e}"), None),
};
if triggers.is_empty() {
return (
"No triggers — `mecha trigger add` creates one.".to_string(),
None,
);
}
let names: std::collections::BTreeSet<&str> =
triggers.iter().map(|t| t.name.as_str()).collect();
let last = last_statuses(&store, &names);
let rows: Vec<Row> = triggers
.iter()
.map(|t| Row {
name: t.name.clone(),
enabled: t.enabled,
schedule: t.schedule.source().to_string(),
running: store.running(&t.name).is_some(),
last: last.get(&t.name).cloned(),
})
.collect();
let enabled = rows.iter().filter(|r| r.enabled).count();
let mut blocks_out = vec![blocks::section(&format!(
"*Triggers:* {} known, {} enabled.",
rows.len(),
enabled
))];
blocks_out.extend(listing_blocks(&rows));
for p in &problems {
blocks_out.push(blocks::context(&format!("unreadable trigger — {p}")));
}
(
format!("Triggers: {} known, {} enabled.", rows.len(), enabled),
Some(blocks::cap_blocks(blocks_out)),
)
}
fn last_statuses(
store: &mecha_core::trigger::TriggerStore,
names: &std::collections::BTreeSet<&str>,
) -> std::collections::BTreeMap<String, String> {
let mut last = std::collections::BTreeMap::new();
let _ = store.scan_runs_rev(|run| {
if names.contains(run.trigger.as_str()) {
let status = run.status.as_str().to_string();
last.entry(run.trigger).or_insert(status);
}
last.len() < names.len()
});
last
}
pub fn listing_blocks(rows: &[Row]) -> Vec<Value> {
let button = |action: &Action, label: &str, style: Option<&str>| {
blocks::button(action.action_id(), label, &action.value(), style)
};
let mut out = Vec::new();
for row in rows {
let state = match (row.running, row.enabled) {
(true, _) => "running now",
(false, true) => "enabled",
(false, false) => "disabled",
};
out.push(blocks::section(&format!(
"`{}` · `{}` · {}{}",
row.name,
row.schedule,
state,
row.last
.as_ref()
.map(|s| format!(" · last {s}"))
.unwrap_or_default()
)));
let controls = if row.running {
vec![
button(
&Action::TriggerCancel {
name: row.name.clone(),
},
"Cancel run",
Some("danger"),
),
button(
&Action::TriggerDisable {
name: row.name.clone(),
},
"Disable",
None,
),
]
} else if row.enabled {
vec![
button(
&Action::TriggerRun {
name: row.name.clone(),
},
"Run now",
None,
),
button(
&Action::TriggerDisable {
name: row.name.clone(),
},
"Disable",
None,
),
]
} else {
vec![button(
&Action::TriggerEnable {
name: row.name.clone(),
},
"Enable",
Some("primary"),
)]
};
out.push(blocks::actions(controls));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::slack::actions::ids;
fn row(name: &str, enabled: bool, running: bool) -> Row {
Row {
name: name.into(),
enabled,
schedule: "0 7 * * *".into(),
running,
last: Some("ok".into()),
}
}
fn text(blocks: &[Value]) -> String {
serde_json::to_string(blocks).unwrap()
}
#[test]
fn the_word_matches_exactly_and_nothing_longer() {
for word in ["triggers", "Triggers", " TRIGGERS "] {
assert!(is_triggers_command(word), "{word:?} should fire");
}
for prose in [
"trigger",
"list my triggers please",
"triggers?",
"run triggers",
"",
] {
assert!(!is_triggers_command(prose), "{prose:?} should not fire");
}
}
#[test]
fn a_disabled_row_offers_enable_and_only_enable() {
let rendered = listing_blocks(&[row("morning", false, false)]);
let t = text(&rendered);
assert!(t.contains(ids::TRIGGER_ENABLE), "{t}");
assert!(!t.contains(ids::TRIGGER_DISABLE), "{t}");
assert!(
!t.contains(ids::TRIGGER_RUN),
"a disabled trigger cannot run: {t}"
);
assert!(!t.contains(ids::TRIGGER_CANCEL), "{t}");
}
#[test]
fn an_enabled_idle_row_offers_run_and_disable_and_a_running_row_offers_cancel() {
let idle = text(&listing_blocks(&[row("morning", true, false)]));
assert!(idle.contains(ids::TRIGGER_RUN), "{idle}");
assert!(idle.contains(ids::TRIGGER_DISABLE), "{idle}");
assert!(!idle.contains(ids::TRIGGER_ENABLE), "{idle}");
assert!(!idle.contains(ids::TRIGGER_CANCEL), "{idle}");
let running = text(&listing_blocks(&[row("morning", true, true)]));
assert!(running.contains(ids::TRIGGER_CANCEL), "{running}");
assert!(
!running.contains(ids::TRIGGER_RUN),
"re-running a mid-flight trigger is an overlap-skip: {running}"
);
}
#[test]
fn the_last_status_comes_from_the_ledger_tail_past_a_torn_old_line() {
use mecha_core::trigger::{RunRecord, RunStatus, TriggerStore};
use std::io::Write;
let dir = std::env::temp_dir().join(format!(
"mecha-slack-triggers-tail-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
let store = TriggerStore::open(&dir).unwrap();
{
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(store.ledger_path())
.unwrap();
file.write_all(b"{\"trigger\": \"morning\xff\xfe\n")
.unwrap();
}
let mut old = RunRecord::started("morning", None, false);
old.status = RunStatus::Error;
store.append_run(&old).unwrap();
let mut prep = RunRecord::started("prep", None, false);
prep.status = RunStatus::SkippedOverlap;
store.append_run(&prep).unwrap();
let mut new = RunRecord::started("morning", None, false);
new.status = RunStatus::Ok;
store.append_run(&new).unwrap();
let names: std::collections::BTreeSet<&str> =
["morning", "prep", "never-ran"].into_iter().collect();
let last = last_statuses(&store, &names);
assert_eq!(last.get("morning").map(String::as_str), Some("ok"));
assert_eq!(
last.get("prep").map(String::as_str),
Some("skipped (overlap)")
);
assert_eq!(last.get("never-ran"), None);
assert!(store.runs().is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn every_button_value_is_the_trigger_name_and_never_a_command_fragment() {
let rendered = listing_blocks(&[
row("morning", true, false),
row("nightly", false, false),
row("prep", true, true),
]);
for block in &rendered {
let Some(elements) = block.get("elements").and_then(Value::as_array) else {
continue;
};
for button in elements.iter().filter(|b| b["type"] == "button") {
let value = button["value"].as_str().unwrap_or_default();
assert!(
["morning", "nightly", "prep"].contains(&value),
"a button value must be a trigger name, got {value:?}"
);
}
}
}
}