- main.rs: hold the gtk4::Application (RAII guard from app.hold()) across the idle_add_local_once save callback, so closing the capture window can no longer let the process quit before the note is actually written to disk - breadpad-shared/src/ai.rs: 10s timeout on the Ollama HTTP call (ureq had none, so a hung local endpoint could stall indefinitely) - breadpad-shared/src/calendar.rs: 15s timeout on the CalDAV reqwest client (applies to every CalDAV request made through it) - breadpad-shared/src/store.rs: exclusive flock (std::fs::File::lock, no new dependency needed at this Rust version) on a sidecar lock file around every read-modify-write span, guarding against breadpad/breadman/ reminder-fire processes racing each other on notes.jsonl - breadpad-shared/src/scheduler.rs: parse_next_from_rrule now handles FREQ=MONTHLY (previously fell into the catch-all None arm, so a monthly reminder fired once and never rescheduled); reachable today via breadman's free-text RRULE editor field - breadpad-shared/src/config.rs: Config::save() chmods breadpad.toml to 0600 after writing, since it can hold the CalDAV password in plaintext Pre-existing, unrelated test failure noted: theme::tests::css_defines_bg_color fails identically on the original commit (depends on this machine's live pywal cache) — confirmed via git stash, not touched.
502 lines
18 KiB
Rust
502 lines
18 KiB
Rust
use crate::types::Note;
|
|
use crate::util::local_naive_to_utc;
|
|
use anyhow::{Context, Result};
|
|
use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
|
|
use std::process::Command;
|
|
|
|
pub struct Scheduler;
|
|
|
|
impl Scheduler {
|
|
pub fn schedule(note: &Note) -> Result<()> {
|
|
let fire_time = note.effective_time().context("note has no scheduled time")?;
|
|
create_timer(¬e.id, fire_time)
|
|
}
|
|
|
|
pub fn cancel(note_id: &str) -> Result<()> {
|
|
let timer_name = timer_unit_name(note_id);
|
|
let service_name = service_unit_name(note_id);
|
|
stop_unit(&timer_name)?;
|
|
disable_unit(&timer_name)?;
|
|
stop_unit(&service_name)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn snooze(note: &mut Note, snooze_until: DateTime<Utc>) -> Result<()> {
|
|
Self::cancel(¬e.id).ok();
|
|
note.snoozed_until = Some(snooze_until);
|
|
create_timer(¬e.id, snooze_until)
|
|
}
|
|
|
|
pub fn fire(note: &Note, missed_grace_minutes: i64) -> bool {
|
|
let now = Utc::now();
|
|
if let Some(t) = note.effective_time() {
|
|
let diff = now.signed_duration_since(t);
|
|
if diff > Duration::minutes(missed_grace_minutes) {
|
|
tracing::info!("reminder {} missed ({}m ago), skipping", note.id, diff.num_minutes());
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
pub fn next_recurrence(note: &Note, default_morning: &str) -> Option<DateTime<Utc>> {
|
|
let rrule = note.rrule.as_ref()?;
|
|
parse_next_from_rrule(rrule.as_str(), default_morning)
|
|
}
|
|
}
|
|
|
|
fn timer_unit_name(id: &str) -> String {
|
|
format!("breadpad-reminder-{}.timer", id)
|
|
}
|
|
|
|
fn service_unit_name(id: &str) -> String {
|
|
format!("breadpad-reminder-{}.service", id)
|
|
}
|
|
|
|
fn create_timer(id: &str, fire_time: DateTime<Utc>) -> Result<()> {
|
|
// Convert to local time for systemd OnCalendar
|
|
let local: chrono::DateTime<Local> = fire_time.with_timezone(&Local);
|
|
let on_calendar = local.format("%Y-%m-%d %H:%M:%S").to_string();
|
|
|
|
let timer_name = timer_unit_name(id);
|
|
|
|
// Find the breadpad binary. Order of preference:
|
|
// 1. $BREADPAD_BIN override,
|
|
// 2. a `breadpad` next to the currently running executable,
|
|
// 3. standard install locations.
|
|
let breadpad_exe = std::env::var_os("BREADPAD_BIN")
|
|
.map(std::path::PathBuf::from)
|
|
.filter(|p| p.exists())
|
|
.or_else(|| {
|
|
std::env::current_exe()
|
|
.ok()
|
|
.and_then(|exe| exe.parent().map(|p| p.join("breadpad")))
|
|
.filter(|p| p.exists())
|
|
})
|
|
.or_else(|| {
|
|
let home_bin = dirs::home_dir().map(|h| h.join(".local/bin/breadpad"));
|
|
["/usr/local/bin/breadpad", "/usr/bin/breadpad"]
|
|
.iter()
|
|
.map(std::path::PathBuf::from)
|
|
.chain(home_bin)
|
|
.find(|p| p.exists())
|
|
})
|
|
.context("breadpad binary not found in $BREADPAD_BIN, alongside this executable, or in standard locations")?;
|
|
|
|
// Use systemd-run to create both service + timer as a transient unit
|
|
// Pass necessary environment variables for notifications to work
|
|
let mut cmd = Command::new("systemd-run");
|
|
cmd.arg("--user")
|
|
.arg("--unit")
|
|
.arg(timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
|
|
.arg("--timer-property")
|
|
.arg(format!("OnCalendar={}", on_calendar))
|
|
.arg("--timer-property")
|
|
.arg("Persistent=true");
|
|
|
|
// Pass DBUS and display environment variables so notify-send works
|
|
if let Ok(dbus) = std::env::var("DBUS_SESSION_BUS_ADDRESS") {
|
|
cmd.arg("--setenv").arg(format!("DBUS_SESSION_BUS_ADDRESS={}", dbus));
|
|
}
|
|
if let Ok(display) = std::env::var("DISPLAY") {
|
|
cmd.arg("--setenv").arg(format!("DISPLAY={}", display));
|
|
}
|
|
if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") {
|
|
cmd.arg("--setenv").arg(format!("WAYLAND_DISPLAY={}", wayland));
|
|
}
|
|
|
|
cmd.arg("--")
|
|
.arg(&breadpad_exe)
|
|
.arg("fire")
|
|
.arg(id);
|
|
|
|
let status = cmd.status().context("failed to run systemd-run")?;
|
|
|
|
if !status.success() {
|
|
anyhow::bail!("systemd-run failed for reminder {}", id);
|
|
}
|
|
|
|
tracing::info!("scheduled reminder {} at {} using {}", id, on_calendar, breadpad_exe.display());
|
|
Ok(())
|
|
}
|
|
|
|
fn stop_unit(unit: &str) -> Result<()> {
|
|
Command::new("systemctl")
|
|
.args(["--user", "stop", unit])
|
|
.status()
|
|
.context("systemctl stop")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn disable_unit(unit: &str) -> Result<()> {
|
|
Command::new("systemctl")
|
|
.args(["--user", "disable", "--now", unit])
|
|
.status()
|
|
.context("systemctl disable")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> Option<DateTime<Utc>> {
|
|
// (see tests module below for coverage)
|
|
// Parse RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0 etc.
|
|
// We extract FREQ, BYDAY, BYHOUR, BYMINUTE to compute next occurrence.
|
|
if rrule_str.trim().is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let parts: std::collections::HashMap<&str, &str> = rrule_str
|
|
.trim_start_matches("RRULE:")
|
|
.split(';')
|
|
.filter_map(|part| {
|
|
let mut kv = part.splitn(2, '=');
|
|
Some((kv.next()?, kv.next()?))
|
|
})
|
|
.collect();
|
|
|
|
let freq = parts.get("FREQ")?;
|
|
let freq = *freq;
|
|
|
|
let (default_h, default_m): (u32, u32) = {
|
|
let mut it = default_morning.splitn(2, ':');
|
|
let h = it.next().and_then(|s| s.parse().ok()).unwrap_or(8);
|
|
let m = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
(h, m)
|
|
};
|
|
let hour: u32 = parts.get("BYHOUR").and_then(|v| v.parse().ok()).unwrap_or(default_h);
|
|
let minute: u32 = parts.get("BYMINUTE").and_then(|v| v.parse().ok()).unwrap_or(default_m);
|
|
|
|
let now = Local::now();
|
|
let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?;
|
|
|
|
match freq {
|
|
"DAILY" => {
|
|
let today = now.date_naive().and_time(fire_time);
|
|
let naive = if now.naive_local() < today {
|
|
today
|
|
} else {
|
|
(now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
|
|
};
|
|
return Some(local_naive_to_utc(naive));
|
|
}
|
|
"WEEKLY" => {
|
|
use chrono::Datelike;
|
|
let byday = parts.get("BYDAY").unwrap_or(&"MO");
|
|
let target_wd = match *byday {
|
|
"MO" => chrono::Weekday::Mon,
|
|
"TU" => chrono::Weekday::Tue,
|
|
"WE" => chrono::Weekday::Wed,
|
|
"TH" => chrono::Weekday::Thu,
|
|
"FR" => chrono::Weekday::Fri,
|
|
"SA" => chrono::Weekday::Sat,
|
|
_ => chrono::Weekday::Sun,
|
|
};
|
|
let days_ahead = (target_wd.num_days_from_monday() as i64
|
|
- now.weekday().num_days_from_monday() as i64)
|
|
.rem_euclid(7);
|
|
let days_ahead = if days_ahead == 0 {
|
|
if now.time() < fire_time {
|
|
0
|
|
} else {
|
|
7
|
|
}
|
|
} else {
|
|
days_ahead
|
|
};
|
|
let target_date =
|
|
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
|
|
return Some(local_naive_to_utc(target_date));
|
|
}
|
|
"MONTHLY" => {
|
|
use chrono::Datelike;
|
|
// BYMONTHDAY isn't guaranteed to be present — breadman's note
|
|
// editor lets a user type an arbitrary RRULE by hand, and
|
|
// "FREQ=MONTHLY" alone is a perfectly valid (if under-specified)
|
|
// one. Fall back to today's day-of-month, mirroring how WEEKLY
|
|
// above defaults BYDAY to "MO" when absent.
|
|
let day: u32 = parts
|
|
.get("BYMONTHDAY")
|
|
.and_then(|v| v.parse().ok())
|
|
.filter(|d: &u32| (1..=31).contains(d))
|
|
.unwrap_or_else(|| now.day());
|
|
|
|
let mut year = now.year();
|
|
let mut month = now.month();
|
|
|
|
// Walk forward month by month for the next calendar date that
|
|
// (a) actually has this day-of-month (a 31st skips e.g. April)
|
|
// and (b) is still in the future. Bounded to 24 months as a
|
|
// defensive cap — every valid day (1-31) recurs well within a
|
|
// year, so this should never come close to firing.
|
|
for _ in 0..24 {
|
|
if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) {
|
|
let candidate = date.and_time(fire_time);
|
|
if now.naive_local() < candidate {
|
|
return Some(local_naive_to_utc(candidate));
|
|
}
|
|
}
|
|
month += 1;
|
|
if month > 12 {
|
|
month = 1;
|
|
year += 1;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::{Note, NoteType, RecurrenceRule};
|
|
use chrono::{Datelike, Local, Timelike, Utc};
|
|
|
|
fn reminder(id: &str) -> Note {
|
|
let mut n = Note::new("test reminder".into(), NoteType::Reminder, None);
|
|
// Override auto-generated id for readable test names
|
|
n.id = id.to_string();
|
|
n
|
|
}
|
|
|
|
// ---- Scheduler::fire ----
|
|
|
|
#[test]
|
|
fn fire_note_without_time_always_fires() {
|
|
let note = reminder("no-time");
|
|
// effective_time() is None → fire returns true (no time = no missed check)
|
|
assert!(Scheduler::fire(¬e, 60));
|
|
}
|
|
|
|
#[test]
|
|
fn fire_future_reminder_fires() {
|
|
let mut note = reminder("future");
|
|
note.time = Some(Utc::now() + Duration::minutes(10));
|
|
assert!(Scheduler::fire(¬e, 60));
|
|
}
|
|
|
|
#[test]
|
|
fn fire_recent_past_reminder_fires() {
|
|
let mut note = reminder("recent");
|
|
note.time = Some(Utc::now() - Duration::minutes(5));
|
|
// 5 min ago, grace = 60 min → should fire
|
|
assert!(Scheduler::fire(¬e, 60));
|
|
}
|
|
|
|
#[test]
|
|
fn fire_exactly_at_grace_boundary_fires() {
|
|
let mut note = reminder("boundary");
|
|
// Use 59 min (well inside the 60-min grace) to avoid a race with wall time
|
|
note.time = Some(Utc::now() - Duration::minutes(59));
|
|
assert!(Scheduler::fire(¬e, 60));
|
|
}
|
|
|
|
#[test]
|
|
fn fire_missed_reminder_beyond_grace_skips() {
|
|
let mut note = reminder("missed");
|
|
note.time = Some(Utc::now() - Duration::minutes(90));
|
|
// 90 min ago, grace = 60 → should NOT fire
|
|
assert!(!Scheduler::fire(¬e, 60));
|
|
}
|
|
|
|
#[test]
|
|
fn fire_uses_snoozed_until_if_set() {
|
|
let mut note = reminder("snoozed");
|
|
note.time = Some(Utc::now() - Duration::hours(5)); // original would be missed
|
|
note.snoozed_until = Some(Utc::now() - Duration::minutes(5)); // snooze is recent
|
|
// effective_time = snoozed_until (5 min ago), grace = 60 → fires
|
|
assert!(Scheduler::fire(¬e, 60));
|
|
}
|
|
|
|
#[test]
|
|
fn fire_zero_grace_only_fires_future() {
|
|
let mut future = reminder("zero-future");
|
|
future.time = Some(Utc::now() + Duration::seconds(1));
|
|
assert!(Scheduler::fire(&future, 0));
|
|
|
|
let mut past = reminder("zero-past");
|
|
past.time = Some(Utc::now() - Duration::seconds(1));
|
|
assert!(!Scheduler::fire(&past, 0));
|
|
}
|
|
|
|
// ---- Scheduler::next_recurrence ----
|
|
|
|
#[test]
|
|
fn next_recurrence_none_without_rrule() {
|
|
let note = reminder("no-rrule");
|
|
assert!(Scheduler::next_recurrence(¬e, "08:00").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn next_recurrence_daily_in_future() {
|
|
let mut note = reminder("daily");
|
|
note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=DAILY;BYHOUR=8;BYMINUTE=0;BYSECOND=0"));
|
|
let t = Scheduler::next_recurrence(¬e, "08:00").unwrap();
|
|
assert!(t >= Utc::now());
|
|
}
|
|
|
|
#[test]
|
|
fn next_recurrence_weekly_is_correct_weekday() {
|
|
let mut note = reminder("weekly-mon");
|
|
note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0"));
|
|
let t = Scheduler::next_recurrence(¬e, "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.weekday(), chrono::Weekday::Mon);
|
|
}
|
|
|
|
// ---- parse_next_from_rrule ----
|
|
|
|
#[test]
|
|
fn daily_rrule_next_is_in_future() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=DAILY;BYHOUR=14;BYMINUTE=0;BYSECOND=0", "08:00");
|
|
assert!(t.is_some());
|
|
assert!(t.unwrap() >= Utc::now());
|
|
}
|
|
|
|
#[test]
|
|
fn daily_rrule_correct_hour() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=DAILY;BYHOUR=14;BYMINUTE=30;BYSECOND=0", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.hour(), 14);
|
|
assert_eq!(local.minute(), 30);
|
|
}
|
|
|
|
#[test]
|
|
fn daily_rrule_defaults_hour_from_morning() {
|
|
// No BYHOUR in rrule — should fall back to default_morning
|
|
let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "07:15").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.hour(), 7);
|
|
assert_eq!(local.minute(), 15);
|
|
}
|
|
|
|
#[test]
|
|
fn weekly_monday_is_monday() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.weekday(), chrono::Weekday::Mon);
|
|
assert!(t >= Utc::now());
|
|
}
|
|
|
|
#[test]
|
|
fn weekly_friday_is_friday() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=FR;BYHOUR=12;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.weekday(), chrono::Weekday::Fri);
|
|
}
|
|
|
|
#[test]
|
|
fn weekly_wednesday_correct_hour() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=WE;BYHOUR=15;BYMINUTE=45;BYSECOND=0", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.hour(), 15);
|
|
assert_eq!(local.minute(), 45);
|
|
}
|
|
|
|
#[test]
|
|
fn weekly_saturday_is_saturday() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SA;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.weekday(), chrono::Weekday::Sat);
|
|
}
|
|
|
|
#[test]
|
|
fn weekly_tuesday_is_tuesday() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TU;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.weekday(), chrono::Weekday::Tue);
|
|
}
|
|
|
|
#[test]
|
|
fn weekly_thursday_is_thursday() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TH;BYHOUR=11;BYMINUTE=30;BYSECOND=0", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.weekday(), chrono::Weekday::Thu);
|
|
assert_eq!(local.minute(), 30);
|
|
}
|
|
|
|
#[test]
|
|
fn weekly_sunday_is_sunday() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SU;BYHOUR=19;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.weekday(), chrono::Weekday::Sun);
|
|
}
|
|
|
|
#[test]
|
|
fn weekly_unknown_byday_falls_back_to_sunday() {
|
|
// The match arm `_ => Weekday::Sun` handles unrecognised BYDAY values
|
|
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=XX;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.weekday(), chrono::Weekday::Sun);
|
|
}
|
|
|
|
#[test]
|
|
fn daily_without_byhour_uses_default_morning() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "06:45").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.hour(), 6);
|
|
assert_eq!(local.minute(), 45);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_freq_returns_none() {
|
|
assert!(parse_next_from_rrule("RRULE:FREQ=YEARLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn monthly_reschedules_instead_of_returning_none() {
|
|
// This used to be the exact bug: MONTHLY fell into the `_ => None`
|
|
// arm, so a monthly reminder fired once and never rescheduled.
|
|
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=15;BYHOUR=9;BYMINUTE=0", "08:00");
|
|
assert!(t.is_some(), "MONTHLY must produce a next occurrence, not None");
|
|
let local: chrono::DateTime<Local> = t.unwrap().into();
|
|
assert_eq!(local.day(), 15);
|
|
assert_eq!(local.hour(), 9);
|
|
assert_eq!(local.minute(), 0);
|
|
assert!(local > Local::now());
|
|
}
|
|
|
|
#[test]
|
|
fn monthly_without_bymonthday_uses_todays_day_of_month() {
|
|
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=23;BYMINUTE=59", "08:00").unwrap();
|
|
let local: chrono::DateTime<Local> = t.into();
|
|
assert_eq!(local.day(), Local::now().day());
|
|
}
|
|
|
|
#[test]
|
|
fn monthly_on_the_31st_skips_shorter_months() {
|
|
// Every candidate month/day combination this walks must actually
|
|
// exist (from_ymd_opt returning None for e.g. April 31 is skipped
|
|
// internally) — this mostly guards against a panic/infinite loop
|
|
// regression rather than a specific date, since "next Feb 31" et al
|
|
// must fall through to a month that really has a 31st.
|
|
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0", "08:00");
|
|
assert!(t.is_some());
|
|
let local: chrono::DateTime<Local> = t.unwrap().into();
|
|
assert_eq!(local.day(), 31);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_rrule_string_returns_none() {
|
|
assert!(parse_next_from_rrule("", "08:00").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rrule_without_rrule_prefix_still_parses() {
|
|
// trim_start_matches("RRULE:") handles the prefix; without it the key would be "RRULE:FREQ" which won't match
|
|
// just verify we don't panic
|
|
let _ = parse_next_from_rrule("FREQ=DAILY;BYHOUR=8;BYMINUTE=0", "08:00");
|
|
}
|
|
|
|
// ---- unit name helpers ----
|
|
|
|
#[test]
|
|
fn timer_unit_name_format() {
|
|
assert_eq!(timer_unit_name("abc123"), "breadpad-reminder-abc123.timer");
|
|
}
|
|
|
|
#[test]
|
|
fn service_unit_name_format() {
|
|
assert_eq!(service_unit_name("abc123"), "breadpad-reminder-abc123.service");
|
|
}
|
|
}
|