pub mod parser;
pub mod reader;
pub mod tokens;
use std::collections::HashMap;
use std::env;
use std::process::{Command, Stdio};
pub use self::parser::Parser;
pub use self::reader::{ReadError, ReadErrorDetail, Reader};
pub use self::tokens::{CronJob, JobDescription, JobSection, Token};
const DEFAULT_SHELL: &str = "/bin/sh";
#[derive(Debug)]
struct ShellCommand {
env: HashMap<String, String>,
shell: String,
home: String,
command: String,
}
#[derive(Debug, Eq, PartialEq)]
pub enum RunResultDetail {
DidRun {
exit_code: Option<i32>,
},
DidNotRun {
reason: String,
},
IsRunning { pid: u32 },
}
#[derive(Debug, Eq, PartialEq)]
pub struct RunResult {
pub was_successful: bool,
pub detail: RunResultDetail,
}
#[derive(Debug)]
pub struct Crontab {
pub tokens: Vec<Token>,
}
impl Crontab {
#[must_use]
pub fn new(tokens: Vec<Token>) -> Self {
Self { tokens }
}
#[must_use]
pub fn has_runnable_jobs(&self) -> bool {
self.tokens
.iter()
.any(|token| matches!(token, Token::CronJob(_)))
}
#[must_use]
pub fn jobs(&self) -> Vec<&CronJob> {
self.tokens
.iter()
.filter_map(|token| {
if let Token::CronJob(job) = token {
Some(job)
} else {
None
}
})
.collect()
}
#[must_use]
pub fn has_job(&self, job: &CronJob) -> bool {
self.jobs().iter().any(|x| *x == job)
}
#[must_use]
pub fn get_job_from_uid(&self, job_uid: u32) -> Option<&CronJob> {
self.jobs().into_iter().find(|job| job.uid == job_uid)
}
#[must_use]
pub fn run(&self, job: &CronJob) -> RunResult {
let mut command = match self.prepare_command(job) {
Ok(command) => command,
Err(res) => return res,
};
let status = command.status();
match status {
Ok(status) => RunResult {
was_successful: status.success(),
detail: RunResultDetail::DidRun {
exit_code: status.code(),
},
},
Err(_) => RunResult {
was_successful: false,
detail: RunResultDetail::DidNotRun {
reason: String::from("Failed to run command (does shell exist?)."),
},
},
}
}
#[must_use]
pub fn run_detached(&self, job: &CronJob) -> RunResult {
let mut command = match self.prepare_command(job) {
Ok(command) => command,
Err(res) => return res,
};
let child = command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
match child {
Ok(child) => RunResult {
was_successful: false,
detail: RunResultDetail::IsRunning { pid: child.id() },
},
Err(_) => RunResult {
was_successful: false,
detail: RunResultDetail::DidNotRun {
reason: String::from("Failed to run command (does shell exist?)."),
},
},
}
}
fn prepare_command(&self, job: &CronJob) -> Result<Command, RunResult> {
let shell_command = match self.make_shell_command(job) {
Ok(shell_command) => shell_command,
Err(reason) => {
return Err(RunResult {
was_successful: false,
detail: RunResultDetail::DidNotRun { reason },
});
}
};
let mut command = Command::new(shell_command.shell);
command
.envs(&shell_command.env)
.current_dir(shell_command.home)
.arg("-c")
.arg(shell_command.command);
Ok(command)
}
fn make_shell_command(&self, job: &CronJob) -> Result<ShellCommand, String> {
self.ensure_job_exists(job)?;
let mut env = self.extract_variables(job);
let shell = Self::determine_shell_to_use(&mut env);
let home = Self::determine_home_to_use(&mut env)?;
let command = job.command.clone();
Ok(ShellCommand {
env,
shell,
home,
command,
})
}
fn ensure_job_exists(&self, job: &CronJob) -> Result<(), String> {
if !self.has_job(job) {
return Err(String::from("The given job is not in the crontab."));
}
Ok(())
}
fn extract_variables(&self, target_job: &CronJob) -> HashMap<String, String> {
let mut variables: HashMap<String, String> = HashMap::new();
for token in &self.tokens {
if let Token::Variable(variable) = token {
variables.insert(variable.identifier.clone(), variable.value.clone());
} else if let Token::CronJob(job) = token {
if job == target_job {
break; }
}
}
variables
}
fn determine_shell_to_use(env: &mut HashMap<String, String>) -> String {
if let Some(shell) = env.remove("SHELL") {
shell
} else {
String::from(DEFAULT_SHELL)
}
}
fn determine_home_to_use(env: &mut HashMap<String, String>) -> Result<String, String> {
if let Some(home) = env.remove("HOME") {
Ok(home)
} else {
Ok(Self::get_home_directory()?)
}
}
fn get_home_directory() -> Result<String, String> {
if let Ok(home_directory) = env::var("HOME") {
Ok(home_directory)
} else {
Err(String::from(
"Could not read Home directory from environment.",
))
}
}
}
pub fn make_instance() -> Result<Crontab, ReadError> {
let crontab: String = Reader::read()?;
let tokens: Vec<Token> = Parser::parse(&crontab);
Ok(Crontab::new(tokens))
}
#[cfg(test)]
mod tests {
use super::tokens::{Comment, CommentKind, Variable};
use super::*;
fn tokens() -> Vec<Token> {
vec![
Token::Comment(Comment {
value: String::from("# CronRunner Demo"),
kind: CommentKind::Regular,
}),
Token::Comment(Comment {
value: String::from("# ---------------"),
kind: CommentKind::Regular,
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@reboot"),
command: String::from("/usr/bin/bash ~/startup.sh"),
description: None,
section: None,
}),
Token::Comment(Comment {
value: String::from(
"# Double-hash comments (##) immediately preceding a job are used as",
),
kind: CommentKind::Regular,
}),
Token::Comment(Comment {
value: String::from("# description. See below:"),
kind: CommentKind::Regular,
}),
Token::Comment(Comment {
value: String::from("## Update brew."),
kind: CommentKind::Description,
}),
Token::CronJob(CronJob {
uid: 2,
schedule: String::from("30 20 * * *"),
command: String::from("/usr/local/bin/brew update && /usr/local/bin/brew upgrade"),
description: Some(JobDescription(String::from("Update brew."))),
section: None,
}),
Token::Variable(Variable {
identifier: String::from("FOO"),
value: String::from("bar"),
}),
Token::Comment(Comment {
value: String::from("## Print variable."),
kind: CommentKind::Description,
}),
Token::CronJob(CronJob {
uid: 3,
schedule: String::from("* * * * *"),
command: String::from("echo $FOO"),
description: Some(JobDescription(String::from("Print variable."))),
section: None,
}),
Token::Comment(Comment {
value: String::from("# Do nothing (this is a regular comment)."),
kind: CommentKind::Regular,
}),
Token::CronJob(CronJob {
uid: 4,
schedule: String::from("@reboot"),
command: String::from(":"),
description: None,
section: None,
}),
Token::Variable(Variable {
identifier: String::from("SHELL"),
value: String::from("/bin/bash"),
}),
Token::CronJob(CronJob {
uid: 5,
schedule: String::from("@hourly"),
command: String::from("echo 'I am echoed by bash!'"),
description: None,
section: None,
}),
Token::Variable(Variable {
identifier: String::from("HOME"),
value: String::from("/home/<custom>"),
}),
Token::CronJob(CronJob {
uid: 6,
schedule: String::from("@yerly"),
command: String::from("./cleanup.sh"),
description: None,
section: None,
}),
]
}
#[test]
fn has_runnable_jobs() {
let crontab = Crontab::new(vec![Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@hourly"),
command: String::from("echo 'hello, world'"),
description: None,
section: None,
})]);
assert!(crontab.has_runnable_jobs());
}
#[test]
fn has_no_runnable_jobs() {
let crontab = Crontab::new(vec![
Token::Comment(Comment {
value: String::from("# This is a comment"),
kind: CommentKind::Regular,
}),
Token::Variable(Variable {
identifier: String::from("SHELL"),
value: String::from("/bin/bash"),
}),
]);
assert!(!crontab.has_runnable_jobs());
}
#[test]
fn has_no_runnable_jobs_because_crontab_is_empty() {
let crontab = Crontab::new(vec![]);
assert!(!crontab.has_runnable_jobs());
}
#[test]
fn list_of_jobs() {
let crontab = Crontab::new(tokens());
let tokens = tokens();
let jobs: Vec<&CronJob> = tokens
.iter()
.filter_map(|token| {
if let Token::CronJob(job) = token {
Some(job)
} else {
None
}
})
.collect();
assert_eq!(crontab.jobs(), jobs);
}
#[test]
fn has_job() {
let crontab = Crontab::new(vec![Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@daily"),
command: String::from("docker image prune --force"),
description: None,
section: None,
})]);
assert!(crontab.has_job(&CronJob {
uid: 1,
schedule: String::from("@daily"),
command: String::from("docker image prune --force"),
description: None,
section: None,
}),);
assert!(!crontab.has_job(&CronJob {
uid: 0,
schedule: String::from("@daily"),
command: String::from("docker image prune --force"),
description: None,
section: None,
}),);
assert!(!crontab.has_job(&CronJob {
uid: 1,
schedule: String::from("<invalid>"),
command: String::from("<invalid>"),
description: None,
section: None,
}),);
}
#[test]
fn get_job() {
let crontab = Crontab::new(vec![Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@reboot"),
command: String::from("echo 'hello, world'"),
description: None,
section: None,
})]);
let job = crontab.get_job_from_uid(1).unwrap();
assert_eq!(
*job,
CronJob {
uid: 1,
schedule: String::from("@reboot"),
command: String::from("echo 'hello, world'"),
description: None,
section: None,
}
);
}
#[test]
fn get_job_not_in_crontab() {
let crontab = Crontab::new(vec![Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@daily"),
command: String::from("echo 'hello, world'"),
description: None,
section: None,
})]);
let job = crontab.get_job_from_uid(42);
assert!(job.is_none());
}
#[test]
fn two_equal_jobs_are_treated_as_different_jobs() {
let crontab = Crontab::new(vec![
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@daily"),
command: String::from("df -h > ~/track_disk_usage.txt"),
description: Some(JobDescription(String::from("Track disk usage."))),
section: None,
}),
Token::Variable(Variable {
identifier: String::from("FOO"),
value: String::from("bar"),
}),
Token::CronJob(CronJob {
uid: 2,
schedule: String::from("@daily"),
command: String::from("df -h > ~/track_disk_usage.txt"),
description: Some(JobDescription(String::from("Track disk usage."))),
section: None,
}),
]);
let job = crontab.get_job_from_uid(2).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(
command.env,
HashMap::from([(String::from("FOO"), String::from("bar"))])
);
assert_eq!(command.command, "df -h > ~/track_disk_usage.txt");
}
#[test]
fn working_directory_is_home_directory() {
env::set_var("HOME", "/home/<test>");
let home_directory = Crontab::get_home_directory().unwrap();
assert_eq!(home_directory, "/home/<test>");
}
#[test]
fn run_cron_without_variable() {
let crontab = Crontab::new(vec![Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@reboot"),
command: String::from("/usr/bin/bash ~/startup.sh"),
description: Some(JobDescription(String::from("Description."))),
section: None,
})]);
let job = crontab.get_job_from_uid(1).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(command.command, "/usr/bin/bash ~/startup.sh");
}
#[test]
fn run_cron_with_variable() {
let crontab = Crontab::new(vec![
Token::Variable(Variable {
identifier: String::from("FOO"),
value: String::from("bar"),
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("* * * * *"),
command: String::from("echo $FOO"),
description: Some(JobDescription(String::from("Print variable."))),
section: None,
}),
]);
let job = crontab.get_job_from_uid(1).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(
command.env,
HashMap::from([(String::from("FOO"), String::from("bar"))])
);
assert_eq!(command.command, "echo $FOO");
}
#[test]
fn run_cron_after_variable_but_not_right_after_it() {
let crontab = Crontab::new(vec![
Token::Variable(Variable {
identifier: String::from("FOO"),
value: String::from("bar"),
}),
Token::Comment(Comment {
value: String::from("## Print variable."),
kind: CommentKind::Description,
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("* * * * *"),
command: String::from("echo $FOO"),
description: Some(JobDescription(String::from("Print variable."))),
section: None,
}),
Token::Comment(Comment {
value: String::from("# Do nothing (this is a regular comment)."),
kind: CommentKind::Regular,
}),
Token::CronJob(CronJob {
uid: 2,
schedule: String::from("@reboot"),
command: String::from(":"),
description: None,
section: None,
}),
]);
let job = crontab.get_job_from_uid(2).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(
command.env,
HashMap::from([(String::from("FOO"), String::from("bar"))])
);
assert_eq!(command.command, ":");
}
#[test]
fn double_variable_change() {
let crontab = Crontab::new(vec![
Token::Variable(Variable {
identifier: String::from("FOO"),
value: String::from("bar"),
}),
Token::Variable(Variable {
identifier: String::from("FOO"),
value: String::from("baz"),
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("30 9 * * * "),
command: String::from("echo 'gm'"),
description: None,
section: None,
}),
]);
let job = crontab.get_job_from_uid(1).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(
command.env,
HashMap::from([(String::from("FOO"), String::from("baz"))])
);
assert_eq!(command.command, "echo 'gm'");
}
#[test]
fn run_cron_with_default_shell() {
let crontab = Crontab::new(vec![Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@reboot"),
command: String::from("cat a-file.txt"),
description: None,
section: None,
})]);
let job = crontab.get_job_from_uid(1).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(command.shell, DEFAULT_SHELL);
assert_eq!(command.command, "cat a-file.txt");
}
#[test]
fn run_cron_with_different_shell() {
let crontab = Crontab::new(vec![
Token::Variable(Variable {
identifier: String::from("SHELL"),
value: String::from("/bin/bash"),
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@hourly"),
command: String::from("echo 'I am echoed by bash!'"),
description: None,
section: None,
}),
]);
let job = crontab.get_job_from_uid(1).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(command.env, HashMap::new());
assert_eq!(command.shell, "/bin/bash");
assert_eq!(command.command, "echo 'I am echoed by bash!'");
}
#[test]
fn shell_variable_is_removed_from_env() {
let crontab = Crontab::new(vec![
Token::Variable(Variable {
identifier: String::from("SHELL"),
value: String::from("/bin/<custom>"),
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@hourly"),
command: String::from("echo 'I am echoed by a custom shell!'"),
description: None,
section: None,
}),
]);
let job = crontab.get_job_from_uid(1).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert!(!command.env.contains_key("SHELL"));
assert_eq!(command.shell, "/bin/<custom>");
}
#[test]
fn double_shell_change() {
let crontab = Crontab::new(vec![
Token::Variable(Variable {
identifier: String::from("SHELL"),
value: String::from("/bin/bash"),
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@hourly"),
command: String::from("echo 'I am echoed by bash!'"),
description: None,
section: None,
}),
Token::Variable(Variable {
identifier: String::from("SHELL"),
value: String::from("/bin/zsh"),
}),
Token::CronJob(CronJob {
uid: 2,
schedule: String::from("@hourly"),
command: String::from("echo 'I am echoed by zsh!'"),
description: None,
section: None,
}),
]);
let job = crontab.get_job_from_uid(2).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(command.shell, "/bin/zsh");
assert_eq!(command.command, "echo 'I am echoed by zsh!'");
}
#[test]
fn run_cron_with_default_home() {
env::set_var("HOME", "/home/<default>");
let crontab = Crontab::new(vec![Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@daily"),
command: String::from("/usr/bin/bash ~/startup.sh"),
description: None,
section: None,
})]);
let job = crontab.get_job_from_uid(1).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(command.home, "/home/<default>");
}
#[test]
fn run_cron_with_different_home() {
env::set_var("HOME", "/home/<default>");
let crontab = Crontab::new(vec![
Token::Variable(Variable {
identifier: String::from("HOME"),
value: String::from("/home/<custom>"),
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@yearly"),
command: String::from("./cleanup.sh"),
description: None,
section: None,
}),
]);
let job = crontab.get_job_from_uid(1).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(command.env, HashMap::new());
assert_eq!(command.home, "/home/<custom>");
assert_eq!(command.command, "./cleanup.sh");
}
#[test]
fn home_variable_is_removed_from_env() {
let crontab = Crontab::new(vec![
Token::Variable(Variable {
identifier: String::from("HOME"),
value: String::from("/home/<custom>"),
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@hourly"),
command: String::from("echo 'I am echoed in a different Home!'"),
description: None,
section: None,
}),
]);
let job = crontab.get_job_from_uid(1).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert!(!command.env.contains_key("HOME"));
assert_eq!(command.home, "/home/<custom>");
}
#[test]
fn get_home_directory_error() {
env::remove_var("HOME");
let crontab = Crontab::new(vec![Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@reboot"),
command: String::from("/usr/bin/bash ~/startup.sh"),
description: None,
section: None,
})]);
let job = crontab.get_job_from_uid(1).unwrap();
let error = crontab
.make_shell_command(job)
.expect_err("should be an error");
assert_eq!(error, "Could not read Home directory from environment.");
env::set_var("HOME", "/home/<test>");
}
#[test]
fn double_home_change() {
let crontab = Crontab::new(vec![
Token::Variable(Variable {
identifier: String::from("HOME"),
value: String::from("/home/user1"),
}),
Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@hourly"),
command: String::from("echo 'I run is user1's Home!'"),
description: None,
section: None,
}),
Token::Variable(Variable {
identifier: String::from("HOME"),
value: String::from("/home/user2"),
}),
Token::CronJob(CronJob {
uid: 2,
schedule: String::from("@hourly"),
command: String::from("echo 'I run is user2's Home!'"),
description: None,
section: None,
}),
]);
let job = crontab.get_job_from_uid(2).unwrap();
let command = crontab.make_shell_command(job).unwrap();
assert_eq!(command.home, "/home/user2");
assert_eq!(command.command, "echo 'I run is user2's Home!'");
}
#[test]
fn run_cron_with_non_existing_job() {
let crontab = Crontab::new(vec![Token::CronJob(CronJob {
uid: 1,
schedule: String::from("@hourly"),
command: String::from("echo 'I am echoed by bash!'"),
description: None,
section: None,
})]);
let job_not_in_crontab = CronJob {
uid: 42,
schedule: String::from("@never"),
command: String::from("sleep infinity"),
description: None,
section: None,
};
let error = crontab
.make_shell_command(&job_not_in_crontab)
.expect_err("the job is not in the crontab");
assert_eq!(error, "The given job is not in the crontab.");
}
}