use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use mecha_core::agent::{Conversation, RunContext};
use mecha_core::config::PermissionMode;
use mecha_core::message::Message;
use mecha_core::session::{Record, RunConfig, Session, SessionMeta};
use mecha_core::trigger::{CatchUp, Due, RunRecord, RunStatus, Trigger, TriggerStore};
use std::io::Write;
use tokio_util::sync::CancellationToken;
use crate::{setup, GlobalOpts};
#[derive(clap::Args, Debug)]
pub struct Args {
#[command(subcommand)]
pub cmd: Option<Cmd>,
}
#[allow(clippy::large_enum_variant)]
#[derive(clap::Subcommand, Debug)]
pub enum Cmd {
List,
Add(AddArgs),
Show {
name: String,
#[arg(long)]
last: bool,
},
Edit { name: String },
Rm { name: String },
Enable { name: String },
Disable { name: String },
Next {
name: Option<String>,
#[arg(long, short = 'n', default_value_t = 5)]
count: usize,
},
Run { name: String },
Tick {
#[arg(long)]
dry_run: bool,
},
Daemon,
Cancel { name: String },
Runs {
name: Option<String>,
#[arg(long, short = 'n', default_value_t = 20)]
count: usize,
},
}
#[derive(clap::Args, Debug)]
pub struct AddArgs {
pub name: String,
#[arg(long)]
pub schedule: String,
#[arg(long)]
pub prompt: String,
#[arg(long)]
pub description: Option<String>,
#[arg(long)]
pub timezone: Option<String>,
#[arg(long)]
pub timeout: Option<String>,
#[arg(long)]
pub catch_up: Option<String>,
#[arg(long)]
pub notify: Option<String>,
#[arg(long)]
pub disabled: bool,
#[arg(long)]
pub force: bool,
}
pub async fn execute(global: &GlobalOpts, args: Args) -> Result<()> {
match args.cmd.unwrap_or(Cmd::List) {
Cmd::List => list(),
Cmd::Add(a) => add(global, a),
Cmd::Show { name, last } => show(&name, last),
Cmd::Edit { name } => edit(&name),
Cmd::Rm { name } => {
TriggerStore::open_default()?.remove(&name)?;
println!("removed trigger `{name}` (its ledger rows stay as the record)");
Ok(())
}
Cmd::Enable { name } => set_enabled(&name, true),
Cmd::Disable { name } => set_enabled(&name, false),
Cmd::Next { name, count } => next(name.as_deref(), count),
Cmd::Run { name } => run_one(global, &name).await,
Cmd::Tick { dry_run } => {
tick(global, dry_run, None).await?;
Ok(())
}
Cmd::Daemon => daemon(global).await,
Cmd::Cancel { name } => cancel(&name),
Cmd::Runs { name, count } => runs(name.as_deref(), count),
}
}
fn cancel(name: &str) -> Result<()> {
let store = open()?;
let _ = store.get(name)?;
if store.request_cancel(name)? {
println!("asked `{name}` to stop — it will end at its next safe point");
} else {
println!("`{name}` is not running");
}
Ok(())
}
fn config_tz() -> Option<Tz> {
mecha_core::config::Config::load_global()
.ok()
.and_then(|c| c.agent.timezone())
}
fn open() -> Result<TriggerStore> {
TriggerStore::open_default()
}
fn list() -> Result<()> {
let store = open()?;
let (triggers, problems) = store.list()?;
for p in &problems {
eprintln!("mecha: unreadable trigger — {p}");
}
if triggers.is_empty() {
println!(
"no triggers. Add one:\n \
mecha trigger add briefing --schedule '0 7 * * 1-5' \\\n \
--prompt \"Summarise my inbox and today's calendar.\""
);
return Ok(());
}
let now = Utc::now();
let tz = config_tz();
let last_slots = store.last_slots()?;
let last_runs = last_run_per_trigger(&store)?;
for t in &triggers {
let state = if t.enabled { "" } else { " (disabled)" };
let when = if !t.enabled {
"—".to_string()
} else {
match t.due(last_slots.get(&t.name).copied(), now, tz) {
Due::Now { .. } | Due::Stale { .. } => "due now".to_string(),
Due::Not { next: Some(next) } => {
format!("in {} ({})", human_gap(next - now), local(next, t.tz(tz)))
}
Due::Not { next: None } | Due::Disabled => "never".to_string(),
}
};
let last = last_runs
.get(&t.name)
.map(|r| {
format!(
" last {} {}",
r.status.as_str(),
human_gap(now - r.started_at) + " ago"
)
})
.unwrap_or_default();
println!(
"{:<20} {:<16} {:<28}{}{}",
t.name,
t.schedule.source(),
when,
last,
state
);
if let Some(d) = &t.description {
println!("{:22}{d}", "");
}
}
Ok(())
}
fn last_run_per_trigger(
store: &TriggerStore,
) -> Result<std::collections::BTreeMap<String, RunRecord>> {
let mut out = std::collections::BTreeMap::new();
for run in store.runs()? {
out.insert(run.trigger.clone(), run);
}
Ok(out)
}
fn show(name: &str, last: bool) -> Result<()> {
let store = open()?;
let t = store.get(name)?;
let tz = t.tz(config_tz());
let now = Utc::now();
println!(
"trigger {}{}",
t.name,
if t.enabled { "" } else { " (disabled)" }
);
if let Some(d) = &t.description {
println!(" {d}");
}
println!(" schedule {} [{}]", t.schedule.source(), tz);
if let Some(next) = t.next_fire(now, config_tz()) {
println!(
" next fire {} (in {})",
local(next, tz),
human_gap(next - now)
);
}
println!(" catch up {}", t.catch_up);
println!(" permission {:?}", t.permission_mode);
println!(
" timeout {}",
mecha_core::trigger::render_duration(t.timeout_duration())
);
if let Some(p) = &t.provider {
println!(" provider {p}");
}
if let Some(m) = &t.model {
println!(" model {m}");
}
match &t.workspace {
Some(w) => println!(" workspace {}", w.display()),
None => println!(
" workspace {} (default)",
mecha_core::work::producer_dir(&t.name)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "unresolvable".into())
),
}
if !t.tools.is_empty() {
println!(" tools {}", t.tools.join(", "));
}
if t.no_mcp {
println!(" mcp off");
}
if let Some(n) = t.max_turns {
println!(" max turns {n}");
}
if let Some(n) = t.max_output_tokens {
println!(" max output {n} tokens");
}
if let Some(c) = t.max_cost_usd {
println!(" max cost ${c:.2}");
}
if let Some(n) = &t.notify {
println!(" notify {n}");
}
println!(" file {}", store.path_of(&t.name).display());
println!("\nprompt:\n{}", indent(&t.prompt));
let mine: Vec<RunRecord> = store
.runs()?
.into_iter()
.filter(|r| r.trigger == t.name)
.collect();
if !mine.is_empty() {
println!("\nrecent runs:");
for r in mine.iter().rev().take(5) {
print_run(r);
}
}
if last {
match mine.iter().rev().find(|r| r.session_id.is_some()) {
Some(r) => print_answer(r)?,
None => println!("\nno recorded run to read back"),
}
}
Ok(())
}
fn print_answer(run: &RunRecord) -> Result<()> {
let id = run.session_id.as_deref().unwrap_or_default();
let dir = Session::default_dir()?;
let path = Session::find(&dir, id)?;
let (_, convo) = Session::load(&path)?;
let text = convo
.messages
.iter()
.rev()
.find(|m| m.role == mecha_core::Role::Assistant)
.map(|m| m.text())
.unwrap_or_default();
println!(
"\n── {} · session {id} ──\n{text}",
run.started_at.to_rfc3339()
);
Ok(())
}
fn next(name: Option<&str>, count: usize) -> Result<()> {
let store = open()?;
let (triggers, _) = store.list()?;
let tz = config_tz();
for t in triggers.iter().filter(|t| name.is_none_or(|n| t.name == n)) {
println!("{} [{}]", t.name, t.tz(tz));
let mut at = Utc::now();
for _ in 0..count {
let Some(fire) = t.next_fire(at, tz) else {
break;
};
println!(
" {} (in {})",
local(fire, t.tz(tz)),
human_gap(fire - Utc::now())
);
at = fire;
}
}
Ok(())
}
fn runs(name: Option<&str>, count: usize) -> Result<()> {
let store = open()?;
let all = store.runs()?;
let mine: Vec<&RunRecord> = all
.iter()
.filter(|r| name.is_none_or(|n| r.trigger == n))
.rev()
.take(count)
.collect();
if mine.is_empty() {
println!("no runs recorded yet");
return Ok(());
}
for r in mine.into_iter().rev() {
print_run(r);
}
Ok(())
}
fn print_run(r: &RunRecord) {
let mut line = format!(
"{} {:<20} {:<18}",
r.started_at.format("%Y-%m-%d %H:%M"),
r.trigger,
r.status.as_str()
);
if r.manual {
line.push_str(" manual");
}
if r.turns > 0 {
line.push_str(&format!(" {} turns", r.turns));
}
if r.staged > 0 {
line.push_str(&format!(" · {} staged", r.staged));
}
if r.blocked_sends > 0 {
line.push_str(&format!(" · {} blocked", r.blocked_sends));
}
if let Some(cause) = r.stop_cause {
line.push_str(&format!(" · {}", cause.describe()));
}
if let Some(c) = r.cost_usd {
line.push_str(&format!(" · ${c:.3}"));
}
if let Some(s) = &r.session_id {
line.push_str(&format!(" · session {s}"));
}
println!("{line}");
if let Some(e) = &r.error {
println!(" error: {e}");
} else if !r.summary.is_empty() {
println!(" {}", r.summary);
}
if let Some(e) = &r.notify_error {
println!(" notify: {e}");
}
}
fn add(global: &GlobalOpts, a: AddArgs) -> Result<()> {
Trigger::valid_name(&a.name)?;
let store = open()?;
anyhow::ensure!(
a.force || !store.exists(&a.name),
"trigger `{}` already exists (use --force to overwrite, or `mecha trigger edit {}`)",
a.name,
a.name
);
let schedule = a.schedule.parse()?;
let prompt = setup::read_maybe_file(&a.prompt)?;
let mut t = Trigger::new(&a.name, schedule, prompt);
t.description = a.description;
t.timezone = Some(
a.timezone
.as_deref()
.map(|n| n.parse::<Tz>().map(|tz| tz.to_string()))
.transpose()
.map_err(|_| anyhow::anyhow!("unknown timezone `{}`", a.timezone.unwrap_or_default()))?
.or_else(|| config_tz().map(|tz| tz.to_string()))
.unwrap_or_else(|| "UTC".to_string()),
);
t.permission_mode = if global.yes {
PermissionMode::Allow
} else {
PermissionMode::ReadOnly
};
t.workspace = match &global.workspace {
Some(w) => Some(
w.canonicalize()
.with_context(|| format!("workspace {} does not exist", w.display()))?,
),
None => Some(mecha_core::work::ensure(&a.name)?),
};
t.tools = global.tools.clone();
t.no_mcp = global.no_mcp;
t.max_turns = global.max_turns;
t.max_output_tokens = global.max_output_tokens;
t.max_cost_usd = global.max_cost;
t.timeout = a.timeout;
if let Some(c) = &a.catch_up {
t.catch_up = c.parse::<CatchUp>()?;
}
t.notify = a.notify;
t.enabled = !a.disabled;
t.provider = global.provider.clone();
t.model = global.model.clone();
check_cost_cap(&t)?;
store.save(&t)?;
println!("wrote {}", store.path_of(&t.name).display());
println!(
"permission {} · timeout {} · catch up {}",
match t.permission_mode {
PermissionMode::Allow => "allow (this run may write and execute unattended)",
PermissionMode::ReadOnly => "read-only (outbox drafts still stage)",
PermissionMode::Ask => "ask — nothing is watching, so this denies writes",
},
mecha_core::trigger::render_duration(t.timeout_duration()),
t.catch_up,
);
let tz = t.tz(config_tz());
match t.next_fire(Utc::now(), config_tz()) {
Some(next) => println!(
"first fire {} (in {})",
local(next, tz),
human_gap(next - Utc::now())
),
None => println!("warning: this schedule never fires"),
}
if t.enabled {
println!("nothing runs it yet — start `mecha trigger daemon`, or point a timer at `mecha trigger tick`");
}
Ok(())
}
fn check_cost_cap(t: &Trigger) -> Result<()> {
let Some(cap) = t.max_cost_usd else {
return Ok(());
};
let cfg = mecha_core::config::Config::load_global()?;
let (name, provider) = cfg.provider(t.provider.as_deref())?;
anyhow::ensure!(
provider.pricing().is_some(),
"trigger `{}` sets max_cost_usd = {cap}, but provider `{name}` has no \
input_price_per_mtok/output_price_per_mtok configured — the cap would never \
fire. Configure the prices, or drop the cap and bound the run with \
max_turns/max_output_tokens instead.",
t.name
);
Ok(())
}
fn edit(name: &str) -> Result<()> {
let store = open()?;
let path = store.path_of(name);
anyhow::ensure!(path.exists(), "no trigger named `{name}`");
let edited = crate::editor::edit_text(&std::fs::read_to_string(&path)?, "toml")?;
let mut parsed: Trigger = toml::from_str(&edited).context("the edited file does not parse")?;
parsed.name = name.to_string();
store.save(&parsed)?;
println!("saved {}", path.display());
Ok(())
}
fn set_enabled(name: &str, enabled: bool) -> Result<()> {
let store = open()?;
let mut t = store.get(name)?;
t.enabled = enabled;
store.save(&t)?;
println!("{} {}", if enabled { "enabled" } else { "disabled" }, name);
Ok(())
}
async fn tick(
global: &GlobalOpts,
dry_run: bool,
stop: Option<&CancellationToken>,
) -> Result<usize> {
let store = open()?;
let (triggers, problems) = store.list()?;
for p in &problems {
eprintln!("mecha: unreadable trigger — {p}");
}
let tz = config_tz();
let now = Utc::now();
let last_slots = store.last_slots()?;
let mut fired = 0;
for t in &triggers {
match t.due(last_slots.get(&t.name).copied(), now, tz) {
Due::Now { slot } => {
if dry_run {
println!(
"{:<20} would fire for slot {}",
t.name,
local(slot, t.tz(tz))
);
continue;
}
let Some(_claim) = store.try_claim(&t.name)? else {
let mut rec = RunRecord::started(&t.name, Some(slot), false);
rec.status = RunStatus::SkippedOverlap;
rec.finished_at = Some(Utc::now());
rec.error = Some("a previous run of this trigger is still going".into());
store.append_run(&rec)?;
eprintln!(
"mecha: {} skipped — the previous run is still going",
t.name
);
continue;
};
fire(global, &store, t, Some(slot), false, stop).await?;
fired += 1;
}
Due::Stale { slot, age } => {
if dry_run {
println!(
"{:<20} would skip slot {} ({} old, past catch_up = {})",
t.name,
local(slot, t.tz(tz)),
human_gap(age),
t.catch_up
);
continue;
}
let mut rec = RunRecord::started(&t.name, Some(slot), false);
rec.status = RunStatus::SkippedStale;
rec.finished_at = Some(Utc::now());
rec.error = Some(format!(
"missed by {}, past catch_up = {}",
human_gap(age),
t.catch_up
));
store.append_run(&rec)?;
}
Due::Not { next } if dry_run => {
let when = next
.map(|n| format!("in {}", human_gap(n - now)))
.unwrap_or_else(|| "never".into());
println!("{:<20} not due ({when})", t.name);
}
Due::Not { .. } | Due::Disabled => {}
}
}
Ok(fired)
}
async fn daemon(global: &GlobalOpts) -> Result<()> {
let store = open()?;
let (triggers, problems) = store.list()?;
for p in &problems {
eprintln!("mecha: unreadable trigger — {p}");
}
println!(
"mecha trigger daemon · {} trigger(s), {} enabled · ticking every minute",
triggers.len(),
triggers.iter().filter(|t| t.enabled).count()
);
let _ = std::io::stdout().flush();
let stop = CancellationToken::new();
{
let stop = stop.clone();
tokio::spawn(async move {
let mut term =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(s) => s,
Err(_) => return,
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
eprintln!("\nstopping — any run in flight stops at its next safe point");
stop.cancel();
});
}
loop {
if stop.is_cancelled() {
return Ok(());
}
if let Err(e) = tick(global, false, Some(&stop)).await {
eprintln!("mecha: tick failed — {e:#}");
}
tokio::select! {
_ = stop.cancelled() => return Ok(()),
_ = tokio::time::sleep(until_next_minute()) => {}
}
}
}
fn until_next_minute() -> std::time::Duration {
let now = Utc::now();
let secs = 60 - (now.timestamp() % 60);
std::time::Duration::from_secs(secs.clamp(1, 60) as u64)
}
async fn run_one(global: &GlobalOpts, name: &str) -> Result<()> {
let store = open()?;
let t = store.get(name)?;
let Some(_claim) = store.try_claim(name)? else {
anyhow::bail!("`{name}` is already running");
};
fire(global, &store, &t, None, true, None).await
}
const UNATTENDED: &str = "\
You are running unattended, on a schedule, with nobody watching. Three things \
follow. There is no one to answer a question, so make the reasonable \
assumption and say plainly what you assumed rather than stopping to ask. \
Anything that would leave this machine is staged as a draft for the user to \
review later — report it as a draft awaiting their release, never as sent or \
done. And your answer will be read later, out of context, by someone who has \
not seen this conversation: lead with what they need to know, and keep it \
short enough to read on a phone.";
async fn fire(
global: &GlobalOpts,
store: &TriggerStore,
t: &Trigger,
slot: Option<DateTime<Utc>>,
manual: bool,
stop: Option<&CancellationToken>,
) -> Result<()> {
let mut record = RunRecord::started(&t.name, slot, manual);
eprintln!("mecha: firing `{}`", t.name);
let _ = store.mark_running(&t.name, slot);
match run_agent(global, t, &mut record, stop).await {
Ok(text) => {
record.status = RunStatus::Ok;
record.summary = first_line(&text);
record.notify_error = notify(t, &workspace_of(t), &text);
}
Err(e) => {
record.status = RunStatus::Error;
record.error = Some(format!("{e:#}"));
eprintln!("mecha: trigger `{}` failed — {e:#}", t.name);
}
}
record.finished_at = Some(Utc::now());
store.clear_running(&t.name);
store.append_run(&record)?;
Ok(())
}
async fn run_agent(
global: &GlobalOpts,
t: &Trigger,
record: &mut RunRecord,
stop: Option<&CancellationToken>,
) -> Result<String> {
check_cost_cap(t)?;
let cfg = mecha_core::config::Config::load_global()?;
let base = cfg.agent.resolve_system_prompt()?.unwrap_or_default();
let system = if base.is_empty() {
UNATTENDED.to_string()
} else {
format!("{base}\n\n{UNATTENDED}")
};
let workspace = match &t.workspace {
Some(w) => w.clone(),
None => mecha_core::work::ensure(&t.name)?,
};
let opts = GlobalOpts {
provider: t.provider.clone().or_else(|| global.provider.clone()),
model: t.model.clone().or_else(|| global.model.clone()),
system: Some(system),
workspace: Some(workspace),
yes: t.permission_mode == mecha_core::config::PermissionMode::Allow,
read_only: t.permission_mode == mecha_core::config::PermissionMode::ReadOnly,
max_turns: t.max_turns,
max_output_tokens: t.max_output_tokens,
max_cost: t.max_cost_usd,
tools: t.tools.clone(),
no_mcp: t.no_mcp,
global_config_only: true,
..GlobalOpts::default()
};
let prepared = setup::prepare(&opts, false).await?;
let session = Session::create(
&Session::default_dir()?,
SessionMeta {
id: Session::new_id(),
created_at: Utc::now(),
provider: prepared.provider_name.clone(),
model: prepared.model.clone(),
workspace: prepared.workspace.clone(),
title: Some(format!("trigger: {}", t.name)),
},
)?;
record.session_id = Some(session.meta.id.clone());
session.append(&Record::Config(RunConfig::of(
&prepared.agent,
&prepared.config,
&prepared.provider_name,
)))?;
if let Some(route) = &prepared.agent.context().outbox {
route.set_session_id(&session.meta.id);
}
if let Some(mb) = &prepared.mailbox {
mb.attach(&t.name, &session.meta.id);
}
let mut convo = Conversation::new();
let user = Message::user(&t.prompt);
convo.push(user.clone());
session.append(&Record::Message(user))?;
let recorded = convo.messages.clone();
let token = stop.map(CancellationToken::child_token).unwrap_or_default();
let cx = RunContext::clone(prepared.agent.context()).with_cancel(token.clone());
let limit = t
.timeout_duration()
.to_std()
.unwrap_or(std::time::Duration::from_secs(1200));
let timer = {
let token = token.clone();
tokio::spawn(async move {
tokio::select! {
_ = tokio::time::sleep(limit) => token.cancel(),
_ = token.cancelled() => {}
}
})
};
let canceller = {
let token = token.clone();
let store = TriggerStore::open_default()?;
let name = t.name.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = token.cancelled() => return,
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
if store.cancel_requested(&name) {
eprintln!("mecha: trigger `{name}` cancelled by request");
token.cancel();
return;
}
}
}
}
})
};
let outcome = prepared.agent.run_in(&cx, &mut convo, None).await;
token.cancel();
let _ = timer.await;
let _ = canceller.await;
session.record_run(&recorded, &convo)?;
session.append(&Record::Taint(convo.taint))?;
if let Some(mb) = &prepared.mailbox {
mb.detach(&session.meta.id);
}
let outcome = match outcome {
Ok(o) => o,
Err(e) => {
let cx = prepared.agent.context();
cx.hooks
.session_end(&session.meta.id, &session.path, &cx.tools.workspace)
.await;
return Err(e);
}
};
session.append(&Record::Summary {
usage: outcome.usage.clone(),
turns: outcome.turns,
})?;
record.turns = outcome.turns;
record.cost_usd = outcome.cost_usd;
record.blocked_sends = outcome.blocked_sends;
record.staged = outcome.tool_calls.iter().filter(|c| c.staged).count() as u32;
record.taint = outcome.taint;
record.stop_cause = (outcome.stop_cause != mecha_core::agent::StopCause::Completed)
.then_some(outcome.stop_cause);
let cx = prepared.agent.context();
cx.hooks
.session_end(&session.meta.id, &session.path, &cx.tools.workspace)
.await;
if outcome.stop_cause.is_early() {
eprintln!(
"mecha: trigger `{}` {} — the answer may be incomplete",
t.name,
outcome.stop_cause.describe()
);
}
Ok(outcome.text)
}
fn workspace_of(t: &Trigger) -> std::path::PathBuf {
t.workspace
.clone()
.or_else(|| mecha_core::work::ensure(&t.name).ok())
.unwrap_or_else(|| std::path::PathBuf::from("."))
}
fn notify(t: &Trigger, workspace: &std::path::Path, text: &str) -> Option<String> {
let command = t.notify.as_ref()?;
let spawned = std::process::Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(workspace)
.stdin(std::process::Stdio::piped())
.spawn();
let mut child = match spawned {
Ok(c) => c,
Err(e) => {
let failure = format!("failed to start: {e}");
eprintln!("mecha: notify command for `{}` {failure}", t.name);
return Some(failure);
}
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
match child.wait() {
Ok(status) if !status.success() => {
let hint = if status.code() == Some(127) {
" — 127 usually means a command was not found on PATH"
} else {
""
};
let failure = format!("exited {status}{hint}");
eprintln!("mecha: notify command for `{}` {failure}", t.name);
Some(failure)
}
Err(e) => {
let failure = format!("failed: {e}");
eprintln!("mecha: notify command for `{}` {failure}", t.name);
Some(failure)
}
_ => None,
}
}
fn local(at: DateTime<Utc>, tz: Tz) -> String {
at.with_timezone(&tz)
.format("%a %-d %b %H:%M %Z")
.to_string()
}
fn human_gap(d: chrono::Duration) -> String {
let secs = d.num_seconds().abs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}d {}h", secs / 86_400, (secs % 86_400) / 3600)
}
}
fn first_line(text: &str) -> String {
let line = text
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if line.chars().count() > 100 {
format!("{}…", line.chars().take(100).collect::<String>())
} else {
line.to_string()
}
}
fn indent(text: &str) -> String {
text.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
fn trigger_with_notify(command: &str) -> Trigger {
let mut t = Trigger::new("t", "0 7 * * *".parse().unwrap(), "p");
t.notify = Some(command.to_string());
t
}
#[test]
fn a_notify_that_could_not_run_is_reported_rather_than_swallowed() {
let dir = std::env::temp_dir();
let failure = notify(
&trigger_with_notify("definitely-not-a-real-command-3f9a"),
&dir,
"the answer",
)
.expect("a command that is not on PATH has to be reported");
assert!(failure.contains("127"), "{failure}");
assert!(
failure.contains("PATH"),
"the hint names the actual cause: {failure}"
);
let failure = notify(&trigger_with_notify("exit 3"), &dir, "x").unwrap();
assert!(failure.contains("exit"), "{failure}");
assert!(!failure.contains("PATH"), "{failure}");
assert!(notify(&trigger_with_notify("true"), &dir, "x").is_none());
assert!(notify(
&Trigger::new("t", "0 7 * * *".parse().unwrap(), "p"),
&dir,
"x"
)
.is_none());
}
#[test]
fn notify_writes_the_answer_into_the_runs_workspace() {
let workspace = std::env::temp_dir().join(format!("mecha-notify-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&workspace);
std::fs::create_dir_all(&workspace).unwrap();
assert!(notify(
&trigger_with_notify("cat > out.md"),
&workspace,
"the briefing"
)
.is_none());
assert_eq!(
std::fs::read_to_string(workspace.join("out.md")).unwrap(),
"the briefing"
);
let _ = std::fs::remove_dir_all(&workspace);
}
#[test]
fn the_cli_has_no_conflicting_flags() {
crate::Cli::command().debug_assert();
}
#[test]
fn the_unattended_preamble_says_the_three_things_that_change_the_answer() {
assert!(UNATTENDED.contains("no one to answer"));
assert!(UNATTENDED.contains("never as sent"));
assert!(UNATTENDED.contains("out of context"));
assert!(!UNATTENDED.contains("best judgment"));
}
#[test]
fn gaps_read_the_way_a_person_would_say_them() {
assert_eq!(human_gap(chrono::Duration::seconds(30)), "30s");
assert_eq!(human_gap(chrono::Duration::minutes(5)), "5m");
assert_eq!(human_gap(chrono::Duration::minutes(90)), "1h 30m");
assert_eq!(human_gap(chrono::Duration::hours(30)), "1d 6h");
}
#[test]
fn a_summary_is_one_line_and_bounded() {
assert_eq!(first_line("\n\nthe answer\nmore"), "the answer");
assert_eq!(first_line(&"x".repeat(200)).chars().count(), 101);
}
}