use anyhow::{Context, Result};
use clap::{Subcommand, ValueEnum};
use pond::output::{dim, line, line_err, paint, red};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(crate) enum ScheduleEvery {
#[value(name = "5m")]
M5,
#[value(name = "15m")]
M15,
#[value(name = "1h")]
H1,
#[value(name = "6h")]
H6,
#[value(name = "1d")]
D1,
}
impl ScheduleEvery {
pub(crate) fn secs(self) -> u32 {
match self {
Self::M5 => 300,
Self::M15 => 900,
Self::H1 => 3_600,
Self::H6 => 21_600,
Self::D1 => 86_400,
}
}
pub(crate) fn label(self) -> &'static str {
match self {
Self::M5 => "5m",
Self::M15 => "15m",
Self::H1 => "1h",
Self::H6 => "6h",
Self::D1 => "1d",
}
}
fn from_secs(secs: u32) -> Option<Self> {
Self::value_variants()
.iter()
.copied()
.find(|every| every.secs() == secs)
}
}
#[derive(Debug, Subcommand)]
pub(crate) enum ScheduleCmd {
#[command(after_long_help = "Examples:
pond schedule start every 5 minutes (the default)
pond schedule start --every 1h
pond schedule start --every 1d")]
Start {
#[arg(long, value_enum, default_value_t = ScheduleEvery::M5)]
every: ScheduleEvery,
},
Stop,
Status,
Logs {
#[arg(long, default_value_t = 50)]
lines: usize,
},
}
#[derive(Default)]
pub(crate) struct ScheduleSnapshot {
pub line: String,
pub active: bool,
pub backend: Option<&'static str>,
pub every: Option<ScheduleEvery>,
pub problem: Option<String>,
pub next_run: Option<chrono::DateTime<chrono::Utc>>,
}
enum State {
Active {
backend: &'static str,
every: Option<ScheduleEvery>,
problem: Option<String>,
next_run: Option<chrono::DateTime<chrono::Utc>>,
},
Inactive,
}
use State::{Active, Inactive};
impl State {
fn active(backend: &'static str, every: Option<ScheduleEvery>) -> Self {
Active {
backend,
every,
problem: None,
next_run: None,
}
}
}
fn render_state(state: &State) -> String {
match state {
Active {
backend,
every,
problem,
..
} => {
let cadence = every
.map(|every| format!(", every {}", every.label()))
.unwrap_or_default();
match problem {
Some(problem) => format!(
"{} {} ({backend}{cadence}) - {problem}",
paint("schedule", dim()),
paint("broken", red()),
),
None => format!("{} active ({backend}{cadence})", paint("schedule", dim())),
}
}
Inactive => format!(
"{} not configured - run `pond schedule start` to sync automatically",
paint("schedule", dim()),
),
}
}
pub(crate) fn log_path() -> PathBuf {
crate::syncstate::pond_state_dir().join("sync.log")
}
pub(crate) fn status_snapshot() -> ScheduleSnapshot {
match platform_probe() {
Ok(state) => {
let (active, backend, every, problem, next_run) = match &state {
Active {
backend,
every,
problem,
next_run,
} => (true, Some(*backend), *every, problem.clone(), *next_run),
Inactive => (false, None, None, None, None),
};
ScheduleSnapshot {
line: render_state(&state),
active,
backend,
every,
problem,
next_run,
}
}
Err(_) => ScheduleSnapshot {
line: format!(
"{} unknown (scheduler probe failed)",
paint("schedule", dim())
),
active: false,
backend: None,
every: None,
problem: None,
next_run: None,
},
}
}
pub(crate) fn run(command: ScheduleCmd, config: Option<PathBuf>) -> Result<()> {
match command {
ScheduleCmd::Start { every } => platform_start(every, &config_file(config)),
ScheduleCmd::Stop => platform_stop(),
ScheduleCmd::Status => {
let state = platform_probe()?;
line(&render_state(&state))?;
match &state {
Active { problem, .. } => {
if problem.is_none() {
line(&format!(
"{} {} (pond schedule logs)",
paint("logs", dim()),
crate::config::display(&crate::config::url_for_path(log_path())?),
))?;
}
Ok(())
}
Inactive => std::process::exit(1),
}
}
ScheduleCmd::Logs { lines } => logs(lines),
}
}
pub(crate) fn logs(lines: usize) -> Result<()> {
#[cfg(target_os = "linux")]
if unix::systemd_timer_enabled() {
let status = std::process::Command::new("journalctl")
.args([
"--user",
"-u",
"pond-sync.service",
"-n",
&lines.to_string(),
"--no-pager",
])
.status()
.context("failed to run journalctl")?;
if !status.success() {
anyhow::bail!("journalctl exited {status}");
}
return Ok(());
}
let path = log_path();
line_err(&paint(
&format!(
"log file: {}",
crate::config::display(&crate::config::url_for_path(&path)?)
),
dim(),
))?;
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
line("(no log yet - the first scheduled run hasn't happened)")?;
return Ok(());
}
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
let all: Vec<&str> = text.lines().collect();
let tail = all.len().saturating_sub(lines);
for entry in &all[tail..] {
line(entry)?;
}
Ok(())
}
pub(crate) fn config_file(explicit: Option<PathBuf>) -> PathBuf {
let path = crate::config_path(explicit);
std::path::absolute(&path).unwrap_or(path)
}
pub(crate) const CONFIG_FILE_SOURCES: &str =
"--config-file or POND_CONFIG_FILE, falling back to $XDG_CONFIG_HOME/pond/config.toml";
pub(crate) const STATE_DIR_SOURCES: &str = "XDG_STATE_HOME, falling back to $HOME/.local/state";
pub(crate) fn start(every: ScheduleEvery, explicit: PathBuf) -> Result<()> {
platform_start(every, &config_file(Some(explicit)))
}
pub(crate) fn reject_unembeddable(what: &str, path: &Path, sources: &str) -> Result<()> {
let text = path.display().to_string();
if text.contains(['<', '>', '&', '"', '%', '\n', '\r']) {
anyhow::bail!(
"{what} {text:?} contains a character (< > & \" % or a newline) that cannot be \
embedded in a scheduler registration; it resolves from {sources} - use a \
simpler absolute path and re-run `pond schedule start`"
);
}
Ok(())
}
#[cfg(windows)]
fn platform_probe() -> Result<State> {
windows::probe()
}
#[cfg(unix)]
fn platform_probe() -> Result<State> {
unix::probe()
}
#[cfg(not(any(unix, windows)))]
fn platform_probe() -> Result<State> {
Ok(Inactive)
}
#[cfg(windows)]
fn platform_start(every: ScheduleEvery, config_file: &Path) -> Result<()> {
windows::start(every, config_file)
}
#[cfg(unix)]
fn platform_start(every: ScheduleEvery, config_file: &Path) -> Result<()> {
unix::start(every, config_file)
}
#[cfg(not(any(unix, windows)))]
fn platform_start(_every: ScheduleEvery, _config_file: &Path) -> Result<()> {
anyhow::bail!("pond schedule is not supported on this platform yet")
}
#[cfg(windows)]
fn platform_stop() -> Result<()> {
windows::stop()
}
#[cfg(unix)]
fn platform_stop() -> Result<()> {
unix::stop()
}
#[cfg(not(any(unix, windows)))]
fn platform_stop() -> Result<()> {
anyhow::bail!("pond schedule is not supported on this platform yet")
}
#[cfg(unix)]
mod unix {
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use clap::ValueEnum;
use super::{ScheduleEvery, State};
use State::{Active, Inactive};
const LAUNCHD_LABEL: &str = "sh.pond.sync";
const CRON_FENCE_BEGIN: &str = "# BEGIN POND SYNC (maintained by pond; do not edit)";
const CRON_FENCE_END: &str = "# END POND SYNC";
pub(super) fn probe() -> Result<State> {
match std::env::consts::OS {
"macos" => probe_launchd(),
"linux" => {
if systemd_timer_enabled() {
return Ok(Active {
backend: "systemd",
every: read_systemd_interval(),
problem: systemd_timer_show()
.as_deref()
.and_then(systemd_timer_problem),
next_run: systemd_next_elapse(),
});
}
if let Some(entry) = read_cron_fence_entry()? {
return Ok(State::active("cron", cron_entry_interval(&entry)));
}
Ok(Inactive)
}
_ => Ok(Inactive),
}
}
pub(super) fn start(every: ScheduleEvery, config_file: &Path) -> Result<()> {
let bin = pond_bin();
let log = super::log_path();
if let Some(parent) = log.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let state = crate::syncstate::state_root();
super::reject_unembeddable("state dir", &state, super::STATE_DIR_SOURCES)?;
super::reject_unembeddable("config file", config_file, super::CONFIG_FILE_SOURCES)?;
match std::env::consts::OS {
"macos" => start_launchd(&bin, every, &log, &state, config_file),
"linux" => {
if systemd_user_available() {
remove_cron_fence()?;
start_systemd(&bin, every, &state, config_file)
} else {
stop_systemd()?;
start_cron(&bin, every, &log, &state, config_file)
}
}
other => bail!("pond schedule is not supported on {other} yet"),
}
}
pub(super) fn stop() -> Result<()> {
let removed = match std::env::consts::OS {
"macos" => stop_launchd()?,
"linux" => {
let systemd = stop_systemd()?;
let cron = remove_cron_fence()?;
systemd || cron
}
other => bail!("pond schedule is not supported on {other} yet"),
};
if removed {
pond::output::line("schedule removed")?;
} else {
pond::output::line("nothing was scheduled")?;
}
Ok(())
}
pub(super) fn systemd_timer_enabled() -> bool {
Command::new("systemctl")
.args(["--user", "is-enabled", "pond-sync.timer"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn pond_bin() -> PathBuf {
crate::find_on_path("pond")
.unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| PathBuf::from("pond")))
}
fn plist_path() -> Result<PathBuf> {
let home = std::env::var_os("HOME").context("HOME is not set")?;
Ok(PathBuf::from(home)
.join("Library/LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist")))
}
fn plist_body(
bin: &Path,
every: ScheduleEvery,
log: &Path,
state: &Path,
config_file: &Path,
) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- created and maintained by pond; edits may be replaced -->
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LAUNCHD_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{bin}</string>
<string>sync</string>
<string>-q</string>
<string>--no-wait</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>XDG_STATE_HOME</key>
<string>{state}</string>
<key>POND_CONFIG_FILE</key>
<string>{config_file}</string>
</dict>
<key>StartInterval</key>
<integer>{secs}</integer>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
"#,
bin = bin.display(),
secs = every.secs(),
log = log.display(),
state = state.display(),
config_file = config_file.display(),
)
}
fn launchd_registered(uid: &str) -> bool {
Command::new("launchctl")
.args(["print", &format!("gui/{uid}/{LAUNCHD_LABEL}")])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn start_launchd(
bin: &Path,
every: ScheduleEvery,
log: &Path,
state: &Path,
config_file: &Path,
) -> Result<()> {
let plist = plist_path()?;
let body = plist_body(bin, every, log, state, config_file);
let uid = current_uid()?;
let unchanged = std::fs::read_to_string(&plist)
.map(|existing| existing == body)
.unwrap_or(false);
if unchanged && launchd_registered(&uid) {
pond::output::line(&format!("already scheduled (every {})", every.label()))?;
return Ok(());
}
if let Some(parent) = plist.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&plist, &body)
.with_context(|| format!("failed to write {}", plist.display()))?;
let _ = Command::new("launchctl")
.args(["bootout", &format!("gui/{uid}/{LAUNCHD_LABEL}")])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let output = Command::new("launchctl")
.args(["bootstrap", &format!("gui/{uid}")])
.arg(&plist)
.output()
.context("failed to run launchctl bootstrap")?;
if !output.status.success() {
bail!(
"launchctl bootstrap exited {}: {} - remove {} and retry, or load it manually with `launchctl bootstrap gui/{uid} {}`",
output.status,
String::from_utf8_lossy(&output.stderr).trim(),
plist.display(),
plist.display(),
);
}
pond::output::line(&super::render_state(&super::State::active(
"launchd",
Some(every),
)))?;
pond::output::line(&format!(
"{} {} (pond schedule logs)",
pond::output::paint("logs", pond::output::dim()),
crate::config::display(&crate::config::url_for_path(log)?),
))?;
Ok(())
}
fn stop_launchd() -> Result<bool> {
let plist = plist_path()?;
let uid = current_uid()?;
let was_registered = launchd_registered(&uid);
let _ = Command::new("launchctl")
.args(["bootout", &format!("gui/{uid}/{LAUNCHD_LABEL}")])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let had_plist = match std::fs::remove_file(&plist) {
Ok(()) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => {
return Err(error).with_context(|| format!("failed to remove {}", plist.display()));
}
};
Ok(was_registered || had_plist)
}
fn probe_launchd() -> Result<State> {
let uid = current_uid()?;
if !launchd_registered(&uid) {
return Ok(Inactive);
}
let every = std::fs::read_to_string(plist_path()?)
.ok()
.and_then(|body| plist_interval(&body));
Ok(State::active("launchd", every))
}
fn plist_interval(body: &str) -> Option<ScheduleEvery> {
let after = body.split("<key>StartInterval</key>").nth(1)?;
let start = after.find("<integer>")? + "<integer>".len();
let end = after.find("</integer>")?;
let secs: u32 = after.get(start..end)?.trim().parse().ok()?;
ScheduleEvery::from_secs(secs)
}
fn current_uid() -> Result<String> {
let output = Command::new("id")
.arg("-u")
.output()
.context("failed to run `id -u`")?;
if !output.status.success() {
bail!("`id -u` exited {}", output.status);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn systemd_user_available() -> bool {
Command::new("systemctl")
.args(["--user", "list-timers"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
const NEXT_ELAPSE_PROPERTIES: [&str; 2] = ["NextElapseUSecRealtime", "NextElapseUSecMonotonic"];
const SUB_STATE_PROPERTY: &str = "SubState";
fn systemd_timer_show() -> Option<String> {
let mut command = Command::new("systemctl");
command.args(["--user", "show", "pond-sync.timer"]);
for property in NEXT_ELAPSE_PROPERTIES {
command.arg(format!("--property={property}"));
}
command.arg(format!("--property={SUB_STATE_PROPERTY}"));
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn systemd_next_elapse() -> Option<chrono::DateTime<chrono::Utc>> {
let output = Command::new("busctl")
.args([
"--user",
"get-property",
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1/unit/pond_2dsync_2etimer",
"org.freedesktop.systemd1.Timer",
"NextElapseUSecRealtime",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
next_elapse_from_busctl(&String::from_utf8_lossy(&output.stdout))
}
fn next_elapse_from_busctl(reply: &str) -> Option<chrono::DateTime<chrono::Utc>> {
let usec: u64 = reply.trim().strip_prefix("t ")?.parse().ok()?;
let usec = i64::try_from(usec).ok().filter(|usec| *usec > 0)?;
chrono::DateTime::from_timestamp_micros(usec)
}
fn systemd_timer_problem(show: &str) -> Option<String> {
timer_is_dead(show).then(|| {
"the timer has no next elapse and will never fire again \
(`systemctl --user list-timers` shows `NEXT: -`); run \
`pond schedule start` to repair it"
.to_string()
})
}
fn timer_is_dead(show: &str) -> bool {
if show.lines().any(|line| line.trim() == "SubState=running") {
return false;
}
next_elapse_missing(show)
}
fn next_elapse_missing(show: &str) -> bool {
let mut measured = false;
for line in show.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
if !NEXT_ELAPSE_PROPERTIES.contains(&key.trim()) {
continue;
}
measured = true;
if !matches!(value.trim(), "" | "0" | "n/a" | "infinity") {
return false;
}
}
measured
}
fn systemd_unit_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.unwrap_or_else(|| PathBuf::from(".config"))
.join("systemd/user")
}
fn systemd_service_body(bin: &Path, state: &Path, config_file: &Path) -> String {
format!(
"# created and maintained by pond; edits may be replaced\n\
[Unit]\n\
Description=pond sync\n\n\
[Service]\n\
Type=oneshot\n\
Environment=\"XDG_STATE_HOME={}\"\n\
Environment=\"POND_CONFIG_FILE={}\"\n\
ExecStart={} sync -q --no-wait\n",
state.display(),
config_file.display(),
bin.display(),
)
}
fn systemd_calendar(every: ScheduleEvery) -> &'static str {
match every {
ScheduleEvery::M5 => "*-*-* *:0/5:00",
ScheduleEvery::M15 => "*-*-* *:0/15:00",
ScheduleEvery::H1 => "*-*-* *:00:00",
ScheduleEvery::H6 => "*-*-* 0/6:00:00",
ScheduleEvery::D1 => "*-*-* 03:00:00",
}
}
fn systemd_timer_body(every: ScheduleEvery) -> String {
format!(
"# created and maintained by pond; edits may be replaced\n\
[Unit]\n\
Description=pond sync every {}\n\n\
[Timer]\n\
OnBootSec=2m\n\
OnCalendar={}\n\
RandomizedDelaySec={}s\n\
FixedRandomDelay=true\n\
Persistent=true\n\n\
[Install]\n\
WantedBy=timers.target\n",
every.label(),
systemd_calendar(every),
(every.secs() / 2).min(ScheduleEvery::H1.secs()),
)
}
fn start_systemd(
bin: &Path,
every: ScheduleEvery,
state: &Path,
config_file: &Path,
) -> Result<()> {
let dir = systemd_unit_dir();
std::fs::create_dir_all(&dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
let service_path = dir.join("pond-sync.service");
let timer_path = dir.join("pond-sync.timer");
let service = systemd_service_body(bin, state, config_file);
let timer = systemd_timer_body(every);
let unchanged = std::fs::read_to_string(&service_path)
.map(|existing| existing == service)
.unwrap_or(false)
&& std::fs::read_to_string(&timer_path)
.map(|existing| existing == timer)
.unwrap_or(false);
let needs_repair = systemd_timer_show()
.as_deref()
.and_then(systemd_timer_problem)
.is_some();
if unchanged && systemd_timer_enabled() && !needs_repair {
pond::output::line(&format!("already scheduled (every {})", every.label()))?;
return Ok(());
}
std::fs::write(&service_path, service)
.with_context(|| format!("failed to write {}", service_path.display()))?;
std::fs::write(&timer_path, timer)
.with_context(|| format!("failed to write {}", timer_path.display()))?;
for args in [
vec!["--user", "daemon-reload"],
vec!["--user", "enable", "pond-sync.timer"],
vec!["--user", "restart", "pond-sync.timer"],
] {
let output = Command::new("systemctl")
.args(&args)
.output()
.context("failed to run systemctl")?;
if !output.status.success() {
bail!(
"systemctl {} exited {}: {}",
args.join(" "),
output.status,
String::from_utf8_lossy(&output.stderr).trim(),
);
}
}
if needs_repair {
let output = Command::new("systemctl")
.args(["--user", "start", "--no-block", "pond-sync.service"])
.output()
.context("failed to run systemctl")?;
if !output.status.success() {
bail!(
"systemctl --user start --no-block pond-sync.service exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim(),
);
}
}
pond::output::line(&super::render_state(&super::State::active(
"systemd",
Some(every),
)))?;
pond::output::line(&format!(
"{} journalctl --user -u pond-sync.service (pond schedule logs)",
pond::output::paint("logs", pond::output::dim()),
))?;
Ok(())
}
fn stop_systemd() -> Result<bool> {
let dir = systemd_unit_dir();
let service_path = dir.join("pond-sync.service");
let timer_path = dir.join("pond-sync.timer");
let was_enabled = systemd_timer_enabled();
if was_enabled {
let _ = Command::new("systemctl")
.args(["--user", "disable", "--now", "pond-sync.timer"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
let mut removed_units = false;
for path in [&service_path, &timer_path] {
match std::fs::remove_file(path) {
Ok(()) => removed_units = true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to remove {}", path.display()));
}
}
}
if removed_units {
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
Ok(was_enabled || removed_units)
}
fn read_systemd_interval() -> Option<ScheduleEvery> {
let body = std::fs::read_to_string(systemd_unit_dir().join("pond-sync.timer")).ok()?;
systemd_interval(&body)
}
fn systemd_interval(body: &str) -> Option<ScheduleEvery> {
if let Some(calendar) = body
.lines()
.find_map(|line| line.trim().strip_prefix("OnCalendar="))
{
return ScheduleEvery::value_variants()
.iter()
.copied()
.find(|every| systemd_calendar(*every) == calendar.trim());
}
let line = body
.lines()
.find_map(|line| line.trim().strip_prefix("OnUnitActiveSec="))?;
let secs: u32 = line.trim().trim_end_matches('s').parse().ok()?;
ScheduleEvery::from_secs(secs)
}
fn cron_entry(
bin: &Path,
every: ScheduleEvery,
log: &Path,
minute: u32,
state: &Path,
config_file: &Path,
) -> String {
let command = format!(
"XDG_STATE_HOME=\"{}\" POND_CONFIG_FILE=\"{}\" {} sync -q --no-wait >> {} 2>&1",
state.display(),
config_file.display(),
bin.display(),
log.display()
);
let schedule = match every {
ScheduleEvery::M5 => format!("{}-59/5 * * * *", minute % 5),
ScheduleEvery::M15 => {
let m = minute % 15;
format!("{m},{},{},{} * * * *", m + 15, m + 30, m + 45)
}
ScheduleEvery::H1 => format!("{} * * * *", minute % 60),
ScheduleEvery::H6 => format!("{} */6 * * *", minute % 60),
ScheduleEvery::D1 => format!("{} 3 * * *", minute % 60),
};
format!("{schedule} {command}")
}
fn cron_entry_interval(entry: &str) -> Option<ScheduleEvery> {
let fields: Vec<&str> = entry.split_whitespace().take(5).collect();
if fields.len() < 5 {
return None;
}
match (fields[0], fields[1]) {
(minute, "*") if minute.contains('/') => Some(ScheduleEvery::M5),
(minute, "*") if minute.contains(',') => Some(ScheduleEvery::M15),
(_, "*") => Some(ScheduleEvery::H1),
(minute, "*/6") if !minute.contains(',') && !minute.contains('/') => {
Some(ScheduleEvery::H6)
}
(minute, _) if !minute.contains(',') && !minute.contains('/') => {
Some(ScheduleEvery::D1)
}
_ => None,
}
}
fn read_crontab() -> Result<String> {
let output = Command::new("crontab")
.arg("-l")
.output()
.context("failed to run `crontab -l`")?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
Ok(String::new())
}
}
fn write_crontab(body: &str) -> Result<()> {
use std::io::Write;
let mut child = Command::new("crontab")
.arg("-")
.stdin(Stdio::piped())
.spawn()
.context("failed to run `crontab -`")?;
child
.stdin
.take()
.context("crontab stdin unavailable")?
.write_all(body.as_bytes())
.context("failed to write crontab")?;
let status = child.wait().context("crontab did not exit")?;
if !status.success() {
bail!("`crontab -` exited {status}");
}
Ok(())
}
fn strip_cron_fence(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut inside = false;
for line in text.lines() {
if line.trim() == CRON_FENCE_BEGIN {
inside = true;
continue;
}
if line.trim() == CRON_FENCE_END {
inside = false;
continue;
}
if !inside {
out.push_str(line);
out.push('\n');
}
}
out
}
fn fence_block(entry: &str) -> String {
format!("{CRON_FENCE_BEGIN}\n{entry}\n{CRON_FENCE_END}\n")
}
fn fence_entry_in(text: &str) -> Option<String> {
let after = text.split(CRON_FENCE_BEGIN).nth(1)?;
let block = after.split(CRON_FENCE_END).next().unwrap_or_default();
block
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_owned)
}
fn read_cron_fence_entry() -> Result<Option<String>> {
Ok(fence_entry_in(&read_crontab()?))
}
fn start_cron(
bin: &Path,
every: ScheduleEvery,
log: &Path,
state: &Path,
config_file: &Path,
) -> Result<()> {
let existing = read_crontab()?;
if let Some(entry) = fence_entry_in(&existing)
&& cron_entry_interval(&entry) == Some(every)
&& entry.contains(&bin.display().to_string())
&& entry.contains("--no-wait")
&& entry.contains("XDG_STATE_HOME=")
&& entry.contains("POND_CONFIG_FILE=")
{
pond::output::line(&format!("already scheduled (every {})", every.label()))?;
return Ok(());
}
let entry = cron_entry(bin, every, log, fastrand::u32(0..60), state, config_file);
let mut body = strip_cron_fence(&existing);
if !body.is_empty() && !body.ends_with('\n') {
body.push('\n');
}
body.push_str(&fence_block(&entry));
write_crontab(&body)?;
pond::output::line(&super::render_state(&super::State::active(
"cron",
Some(every),
)))?;
pond::output::line(&format!(
"{} {} (pond schedule logs)",
pond::output::paint("logs", pond::output::dim()),
crate::config::display(&crate::config::url_for_path(log)?),
))?;
Ok(())
}
fn remove_cron_fence() -> Result<bool> {
let existing = read_crontab()?;
if !existing.contains(CRON_FENCE_BEGIN) {
return Ok(false);
}
write_crontab(&strip_cron_fence(&existing))?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
const BIN: &str = "/usr/local/bin/pond";
const LOG: &str = "/tmp/sync.log";
const STATE: &str = "/home/user/.local/state";
const CONFIG: &str = "/home/user/.config/pond/config.toml";
#[test]
fn systemd_timers_use_persistent_calendar_recurrence() {
for (every, calendar) in [
(ScheduleEvery::M5, "*-*-* *:0/5:00"),
(ScheduleEvery::M15, "*-*-* *:0/15:00"),
(ScheduleEvery::H1, "*-*-* *:00:00"),
(ScheduleEvery::H6, "*-*-* 0/6:00:00"),
(ScheduleEvery::D1, "*-*-* 03:00:00"),
] {
let body = systemd_timer_body(every);
assert!(body.contains(&format!("OnCalendar={calendar}\n")), "{body}");
assert!(body.contains("OnBootSec=2m\n"), "{body}");
assert!(body.contains("Persistent=true\n"), "{body}");
assert!(!body.contains("OnUnitActiveSec="), "{body}");
assert_eq!(systemd_interval(&body), Some(every));
}
}
#[test]
fn systemd_timers_spread_hosts_with_a_fixed_delay_below_the_cadence() {
for every in ScheduleEvery::value_variants().iter().copied() {
let body = systemd_timer_body(every);
let delay = body
.lines()
.find_map(|line| line.strip_prefix("RandomizedDelaySec="))
.and_then(|value| value.strip_suffix('s'))
.and_then(|value| value.parse::<u32>().ok());
assert!(
delay.is_some_and(|secs| secs > 0
&& secs <= every.secs() / 2
&& secs <= ScheduleEvery::H1.secs()),
"{body}"
);
assert!(body.contains("FixedRandomDelay=true\n"), "{body}");
}
}
#[test]
fn systemd_interval_reads_unrepaired_units_and_rejects_unknown_cadences() {
for every in [
ScheduleEvery::M5,
ScheduleEvery::M15,
ScheduleEvery::H1,
ScheduleEvery::H6,
ScheduleEvery::D1,
] {
assert_eq!(
systemd_interval(&format!("OnUnitActiveSec={}s\n", every.secs())),
Some(every)
);
}
for body in ["", "OnUnitActiveSec=123s", "OnCalendar=weekly"] {
assert_eq!(systemd_interval(body), None, "{body}");
}
}
const SHOW_DEAD: &str = "LoadState=loaded\n\
ActiveState=active\n\
SubState=elapsed\n\
NextElapseUSecRealtime=\n\
NextElapseUSecMonotonic=infinity\n";
const SHOW_HEALTHY: &str = "LoadState=loaded\n\
ActiveState=active\n\
SubState=waiting\n\
NextElapseUSecRealtime=\n\
NextElapseUSecMonotonic=2month 1w 10h 50min 22.539762s\n";
const SHOW_RUNNING: &str = "LoadState=loaded\n\
ActiveState=active\n\
SubState=running\n\
NextElapseUSecRealtime=\n\
NextElapseUSecMonotonic=infinity\n";
#[test]
fn elapsed_timer_with_no_next_elapse_is_reported_broken() {
assert!(next_elapse_missing(SHOW_DEAD));
}
#[test]
fn waiting_timer_with_a_next_elapse_is_not_reported_broken() {
assert!(!next_elapse_missing(SHOW_HEALTHY));
}
#[test]
fn a_timer_whose_service_is_running_is_not_reported_broken() {
assert!(!timer_is_dead(SHOW_RUNNING));
assert!(timer_is_dead(SHOW_DEAD));
}
#[test]
fn systemctl_that_names_neither_property_is_not_called_broken() {
assert!(!next_elapse_missing(
"LoadState=loaded\nActiveState=active\n"
));
}
#[test]
fn next_elapse_is_read_only_from_an_armed_busctl_reply() {
assert_eq!(
next_elapse_from_busctl("t 1791949277000000\n"),
chrono::DateTime::parse_from_rfc3339("2026-10-14T03:41:17Z")
.ok()
.map(|at| at.to_utc()),
);
for reply in ["t 0\n", "t 18446744073709551615\n", "", "s \"n/a\"\n"] {
assert_eq!(next_elapse_from_busctl(reply), None, "{reply}");
}
}
#[test]
fn the_problem_message_names_the_command_that_repairs_it() {
let Some(problem) = systemd_timer_problem(SHOW_DEAD) else {
panic!("a dead timer must be reported as a problem");
};
assert!(
problem.contains("`pond schedule start` to repair it"),
"remedy must name the repairing command: {problem}"
);
assert!(
!problem.contains("systemctl --user start pond-sync.service"),
"remedy must not hand the user the internal step: {problem}"
);
assert!(systemd_timer_problem(SHOW_HEALTHY).is_none());
assert!(systemd_timer_problem(SHOW_RUNNING).is_none());
}
#[test]
fn cron_entries_reverse_map_to_their_cadence() {
let bin = Path::new(BIN);
let log = Path::new(LOG);
let state = Path::new(STATE);
let config_file = Path::new(CONFIG);
for every in [
ScheduleEvery::M5,
ScheduleEvery::M15,
ScheduleEvery::H1,
ScheduleEvery::H6,
ScheduleEvery::D1,
] {
for minute in [0, 7, 59] {
let entry = cron_entry(bin, every, log, minute, state, config_file);
assert_eq!(cron_entry_interval(&entry), Some(every), "entry: {entry}");
}
}
}
#[test]
fn every_template_pins_the_config_file() {
let (bin, log, state, config_file) = (
Path::new(BIN),
Path::new(LOG),
Path::new(STATE),
Path::new(CONFIG),
);
let plist = plist_body(bin, ScheduleEvery::M5, log, state, config_file);
assert!(
plist.contains(&format!(
"<key>POND_CONFIG_FILE</key>\n\t\t<string>{CONFIG}</string>"
)),
"{plist}"
);
let service = systemd_service_body(bin, state, config_file);
assert!(
service.contains(&format!("Environment=\"POND_CONFIG_FILE={CONFIG}\"\n")),
"{service}"
);
let entry = cron_entry(bin, ScheduleEvery::M5, log, 7, state, config_file);
assert!(
entry.contains(&format!("POND_CONFIG_FILE=\"{CONFIG}\"")),
"{entry}"
);
assert!(entry.find("POND_CONFIG_FILE=") < entry.find(BIN), "{entry}");
}
}
}
#[cfg(windows)]
mod windows {
use std::path::PathBuf;
use std::process::Command;
use anyhow::{Context, Result, bail};
use super::{ScheduleEvery, State};
use State::{Active, Inactive};
const TASK_NAME: &str = "pond-sync";
const ELEVATED_OWNERSHIP_HINT: &str = "likely registered from an elevated shell and owned by Administrators; remove it \
by running `pond schedule stop` from an elevated shell (or \
`schtasks /Delete /TN pond-sync /F`)";
fn query_xml() -> Result<Option<String>> {
let output = Command::new("schtasks")
.args(["/Query", "/TN", TASK_NAME, "/XML", "ONE"])
.output()
.context("failed to run schtasks /Query")?;
Ok(output
.status
.success()
.then(|| decode_console(&output.stdout)))
}
pub(super) fn probe() -> Result<State> {
let Some(xml) = query_xml()? else {
return Ok(Inactive);
};
Ok(Active {
backend: "task-scheduler",
every: parse_interval_from_xml(&xml),
problem: launcher_problem(&xml),
next_run: None,
})
}
fn launcher_problem(xml: &str) -> Option<String> {
between(xml, "<Command>", "</Command>")
.map(xml_unescape)
.filter(|launcher| !std::path::Path::new(launcher).is_file())
.map(|launcher| {
format!(
"the registered launcher no longer exists ({launcher}); \
run `pond schedule start` to re-register from the current install"
)
})
}
pub(super) fn start(every: ScheduleEvery, config_file: &std::path::Path) -> Result<()> {
let bin = pond_bin();
if !bin.is_file() {
bail!(
"could not resolve the pond binary to register ({}); run \
`pond schedule start` from an installed pond",
bin.display()
);
}
let launcher = pondw_bin(&bin)?;
let log = super::log_path();
let state_root = crate::syncstate::state_root();
let pond_state = crate::syncstate::pond_state_dir();
std::fs::create_dir_all(&pond_state)
.with_context(|| format!("failed to create {}", pond_state.display()))?;
for (what, path, sources) in [
(
"launcher",
launcher.as_path(),
"the installed pond binary's directory",
),
("pond binary", bin.as_path(), "the installed pond location"),
(
"sync log",
log.as_path(),
"the state dir (--state-dir or XDG_STATE_HOME)",
),
(
"state dir",
state_root.as_path(),
"--state-dir or XDG_STATE_HOME",
),
("config file", config_file, super::CONFIG_FILE_SOURCES),
] {
super::reject_unembeddable(what, path, sources)?;
}
let arguments = task_arguments(&log, &bin, &state_root, config_file);
if std::env::var_os("XDG_STATE_HOME").is_some() {
pond::output::line(&format!(
"note: XDG_STATE_HOME is set; the task is pinned to {} and will not follow later changes",
state_root.display()
))?;
}
let launcher_str = launcher.display().to_string();
if let Some(xml) = query_xml()?
&& between(&xml, "<Command>", "</Command>")
.map(xml_unescape)
.as_deref()
== Some(launcher_str.as_str())
&& between(&xml, "<Arguments>", "</Arguments>")
.map(xml_unescape)
.as_deref()
== Some(arguments.as_str())
&& parse_interval_from_xml(&xml) == Some(every)
{
pond::output::line(&format!("already scheduled (every {})", every.label()))?;
return Ok(());
}
if shell_is_elevated() {
pond::output::line(
"note: this shell is elevated - the task will be owned by Administrators, \
and `pond schedule start`/`stop` from a normal shell will fail with \
Access denied; re-run from a non-elevated shell unless that is intended",
)?;
}
let xml = task_xml(&launcher, &arguments, every);
let tmp_xml = pond_state.join("pond-sync-task.xml.tmp");
std::fs::write(&tmp_xml, utf16le_bom(&xml))
.with_context(|| format!("failed to write {}", tmp_xml.display()))?;
let create_result = Command::new("schtasks")
.args(["/Create", "/TN", TASK_NAME, "/XML"])
.arg(&tmp_xml)
.arg("/F")
.output()
.context("failed to run schtasks /Create");
let _ = std::fs::remove_file(&tmp_xml);
let output = create_result?;
if !output.status.success() {
let stderr = decode_console(&output.stderr);
let stderr = stderr.trim();
if matches!(query_xml(), Ok(Some(_))) {
bail!(
"schtasks /Create failed: {stderr}\n\
the existing '{TASK_NAME}' task could not be replaced - it was \
{ELEVATED_OWNERSHIP_HINT}, then re-run `pond schedule start` unelevated"
);
}
bail!("schtasks /Create failed: {stderr}");
}
for stale in ["pond-sync.cmd", "pond-sync.vbs"] {
let _ = std::fs::remove_file(pond_state.join(stale));
}
pond::output::line(&super::render_state(&super::State::active(
"task-scheduler",
Some(every),
)))?;
pond::output::line(&format!(
"{} {} (pond schedule logs)",
pond::output::paint("logs", pond::output::dim()),
crate::config::display(&crate::config::url_for_path(log)?),
))?;
Ok(())
}
pub(super) fn stop() -> Result<()> {
let output = Command::new("schtasks")
.args(["/Delete", "/TN", TASK_NAME, "/F"])
.output()
.context("failed to run schtasks /Delete")?;
if output.status.success() {
pond::output::line("schedule removed")?;
return Ok(());
}
match probe()? {
Inactive => pond::output::line("nothing was scheduled")?,
Active { .. } => {
bail!(
"schtasks /Delete failed: {}\n\
the task is {ELEVATED_OWNERSHIP_HINT}",
decode_console(&output.stderr).trim()
);
}
}
Ok(())
}
fn quote_arg(path: &std::path::Path) -> String {
let text = path.display().to_string();
let trailing = text.len() - text.trim_end_matches('\\').len();
format!("\"{text}{}\"", "\\".repeat(trailing))
}
fn task_arguments(
log: &std::path::Path,
bin: &std::path::Path,
state_root: &std::path::Path,
config_file: &std::path::Path,
) -> String {
format!(
"--log {log} -- {bin} sync -q --no-wait --state-dir {state} --config-file {config}",
log = quote_arg(log),
bin = quote_arg(bin),
state = quote_arg(state_root),
config = quote_arg(config_file),
)
}
fn task_xml(launcher: &std::path::Path, arguments: &str, every: ScheduleEvery) -> String {
let trigger = match every {
ScheduleEvery::D1 => " <CalendarTrigger>\n\
\x20\x20\x20\x20 <StartBoundary>2000-01-01T03:00:00</StartBoundary>\n\
\x20\x20\x20\x20 <Enabled>true</Enabled>\n\
\x20\x20\x20\x20 <ScheduleByDay>\
<DaysInterval>1</DaysInterval>\
</ScheduleByDay>\n\
\x20\x20\x20\x20</CalendarTrigger>"
.to_owned(),
_ => {
let interval = match every {
ScheduleEvery::M5 => "PT5M",
ScheduleEvery::M15 => "PT15M",
ScheduleEvery::H1 => "PT1H",
ScheduleEvery::H6 => "PT6H",
ScheduleEvery::D1 => unreachable!(),
};
format!(
" <TimeTrigger>\n\
\x20\x20\x20\x20 <Repetition>\n\
\x20\x20\x20\x20 <Interval>{interval}</Interval>\n\
\x20\x20\x20\x20 <StopAtDurationEnd>false</StopAtDurationEnd>\n\
\x20\x20\x20\x20 </Repetition>\n\
\x20\x20\x20\x20 <StartBoundary>2000-01-01T00:00:00</StartBoundary>\n\
\x20\x20\x20\x20 <Enabled>true</Enabled>\n\
\x20\x20\x20\x20</TimeTrigger>"
)
}
};
let launcher_str = xml_escape(&launcher.display().to_string());
let arguments = xml_escape(arguments);
format!(
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
<Task version=\"1.2\" \
xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
\x20\x20<RegistrationInfo>\n\
\x20\x20 <Description>pond sync (managed by pond; do not edit)</Description>\n\
\x20\x20</RegistrationInfo>\n\
\x20\x20<Triggers>\n\
{trigger}\n\
\x20\x20</Triggers>\n\
\x20\x20<Settings>\n\
\x20\x20 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
\x20\x20 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n\
\x20\x20 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n\
\x20\x20 <StartWhenAvailable>true</StartWhenAvailable>\n\
\x20\x20 <Hidden>true</Hidden>\n\
\x20\x20 <ExecutionTimeLimit>PT1H</ExecutionTimeLimit>\n\
\x20\x20 <Priority>7</Priority>\n\
\x20\x20</Settings>\n\
\x20\x20<Actions Context=\"Author\">\n\
\x20\x20 <Exec>\n\
\x20\x20 <Command>{launcher_str}</Command>\n\
\x20\x20 <Arguments>{arguments}</Arguments>\n\
\x20\x20 </Exec>\n\
\x20\x20</Actions>\n\
</Task>\n"
)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn xml_unescape(s: &str) -> String {
s.replace(""", "\"")
.replace(">", ">")
.replace("<", "<")
.replace("&", "&")
}
fn parse_interval_from_xml(xml: &str) -> Option<ScheduleEvery> {
if let Some(interval) = between(xml, "<Interval>", "</Interval>") {
let secs = if let Some(m) = interval
.strip_prefix("PT")
.and_then(|s| s.strip_suffix('M'))
{
m.parse::<u32>().ok().map(|m| m * 60)
} else {
let h = interval
.strip_prefix("PT")
.and_then(|s| s.strip_suffix('H'))?;
h.parse::<u32>().ok().map(|h| h * 3_600)
}?;
return ScheduleEvery::from_secs(secs);
}
if between(xml, "<DaysInterval>", "</DaysInterval>").is_some() {
return ScheduleEvery::from_secs(86_400);
}
None
}
fn between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
let start = text.find(open)? + open.len();
let rest = &text[start..];
let end = rest.find(close)?;
Some(&rest[..end])
}
fn pond_bin() -> PathBuf {
crate::find_on_path("pond.exe")
.or_else(|| crate::find_on_path("pond"))
.unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| PathBuf::from("pond")))
}
fn shell_is_elevated() -> bool {
Command::new("whoami")
.args(["/groups"])
.output()
.map(|output| decode_console(&output.stdout).contains("S-1-16-12288"))
.unwrap_or(false)
}
fn pondw_bin(bin: &std::path::Path) -> Result<PathBuf> {
[
Some(bin.with_file_name("pondw.exe")),
std::env::current_exe()
.ok()
.map(|exe| exe.with_file_name("pondw.exe")),
]
.into_iter()
.flatten()
.find(|path| path.is_file())
.context(
"pondw.exe not found beside pond.exe: it ships in the release zip and runs \
the scheduled sync without a console window - reinstall pond and re-run \
`pond schedule start`",
)
}
fn decode_console(bytes: &[u8]) -> String {
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
return decode_utf16le(&bytes[2..]);
}
if bytes.iter().take(64).filter(|&&b| b == 0).count() >= 2 {
return decode_utf16le(bytes);
}
String::from_utf8_lossy(bytes).into_owned()
}
fn decode_utf16le(bytes: &[u8]) -> String {
let units: Vec<u16> = bytes
.as_chunks::<2>()
.0
.iter()
.map(|pair| u16::from_le_bytes(*pair))
.collect();
String::from_utf16_lossy(&units)
}
fn utf16le_bom(text: &str) -> Vec<u8> {
let mut bytes = Vec::with_capacity(2 + text.len() * 2);
bytes.extend_from_slice(&[0xFF, 0xFE]);
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
bytes
}
#[cfg(test)]
mod tests {
#![expect(clippy::expect_used, reason = "tests fail by panicking")]
use super::*;
fn fixture() -> (std::path::PathBuf, String) {
let launcher = std::path::PathBuf::from("C:\\Program Files\\pond\\pondw.exe");
let arguments = task_arguments(
std::path::Path::new("C:\\Users\\Adam\\AppData\\Local\\pond\\state\\sync.log"),
std::path::Path::new("C:\\Program Files\\pond\\pond.exe"),
std::path::Path::new("C:\\Users\\Adam\\AppData\\Local\\pond\\state"),
std::path::Path::new("C:\\Users\\Adam\\AppData\\Roaming\\pond\\config.toml"),
);
(launcher, arguments)
}
#[test]
fn all_cadences_round_trip_through_task_xml() {
let (launcher, arguments) = fixture();
for every in [
ScheduleEvery::M5,
ScheduleEvery::M15,
ScheduleEvery::H1,
ScheduleEvery::H6,
ScheduleEvery::D1,
] {
let xml = task_xml(&launcher, &arguments, every);
let parsed = parse_interval_from_xml(&xml);
assert_eq!(parsed, Some(every), "cadence {every:?} did not round-trip");
}
}
#[test]
fn launcher_problem_flags_only_a_missing_command() {
let dir = tempfile::tempdir().expect("tempdir");
let real = dir.path().join("pondw.exe");
std::fs::write(&real, b"x").expect("write");
let present = format!("<Command>{}</Command>", real.display());
assert_eq!(launcher_problem(&present), None);
let gone = dir.path().join("uninstalled").join("pondw.exe");
let problem = launcher_problem(&format!("<Command>{}</Command>", gone.display()))
.expect("problem");
assert!(problem.contains("pond schedule start"), "{problem}");
assert!(problem.contains("uninstalled"), "{problem}");
}
#[test]
fn between_finds_content_between_delimiters() {
assert_eq!(between("<Foo>42</Foo>", "<Foo>", "</Foo>"), Some("42"));
assert_eq!(between("<A>x</A><B>y</B>", "<B>", "</B>"), Some("y"));
assert_eq!(between("no match", "<X>", "</X>"), None);
assert_eq!(between("<Open>missing close", "<Open>", "</Open>"), None);
}
#[test]
fn decode_console_handles_utf8_and_utf16le_bom() {
assert_eq!(decode_console(b"hello world"), "hello world");
let text = "hello";
let mut bytes: Vec<u8> = vec![0xFF, 0xFE];
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
assert_eq!(decode_console(&bytes), "hello");
}
#[test]
fn utf16le_bom_leads_with_bom_and_round_trips_non_ascii() {
let text = "C:\\Users\\p\u{f6}nd \u{e9}tat\\pondw.exe";
let bytes = utf16le_bom(text);
assert_eq!(&bytes[..2], &[0xFF, 0xFE], "BOM must lead the file");
assert_eq!(bytes.len(), 2 + text.encode_utf16().count() * 2);
assert_eq!(decode_console(&bytes), text);
}
#[test]
fn task_xml_execs_the_launcher_and_contains_expected_settings() {
let (launcher, arguments) = fixture();
let xml = task_xml(&launcher, &arguments, ScheduleEvery::M5);
assert!(xml.contains("<Command>C:\\Program Files\\pond\\pondw.exe</Command>"));
assert!(!xml.contains("wscript"));
assert!(xml.contains("<StartWhenAvailable>true</StartWhenAvailable>"));
assert!(xml.contains("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>"));
assert!(xml.contains("<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>"));
assert!(xml.contains("<Hidden>true</Hidden>"));
}
#[test]
fn a_trailing_separator_does_not_escape_the_closing_quote() {
let args = task_arguments(
std::path::Path::new("C:\\s\\sync.log"),
std::path::Path::new("C:\\bin\\pond.exe"),
std::path::Path::new("C:\\my state\\"),
std::path::Path::new("C:\\c\\config.toml"),
);
assert!(args.contains("--state-dir \"C:\\my state\\\\\""), "{args}");
assert_eq!(args.matches('"').count() % 2, 0, "{args}");
}
#[test]
fn task_arguments_pin_the_state_dir_and_quote_spaced_paths() {
let (_, arguments) = fixture();
assert!(
arguments.contains("--state-dir \"C:\\Users\\Adam\\AppData\\Local\\pond\\state\""),
"{arguments}"
);
assert!(arguments.contains("sync -q --no-wait"), "{arguments}");
assert!(
arguments.contains(
"--config-file \"C:\\Users\\Adam\\AppData\\Roaming\\pond\\config.toml\""
),
"{arguments}"
);
assert!(
arguments.contains("-- \"C:\\Program Files\\pond\\pond.exe\""),
"{arguments}"
);
assert!(arguments.starts_with("--log \"C:\\"), "{arguments}");
}
#[test]
fn action_survives_the_xml_escape_round_trip() {
let (launcher, arguments) = fixture();
let xml = task_xml(&launcher, &arguments, ScheduleEvery::M5);
assert_eq!(
between(&xml, "<Arguments>", "</Arguments>").map(xml_unescape),
Some(arguments)
);
assert_eq!(
between(&xml, "<Command>", "</Command>").map(xml_unescape),
Some(launcher.display().to_string())
);
}
#[test]
fn xml_unescape_leaves_an_escaped_entity_literal() {
assert_eq!(xml_unescape(&xml_escape("a"b")), "a"b");
assert_eq!(xml_unescape(&xml_escape("a\"<b>&c")), "a\"<b>&c");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_round_trips_through_secs_and_labels() {
for every in [
ScheduleEvery::M5,
ScheduleEvery::M15,
ScheduleEvery::H1,
ScheduleEvery::H6,
ScheduleEvery::D1,
] {
assert_eq!(ScheduleEvery::from_secs(every.secs()), Some(every));
}
assert_eq!(ScheduleEvery::from_secs(123), None);
}
#[test]
fn unembeddable_paths_are_rejected_before_registration() {
for bad in [
"/home/user/a\"b/config.toml",
"/home/user/100%/config.toml",
"/home/user/a<b>/config.toml",
"/home/user/a&b/config.toml",
"/home/user/a\nb/config.toml",
] {
let error = reject_unembeddable("config file", Path::new(bad), "POND_CONFIG_FILE")
.err()
.map(|error| error.to_string())
.unwrap_or_default();
assert!(error.contains("config file"), "accepted {bad}");
assert!(error.contains("POND_CONFIG_FILE"), "{error}");
}
assert!(
reject_unembeddable(
"config file",
Path::new("/home/user/.config/pond/config.toml"),
"POND_CONFIG_FILE"
)
.is_ok()
);
}
#[test]
fn config_pin_is_absolutized() {
let pinned = config_file(Some(PathBuf::from("relative/config.toml")));
assert!(pinned.is_absolute(), "{}", pinned.display());
}
}