use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use eyre::{Result, bail, eyre};
use indexmap::IndexMap;
const SCHTASKS_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScheduledTaskRequest {
pub name: String,
pub task: String,
pub description: Option<String>,
pub command: String,
pub restart_on_failure: bool,
pub environment: IndexMap<String, String>,
pub launcher: Option<String>,
pub working_directory: Option<String>,
pub start: bool,
pub nice: Option<i8>,
pub at_logon: bool,
pub restart: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ScheduledTaskState {
Running,
Ready,
Disabled,
Differs,
Missing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScheduledTaskStatus {
pub request: ScheduledTaskRequest,
pub path: PathBuf,
pub state: ScheduledTaskState,
}
impl ScheduledTaskStatus {
pub(crate) fn is_desired(&self) -> bool {
match self.state {
ScheduledTaskState::Running => self.request.start,
ScheduledTaskState::Ready => !self.request.start,
ScheduledTaskState::Disabled
| ScheduledTaskState::Differs
| ScheduledTaskState::Missing => false,
}
}
}
impl ScheduledTaskRequest {
pub(crate) fn new(name: &str) -> Self {
Self {
name: name.to_string(),
task: task_name(name),
description: None,
command: String::new(),
restart_on_failure: false,
environment: IndexMap::new(),
launcher: None,
working_directory: None,
start: true,
at_logon: true,
nice: None,
restart: false,
}
}
}
pub(crate) fn is_available() -> bool {
cfg!(windows) && crate::file::which_spawnable("schtasks").is_some()
}
pub(crate) fn unavailable_reason() -> String {
if cfg!(windows) {
"`schtasks` not found".to_string()
} else {
"only available on windows".to_string()
}
}
pub(crate) fn task_name(name: &str) -> String {
format!("mise\\{name}")
}
pub(crate) fn definition_path(name: &str) -> PathBuf {
crate::dirs::STATE
.join("user-services")
.join(format!("{name}.xml"))
}
fn current_user_id() -> String {
let user = crate::env::var("USERNAME").unwrap_or_else(|_| "".to_string());
match crate::env::var("USERDOMAIN") {
Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"),
_ => user,
}
}
pub(crate) fn render_definition(request: &ScheduledTaskRequest, user_id: &str) -> Result<Vec<u8>> {
let xml = render_xml(request, user_id)?;
let mut out = vec![0xFF, 0xFE];
for unit in xml.encode_utf16() {
out.extend_from_slice(&unit.to_le_bytes());
}
Ok(out)
}
pub(crate) fn render_xml(request: &ScheduledTaskRequest, user_id: &str) -> Result<String> {
let (command, arguments) = exec_action(request)?;
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
out.push_str(
"<Task version=\"1.4\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
);
out.push_str(" <RegistrationInfo>\n");
out.push_str(&format!(
" <Description>{}</Description>\n",
escape(
request
.description
.as_deref()
.unwrap_or("managed by mise bootstrap")
)
));
out.push_str(" </RegistrationInfo>\n");
out.push_str(" <Triggers>\n <LogonTrigger>\n");
out.push_str(&format!(
" <Enabled>{}</Enabled>\n",
yes_no(request.at_logon)
));
out.push_str(&format!(" <UserId>{}</UserId>\n", escape(user_id)));
out.push_str(" </LogonTrigger>\n </Triggers>\n");
out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
out.push_str(&format!(" <UserId>{}</UserId>\n", escape(user_id)));
out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
out.push_str(" </Principal>\n </Principals>\n");
out.push_str(" <Settings>\n");
out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
out.push_str(" <Enabled>true</Enabled>\n");
out.push_str(" <Hidden>false</Hidden>\n");
out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
if request.restart_on_failure {
out.push_str(" <RestartOnFailure>\n <Interval>PT1M</Interval>\n <Count>3</Count>\n </RestartOnFailure>\n");
}
let priority = if request.nice.is_some_and(|nice| nice > 0) {
9
} else {
7
};
out.push_str(&format!(" <Priority>{priority}</Priority>\n"));
out.push_str(" </Settings>\n");
out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
out.push_str(&format!(" <Command>{}</Command>\n", escape(&command)));
if !arguments.is_empty() {
out.push_str(&format!(
" <Arguments>{}</Arguments>\n",
escape(&arguments)
));
}
if let Some(dir) = &request.working_directory {
out.push_str(&format!(
" <WorkingDirectory>{}</WorkingDirectory>\n",
escape(&expand_path_string(dir))
));
}
out.push_str(" </Exec>\n </Actions>\n");
out.push_str("</Task>\n");
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct ServiceLaunch {
pub program: String,
pub args: String,
pub environment: IndexMap<String, String>,
}
fn launch(request: &ScheduledTaskRequest) -> Result<Option<ServiceLaunch>> {
if request.environment.is_empty() {
return Ok(None);
}
for (key, value) in &request.environment {
if key.is_empty() || key.contains(['=', '\0']) {
bail!(
"user service '{}': environment name {key:?} cannot be a Windows environment variable",
request.name
);
}
if value.contains('\0') {
bail!(
"user service '{}': environment value for {key} contains a NUL, which cannot be passed to a process",
request.name
);
}
}
let (program, args) = split_command(&request.command);
Ok(Some(ServiceLaunch {
program,
args,
environment: request.environment.clone(),
}))
}
pub(crate) fn render_launch(request: &ScheduledTaskRequest) -> Result<Option<(String, String)>> {
let Some(launch) = launch(request)? else {
return Ok(None);
};
let json = serde_json::to_string(&launch)?;
let digest = crate::hash::hash_blake3_to_str(&json);
Ok(Some((json, digest)))
}
pub(crate) fn launch_dir(name: &str) -> PathBuf {
crate::dirs::STATE
.join("user-services")
.join(format!("{name}.launches"))
}
fn service_lock(name: &str) -> Result<fslock::LockFile> {
let path = crate::dirs::STATE
.join("user-services")
.join(format!("{name}.lock"));
crate::lock_file::LockFile::at(&path)
.try_lock()?
.ok_or_else(|| eyre!("another command is changing user service '{name}'; retry shortly"))
}
pub(crate) fn launch_path(name: &str, digest: &str) -> PathBuf {
launch_dir(name).join(format!("{digest}.json"))
}
fn exec_action(request: &ScheduledTaskRequest) -> Result<(String, String)> {
let Some((_, digest)) = render_launch(request)? else {
return Ok(split_command(&request.command));
};
let Some(mise) = request.launcher.as_deref() else {
bail!(
"user service '{}' sets `environment`, which mise carries for it; install mise on this host first",
request.name
);
};
let path = quote_argument(&launch_path(&request.name, &digest).to_string_lossy());
Ok((
mise.to_string(),
format!(
"bootstrap __service-exec --launch {path} --digest {digest} -- {}",
request.name
),
))
}
fn quote_argument(value: &str) -> String {
if value.contains(char::is_whitespace) {
format!("\"{value}\"")
} else {
value.to_string()
}
}
fn stored_is_current(request: &ScheduledTaskRequest, user_id: &str) -> Result<bool> {
if std::fs::read(definition_path(&request.name)).unwrap_or_default()
!= render_definition(request, user_id)?
{
return Ok(false);
}
let Some((json, digest)) = render_launch(request)? else {
return Ok(true);
};
Ok(std::fs::read(launch_path(&request.name, &digest)).unwrap_or_default() == json.into_bytes())
}
fn split_command(command: &str) -> (String, String) {
let trimmed = command.trim();
let (program, args) = if let Some(rest) = trimmed.strip_prefix('"')
&& let Some(end) = rest.find('"')
{
(rest[..end].to_string(), rest[end + 1..].trim().to_string())
} else {
match trimmed.split_once(char::is_whitespace) {
Some((program, args)) => (program.to_string(), args.trim().to_string()),
None => (trimmed.to_string(), String::new()),
}
};
let program = if program == "~" || program.starts_with("~/") || program.starts_with("~\\") {
expand_path_string(&program)
} else {
program
};
(program, args)
}
fn escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn yes_no(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn expand_path_string(path: &str) -> String {
if path == "~" {
return crate::dirs::HOME.to_string_lossy().to_string();
}
crate::file::replace_path(Path::new(path))
.to_string_lossy()
.to_string()
}
pub(crate) async fn status(requests: &[ScheduledTaskRequest]) -> Result<Vec<ScheduledTaskStatus>> {
let user_id = current_user_id();
let mut out = vec![];
for req in requests {
let path = definition_path(&req.name);
let registered = query(&req.task).await?;
let state = match registered {
None => ScheduledTaskState::Missing,
Some(query) => {
if !stored_is_current(req, &user_id)? {
ScheduledTaskState::Differs
} else if query.running {
ScheduledTaskState::Running
} else if query.disabled {
ScheduledTaskState::Disabled
} else {
ScheduledTaskState::Ready
}
}
};
out.push(ScheduledTaskStatus {
request: req.clone(),
path,
state,
});
}
Ok(out)
}
pub(crate) async fn exists(name: &str) -> Result<bool> {
Ok(query(&task_name(name)).await?.is_some())
}
fn transition(running: bool, changed: bool, start: bool, restart: bool) -> (bool, bool) {
let replace = changed || restart;
(
running && (!start || replace),
start && (!running || replace),
)
}
pub(crate) async fn apply(requests: &[ScheduledTaskRequest], dry_run: bool) -> Result<()> {
let user_id = current_user_id();
for req in requests {
let path = definition_path(&req.name);
let staging = path.with_extension("xml.new");
let rendered = render_definition(req, &user_id)?;
let launch = render_launch(req)?;
let create = [
"/create".to_string(),
"/tn".to_string(),
req.task.clone(),
"/xml".to_string(),
staging.display().to_string(),
"/f".to_string(),
];
let end = [
"/end".to_string(),
"/tn".to_string(),
req.task.clone(),
"/HRESULT".to_string(),
];
let run = ["/run".to_string(), "/tn".to_string(), req.task.clone()];
let _lock = if dry_run {
None
} else {
Some(service_lock(&req.name)?)
};
let registered = query(&req.task).await?;
let running = registered.as_ref().is_some_and(|query| query.running);
let changed = registered.is_some() && !stored_is_current(req, &user_id)?;
let (end_first, start) = transition(running, changed, req.start, req.restart);
if dry_run {
if let Some((_, digest)) = &launch {
miseprintln!(
"write {}",
shell_words::join([launch_path(&req.name, digest).display().to_string()])
);
}
miseprintln!("write {}", shell_words::join([path.display().to_string()]));
miseprintln!("schtasks {}", shell_words::join(&create));
if end_first {
miseprintln!("schtasks {}", shell_words::join(&end));
}
if start {
miseprintln!("schtasks {}", shell_words::join(&run));
}
continue;
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if let Some((json, digest)) = &launch {
std::fs::create_dir_all(launch_dir(&req.name))?;
let path = launch_path(&req.name, digest);
if std::fs::read(&path).ok().as_deref() != Some(json.as_bytes()) {
std::fs::write(&path, json)?;
}
}
std::fs::write(&staging, &rendered)?;
if let Err(err) = schtasks(&create).await {
let _ = std::fs::remove_file(&staging);
return Err(err);
}
std::fs::write(&path, &rendered)?;
let _ = std::fs::remove_file(&staging);
if end_first {
let (status, printed) = schtasks_output(&end).await?;
if !status.success()
&& status.code() != Some(SCHED_E_TASK_NOT_RUNNING)
&& !end_error_is_noop(&printed)
{
bail!("`schtasks {}` failed: {printed}", shell_words::join(&end));
}
}
if start {
schtasks(&run).await?;
}
}
Ok(())
}
pub(crate) async fn remove_task(name: &str, dry_run: bool) -> Result<bool> {
let task = task_name(name);
let path = definition_path(name);
let launches = launch_dir(name);
let _lock = if dry_run {
None
} else {
Some(service_lock(name)?)
};
if !exists(name).await? {
if !dry_run {
if path.exists() {
std::fs::remove_file(&path)?;
}
let _ = std::fs::remove_dir_all(&launches);
}
return Ok(false);
}
let args = [
"/delete".to_string(),
"/tn".to_string(),
task,
"/f".to_string(),
];
if dry_run {
miseprintln!("schtasks {}", shell_words::join(&args));
for stale in [&path, &launches] {
if stale.exists() {
miseprintln!(
"{}",
shell_words::join(["rm".to_string(), stale.display().to_string()])
);
}
}
return Ok(true);
}
schtasks(&args).await?;
if path.exists() {
std::fs::remove_file(&path)?;
}
let _ = std::fs::remove_dir_all(&launches);
Ok(true)
}
struct Query {
running: bool,
disabled: bool,
}
fn query_script(name: &str) -> String {
format!(
"$t = Get-ScheduledTask -TaskPath '\\mise\\' -TaskName '{name}' -ErrorAction SilentlyContinue; if ($null -eq $t) {{ 'MISSING' }} else {{ $t.State.ToString() }}"
)
}
async fn query(task: &str) -> Result<Option<Query>> {
let name = task.strip_prefix("mise\\").unwrap_or(task);
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
{
bail!("scheduled task name {name:?} contains characters that cannot be queried");
}
let args = [
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
query_script(name),
];
debug!("$ powershell {}", shell_words::join(&args));
let mut cmd = tokio::process::Command::new("powershell.exe");
cmd.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let output = tokio::time::timeout(SCHTASKS_TIMEOUT, cmd.output())
.await
.map_err(|_| eyre!("querying scheduled task {task} timed out"))??;
if !output.status.success() {
bail!(
"querying scheduled task {task} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(parse_query(&String::from_utf8_lossy(&output.stdout)))
}
fn parse_query(output: &str) -> Option<Query> {
let state = output.trim();
if state.eq_ignore_ascii_case("MISSING") || state.is_empty() {
return None;
}
Some(Query {
running: state.eq_ignore_ascii_case("Running"),
disabled: state.eq_ignore_ascii_case("Disabled"),
})
}
const SCHED_E_TASK_NOT_RUNNING: i32 = 0x8004130Bu32 as i32;
fn end_error_is_noop(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("not running") || error.contains("no running instance")
}
async fn schtasks(args: &[String]) -> Result<()> {
let (status, printed) = schtasks_output(args).await?;
if !status.success() {
bail!("`schtasks {}` failed: {printed}", shell_words::join(args));
}
Ok(())
}
async fn schtasks_output(args: &[String]) -> Result<(std::process::ExitStatus, String)> {
debug!("$ schtasks {}", shell_words::join(args));
let mut cmd = tokio::process::Command::new("schtasks");
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let output = tokio::time::timeout(SCHTASKS_TIMEOUT, cmd.output())
.await
.map_err(|_| eyre!("`schtasks {}` timed out", shell_words::join(args)))??;
let printed = [
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
]
.iter()
.map(|text| text.trim().to_string())
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("; ");
Ok((output.status, printed))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ScheduledTaskRequest {
let mut request = ScheduledTaskRequest::new("agent");
request.command = "C:\\Tools\\agent.exe --serve".to_string();
request.description = Some("My <agent>".to_string());
request.restart_on_failure = true;
request
}
#[test]
fn a_running_task_is_replaced_only_when_it_must_be() {
let cases = [
((false, false, false, false), (false, false)),
((false, false, false, true), (false, false)),
((false, true, false, false), (false, false)),
((false, true, false, true), (false, false)),
((false, false, true, false), (false, true)),
((false, false, true, true), (false, true)),
((false, true, true, false), (false, true)),
((false, true, true, true), (false, true)),
((true, false, false, false), (true, false)),
((true, false, false, true), (true, false)),
((true, true, false, false), (true, false)),
((true, true, false, true), (true, false)),
((true, false, true, false), (false, false)),
((true, false, true, true), (true, true)),
((true, true, true, false), (true, true)),
((true, true, true, true), (true, true)),
];
assert_eq!(cases.len(), 16, "every combination is covered");
for ((running, changed, start, restart), expected) in cases {
assert_eq!(
transition(running, changed, start, restart),
expected,
"running={running} changed={changed} start={start} restart={restart}"
);
}
}
#[test]
fn renders_a_logon_task() {
let xml = render_xml(&sample(), "HOST\\me").unwrap();
assert!(xml.contains("<Description>My <agent></Description>"));
assert!(xml.contains(
"<LogonTrigger>\n <Enabled>true</Enabled>\n <UserId>HOST\\me</UserId>"
));
assert!(xml.contains("<Command>C:\\Tools\\agent.exe</Command>"));
assert!(xml.contains("<Arguments>--serve</Arguments>"));
assert!(xml.contains("<RestartOnFailure>"));
assert!(!xml.contains("<WorkingDirectory>"));
}
#[test]
fn an_environment_makes_mise_the_action() {
let mut request = sample();
request.environment.insert("RUST_LOG".into(), "info".into());
request.launcher = Some("C:\\mise\\mise.exe".to_string());
request.at_logon = false;
request.restart_on_failure = false;
let xml = render_xml(&request, "me").unwrap();
assert!(
xml.contains("<Command>C:\\mise\\mise.exe</Command>"),
"{xml}"
);
let (launch, digest) = render_launch(&request).unwrap().unwrap();
let path = launch_path("agent", &digest);
assert!(path.is_absolute(), "{}", path.display());
let expected = format!(
"<Arguments>bootstrap __service-exec --launch {} --digest {digest} -- agent</Arguments>",
escape("e_argument(&path.to_string_lossy()))
);
assert!(xml.contains(&expected), "{xml}\nexpected {expected}");
assert!(!xml.contains("RUST_LOG"), "{xml}");
assert!(!xml.contains("agent.exe"), "{xml}");
assert!(xml.contains("<Enabled>false</Enabled>\n <UserId>me</UserId>"));
assert!(!xml.contains("<RestartOnFailure>"));
let launch: ServiceLaunch = serde_json::from_str(&launch).unwrap();
assert_eq!(launch.program, "C:\\Tools\\agent.exe");
assert_eq!(launch.args, "--serve");
assert_eq!(launch.environment["RUST_LOG"], "info");
assert!(
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with(&digest),
"{}",
path.display()
);
let mut changed = request.clone();
changed
.environment
.insert("RUST_LOG".into(), "debug".into());
assert_ne!(render_xml(&changed, "me").unwrap(), xml);
}
#[test]
fn what_cmd_would_have_reinterpreted_is_carried_as_written() {
let mut request = sample();
request.command = "C:\\Tools\\agent.exe --filter a&b|c<d>e^f".to_string();
request.launcher = Some("mise.exe".to_string());
request
.environment
.insert("P".into(), "%PATH%;C:\\x".into());
request
.environment
.insert("Q".into(), "a \"quoted\" & piped | value".into());
let xml = render_xml(&request, "me").unwrap();
assert!(xml.contains("<Command>mise.exe</Command>"), "{xml}");
let launch: ServiceLaunch =
serde_json::from_str(&render_launch(&request).unwrap().unwrap().0).unwrap();
assert_eq!(launch.args, "--filter a&b|c<d>e^f");
assert_eq!(launch.environment["P"], "%PATH%;C:\\x");
assert_eq!(launch.environment["Q"], "a \"quoted\" & piped | value");
}
#[test]
fn names_windows_cannot_hold_are_still_refused() {
for (key, value) in [("A=B", "1"), ("", "1"), ("A\0B", "1")] {
let mut request = sample();
request.launcher = Some("mise.exe".to_string());
request.environment.insert(key.into(), value.into());
let err = render_xml(&request, "me").unwrap_err().to_string();
assert!(
err.contains("cannot be a Windows environment"),
"{key:?}: {err}"
);
}
let mut request = sample();
request.launcher = Some("mise.exe".to_string());
request.environment.insert("A".into(), "one\0two".into());
let err = render_xml(&request, "me").unwrap_err().to_string();
assert!(err.contains("contains a NUL"), "{err}");
}
#[test]
fn a_name_that_looks_like_options_still_reaches_the_launcher() {
let mut request = ScheduledTaskRequest::new("-agent");
request.command = "C:\\Tools\\agent.exe --serve".to_string();
request.launcher = Some("mise.exe".to_string());
request.environment.insert("A".into(), "1".into());
let xml = render_xml(&request, "me").unwrap();
assert!(xml.contains(" -- -agent</Arguments>"), "{xml}");
}
#[test]
fn a_launch_path_with_spaces_is_quoted() {
assert_eq!(quote_argument("C:\\x\\y.json"), "C:\\x\\y.json");
assert_eq!(
quote_argument("C:\\Program Files\\y.json"),
"\"C:\\Program Files\\y.json\""
);
}
#[test]
fn carrying_an_environment_needs_mise() {
let mut request = sample();
request.environment.insert("A".into(), "1".into());
let err = render_xml(&request, "me").unwrap_err().to_string();
assert!(err.contains("install mise on this host first"), "{err}");
}
#[test]
fn no_environment_leaves_the_action_alone() {
let request = sample();
let xml = render_xml(&request, "me").unwrap();
assert!(xml.contains("<Command>C:\\Tools\\agent.exe</Command>"));
assert!(xml.contains("<Arguments>--serve</Arguments>"));
assert!(render_launch(&request).unwrap().is_none());
}
#[test]
fn tilde_expands_in_the_program() {
let (program, args) = split_command("~/.local/bin/agent --serve");
assert!(!program.starts_with('~'), "{program}");
assert!(program.ends_with("agent"), "{program}");
assert_eq!(args, "--serve");
}
#[test]
fn quoted_programs_keep_their_spaces() {
assert_eq!(
split_command("\"C:\\Program Files\\x\\a.exe\" --flag one"),
(
"C:\\Program Files\\x\\a.exe".to_string(),
"--flag one".to_string()
)
);
assert_eq!(
split_command("agent.exe"),
("agent.exe".to_string(), String::new())
);
}
#[test]
fn definition_is_utf16_with_bom() {
let bytes = render_definition(&sample(), "me").unwrap();
assert_eq!(&bytes[..2], &[0xFF, 0xFE]);
assert_eq!(&bytes[2..4], &[b'<', 0]);
}
#[test]
fn parses_query_output() {
let query = parse_query("Running\r\n").unwrap();
assert!(query.running);
assert!(!query.disabled);
let query = parse_query("Disabled\n").unwrap();
assert!(!query.running);
assert!(query.disabled);
let query = parse_query("Ready\n").unwrap();
assert!(!query.running && !query.disabled);
assert!(parse_query("MISSING\n").is_none());
}
#[test]
fn desired_state_follows_start() {
let mut status = ScheduledTaskStatus {
request: sample(),
path: PathBuf::from("x"),
state: ScheduledTaskState::Running,
};
assert!(status.is_desired());
status.request.start = false;
assert!(!status.is_desired());
status.state = ScheduledTaskState::Ready;
assert!(status.is_desired());
status.state = ScheduledTaskState::Differs;
assert!(!status.is_desired());
}
}