diff --git a/Cargo.toml b/Cargo.toml index d1412a65..6db0bc66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "2" members = [ "awk", "calc", + "cron", "datetime", "dev", "display", @@ -26,7 +27,8 @@ members = [ "tree", "users", "xform", - "i18n" + "i18n", + "cron", ] [workspace.package] @@ -38,11 +40,12 @@ rust-version = "1.84.0" [workspace.dependencies] clap = { version = "4", default-features = false, features = ["std", "derive", "help", "usage", "error-context", "cargo"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } +chrono-tz = "0.10.0" libc = "0.2" regex = "1.10" gettext-rs = { path = "./gettext-rs" } errno = "0.3" -chrono-tz = "0.10" +tempfile = "3.14" [workspace.lints] diff --git a/cron/Cargo.toml b/cron/Cargo.toml new file mode 100644 index 00000000..2b87f8ba --- /dev/null +++ b/cron/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "posixutils-cron" +version = "0.1.0" +edition = "2021" + +[dependencies] +gettext-rs = { workspace = true } +plib = { path = "../plib" } +clap = { workspace = true } +libc = { workspace = true } +chrono = { workspace = true } +chrono-tz = { workspace = true } +tempfile = { workspace = true } + +[[bin]] +name = "at" +path = "./at.rs" + +[[bin]] +name = "batch" +path = "./batch.rs" + +[[bin]] +name = "crontab" +path = "./crontab.rs" + +[[bin]] +name = "crond" +path = "./crond.rs" + +[lib] +name = "cron" +path = "./lib.rs" \ No newline at end of file diff --git a/cron/at.rs b/cron/at.rs new file mode 100644 index 00000000..e9dc4be2 --- /dev/null +++ b/cron/at.rs @@ -0,0 +1,2836 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use chrono::{DateTime, Utc}; +use clap::Parser; +use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; +use libc::{getlogin, getpwnam, passwd}; +use timespec::Timespec; + +use std::{ + collections::{BTreeMap, HashSet}, + env, + ffi::{CStr, CString}, + fmt::Display, + fs::{self, File}, + io::{BufRead, Read, Seek, Write}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process, + str::FromStr, +}; + +#[cfg(target_os = "linux")] +const SPOOL_DIRECTORIES: &[&str] = &[ + "/var/spool/cron/atjobs/", + "/var/spool/at/", + "/var/spool/atjobs/", +]; + +#[cfg(target_os = "macos")] +const MACOS_DIRECTORY: &str = "/var/at/jobs/"; + +#[cfg(target_os = "linux")] +const DEFAULT_DIRECTORY: &str = "/var/spool/atjobs/"; + +/// at - execute commands at a later time +#[derive(Parser, Debug)] +#[command( + author, + version, + about = gettext("at - execute commands at a later time"), + long_about = gettext("The 'at' command schedules commands to be executed later.\n\ + Usage:\n\ + at [-m] [-f file] [-q queuename] -t time_arg\n\ + at [-m] [-f file] [-q queuename] timespec...\n\ + at -r at_job_id...\n\ + at -l -q queuename\n\ + at -l [at_job_id...]" +))] +struct Args { + /// Submit the job to be run at the date and time specified. + #[arg(value_name = "TIMESPEC", required = false, help = gettext("Submit the job to be run at the date and time specified."))] + timespec: Option, + /// Displays a list of all scheduled jobs + #[arg(short = 'l', long = "list", help = gettext("Displays a list of all scheduled jobs"))] + list: bool, + + /// Specifies the pathname of a file to be used as the source of the at-job, instead of standard input. + #[arg(short = 'f', long = "file", value_name = "FILE", help = gettext("Specifies the pathname of a file to be used as the source of the at-job, instead of standard input."))] + file: Option, + + /// Send mail to the invoking user after the at-job has run. + #[arg(short = 'm', long = "mail", help = gettext("Send mail to the invoking user after the at-job has run."))] + mail: bool, + + /// Specify in which queue to schedule a job for submission. + #[arg(short = 'q', long = "queue", value_name = "QUEUENAME", help = gettext("Specify in which queue to schedule a job for submission."))] + queue: Option, + + /// Remove the jobs with the specified at_job_id operands that were previously scheduled by the at utility. + #[arg(short = 'r', long = "remove", help = gettext("Remove the jobs with the specified at_job_id operands that were previously scheduled by the at utility."))] + remove: bool, + + /// Submit the job to be run at the time specified by the time option-argument. + #[arg(short = 't', long = "time", value_name = "TIME_ARG", help = gettext("Submit the job to be run at the time specified by the time option-argument."))] + time: Option, + + /// Job IDs for reporting jobs scheduled for the invoking user. + #[arg(value_name = "AT_JOB_ID", required = false, help = gettext("Job IDs for reporting jobs scheduled"))] + at_job_ids: Vec, +} + +impl Args { + pub fn validate_args(&mut self) -> Result<(), String> { + // Check for incompatibility of the `-l` (list jobs) option with other options + if self.list { + if let Some(timespec) = self.timespec.take() { + let id = timespec + .parse() + .map_err(|e| format!("Failed to parse job ID. Reason: {e}"))?; + self.at_job_ids.push(id); + } + if self.remove || self.time.is_some() || self.file.is_some() || self.mail { + return Err("Option '-l' cannot be used with '-r', '-t', '-m' or -f".to_string()); + } + + if self.queue.is_some() && !self.at_job_ids.is_empty() { + return Err("at -l -q queueename cannot be used with AT_JOB_IDs".to_string()); + } + } + + // Check for incompatibility of the `-r` (remove jobs) option with other options + if self.remove { + if let Some(timespec) = self.timespec.take() { + let id = timespec + .parse() + .map_err(|e| format!("Failed to parse job ID. Reason: {e}"))?; + self.at_job_ids.push(id); + } + + if self.list + || self.time.is_some() + || self.file.is_some() + || self.mail + || self.queue.is_some() + { + return Err( + "Option '-r' cannot be used with '-l', '-t', '-m', '-q' or '-f'".to_string(), + ); + } + } + + // Checking if `TIMESPEC` and `-t` are specified at the same time + if self.timespec.is_some() && self.time.is_some() { + return Err("Options TIMESPEC and '-t' cannot be used together".to_string()); + } + + // Check if `TIMESPEC` or `-t` is specified with `AT_JOB_ID` + if (!self.at_job_ids.is_empty()) && (self.timespec.is_some() || self.time.is_some()) { + return Err("AT_JOB_ID cannot be used with TIMESPEC or '-t'".to_string()); + } + + // Checking the queue for correctness + if let Some(queue) = self.queue { + if !queue.is_ascii_lowercase() { + return Err( + "Invalid queue name. Queue must be a single lowercase ASCII letter." + .to_string(), + ); + } + } + + // If all checks are successful + Ok(()) + } +} + +fn main() -> Result<(), Box> { + let mut args = Args::try_parse().unwrap_or_else(|err| { + eprintln!("{}", err); + std::process::exit(1); + }); + + args.validate_args()?; + + setlocale(LocaleCategory::LcAll, ""); + textdomain("posixutils-rs")?; + bind_textdomain_codeset("posixutils-rs", "UTF-8")?; + + if args.remove { + remove_jobs(&args.at_job_ids)?; + return Ok(()); + } + + if args.list { + let list = list_jobs(get_job_dir()?); + if list.is_empty() { + return Ok(()); + } + if let Some(queue) = args.queue { + let queue_jobs = jobs_in_queue(queue, &list); + for job in queue_jobs { + println!("{}", job); + } + } else if !args.at_job_ids.is_empty() { + for id in args.at_job_ids { + if let Some(job) = list.get(&id) { + println!("{}", job); + } else { + eprintln!("Job with ID {} not found", id); + } + } + } else { + for job in list.values() { + println!("{}", job); + } + } + return Ok(()); + } + + let time = match (args.time, args.timespec) { + (None, None) => print_err_and_exit(1, "You need `timespec` arg or `-t` flag"), + (None, Some(timespec)) => Timespec::from_str(×pec)? + .to_date_time() + .ok_or("Failed to parse `timespec` did you set too big date?")?, + (Some(time), None) => time::parse_time_posix(&time)?, + (Some(_), Some(_)) => print_err_and_exit( + 1, + "You can't specify time twice. Use only `timespec` arg or `-t` flag", + ), + }; + + let cmd = match args.file { + Some(path) => { + let path = match path.is_absolute() { + true => path, + false => std::env::current_dir().ok().unwrap_or_default().join(path), + }; + + let mut file = File::open(path) + .map_err(|e| format!("Failed to open command file. Reason: {e}"))?; + + let mut buf = String::new(); + + file.read_to_string(&mut buf) + .map_err(|e| format!("Failed to read command file. Reason: {e}"))?; + + buf + } + None => { + let stdout = std::io::stdout(); + let mut stdout_lock = stdout.lock(); + + writeln!(&mut stdout_lock, "at {}", time.to_rfc2822())?; + write!(&mut stdout_lock, "at> ")?; + stdout_lock.flush()?; + + let stdin = std::io::stdin(); + let mut stdin_lock = stdin.lock(); + + let mut result = Vec::new(); + let mut buf = String::new(); + + while stdin_lock.read_line(&mut buf)? != 0 { + write!(&mut stdout_lock, "at> ")?; + stdout_lock.flush()?; + + result.push(buf.to_owned()); + } + + writeln!(&mut stdout_lock, "")?; + stdout_lock.flush()?; + + result.join("\n") + } + }; + + let _ = at(args.queue, &time, cmd, args.mail).inspect_err(|err| print_err_and_exit(1, err)); + + Ok(()) +} + +/// Returns the path to the jobs directory, adjusted for the operating system. +/// On Linux: checks the `AT_JOB_DIR` environment variable, then predefined directories. +/// On macOS: checks or creates the `/var/at/jobs` directory. +fn get_job_dir() -> Result { + // Check `AT_JOB_DIR` environment variable + if let Ok(env_dir) = env::var("AT_JOB_DIR") { + if Path::new(&env_dir).exists() { + return Ok(env_dir); + } + } + #[cfg(target_os = "linux")] + { + // Check the predefined spool directories + for dir in SPOOL_DIRECTORIES { + if Path::new(dir).exists() { + return Ok(dir.to_string()); + } + } + + // Create the default directory if none exist + let default_path = Path::new(DEFAULT_DIRECTORY); + if !default_path.exists() { + if let Err(err) = fs::create_dir_all(default_path) { + return Err(format!( + "Failed to create directory {}: {}", + DEFAULT_DIRECTORY, err + )); + } + } + + Ok(DEFAULT_DIRECTORY.to_string()) + } + #[cfg(target_os = "macos")] + { + let macos_path = Path::new(MACOS_DIRECTORY); + + if !macos_path.exists() { + if let Err(err) = fs::create_dir_all(macos_path) { + return Err(format!( + "Failed to create directory {}: {}", + MACOS_DIRECTORY, err + )); + } + } + + Ok(MACOS_DIRECTORY.to_string()) + } +} + +/// Checks if the file name matches the job format +fn is_job_file(file_name: &str) -> bool { + file_name.len() == 14 + && file_name + .chars() + .next() + .map(|c| c.is_ascii_alphabetic()) + .unwrap_or(false) +} + +/// Structure representing a job +#[derive(Debug)] +struct JobInfo { + id: u32, + queue: char, + file_name: String, + formatted_time: String, +} + +impl Display for JobInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} {} {}", + self.id, self.formatted_time, self.queue + ) + } +} + +/// Parses the file name and returns JobInfo +fn parse_job_file(file_name: &str) -> Option<(u32, JobInfo)> { + let queue = &file_name.chars().next().unwrap(); + let job_id_str = &file_name[1..6]; + let time_str = &file_name[6..14]; + + let job_id = u32::from_str_radix(job_id_str, 16).ok()?; + let duration_seconds = u32::from_str_radix(time_str, 16).ok()? * 60; + let formatted_time = format_execution_time(duration_seconds); + + Some(( + job_id, + JobInfo { + id: job_id, + queue: *queue, + file_name: file_name.to_string(), + formatted_time, + }, + )) +} + +fn jobs_in_queue(queue_name: char, jobs: &BTreeMap) -> Vec<&JobInfo> { + jobs.values() + .filter(|job| job.queue == queue_name) + .collect() +} + +/// Removes jobs with the given job IDs +fn remove_jobs(job_ids: &[u32]) -> Result<(), String> { + let job_dir = get_job_dir()?; + let path = Path::new(&job_dir); + // get a list of all jobs + let jobs = list_jobs(path); + + // Go through all the job identifiers + for job_id in job_ids { + if let Some(job_info) = jobs.get(job_id) { + let file_path = path.join(&job_info.file_name); + // Delete the file + if let Err(e) = fs::remove_file(&file_path) { + return Err(format!( + "Could not delete a file {}: {}", + job_info.file_name, e + )); + } + } else { + return Err(format!("The job with ID {} was not found.", job_id)); + } + } + Ok(()) +} + +/// Formats the execution time as `Thu Dec 12 10:44:00 2024` +fn format_execution_time(duration_seconds: u32) -> String { + let datetime = DateTime::from_timestamp(duration_seconds as i64, 0); + if let Some(dt) = datetime { + dt.format("%a %b %d %H:%M:%S %Y").to_string() + } else { + "Invalid time".to_string() + } +} + +/// Scans the directory, collects the jobs +fn list_jobs

(dir: P) -> BTreeMap +where + P: AsRef, +{ + let mut queues: BTreeMap = BTreeMap::new(); + + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + if let Ok(file_name) = entry.file_name().into_string() { + if is_job_file(&file_name) { + if let Some((job_id, job_info)) = parse_job_file(&file_name) { + queues.insert(job_id, job_info); + } + } + } + } + } + + queues +} + +fn print_err_and_exit(exit_code: i32, err: impl std::fmt::Display) -> ! { + eprintln!("{}", err); + process::exit(exit_code) +} + +fn at( + queue: Option, + execution_time: &DateTime, + cmd: impl Into, + mail: bool, +) -> Result<(), Box> { + let jobno = next_job_id()?; + let job_filename = job_file_name(jobno, queue, execution_time) + .ok_or("Failed to generate file name for job")?; + + let user = User::new().ok_or("Failed to get current user")?; + if !is_user_allowed(&user.name) { + return Err(format!("Access denied for user: {}", &user.name).into()); + } + + let job = Job::new(&user, std::env::current_dir()?, std::env::vars(), cmd, mail).into_script(); + + let mut file_opt = std::fs::OpenOptions::new(); + file_opt.read(true).write(true).create_new(true); + + let file_path = PathBuf::from(format!("{}/{job_filename}", get_job_dir()?)); + + let mut file = file_opt + .open(&file_path) + .map_err(|e| format!("Failed to create file with job. Reason: {e}"))?; + + file.write_all(job.as_bytes())?; + + file.set_permissions(std::fs::Permissions::from_mode(0o700))?; + + println!( + "job {} at {}", + jobno, + execution_time.format("%a %b %d %H:%M:%S %Y") + ); + + Ok(()) +} + +/// Structure to represent future job or script to be saved +pub struct Job { + shell: String, + user_uid: u32, + user_gid: u32, + user_name: String, + env: std::env::Vars, + call_place: PathBuf, + cmd: String, + mail: bool, +} + +impl Job { + pub fn new( + User { + shell, + uid, + gid, + name, + }: &User, + call_place: PathBuf, + env: std::env::Vars, + cmd: impl Into, + mail: bool, + ) -> Self { + Self { + shell: shell.to_owned(), + user_uid: *uid, + user_gid: *gid, + user_name: name.to_owned(), + env, + call_place, + cmd: cmd.into(), + mail, + } + } + + pub fn into_script(self) -> String { + let Self { + shell, + user_uid, + user_gid, + user_name, + env, + call_place, + cmd, + mail, + } = self; + + let env = env + .into_iter() + .map(|(key, value)| format!("{}={}; export {}", key, value, key)) + .collect::>() + .join("\n"); + + format!( + "#!{shell}\n# atrun uid={user_uid} gid={user_gid}\n# mail {user_name} {}\numask 22\n{env}\ncd {} || {{\n\techo 'Execution directory inaccessible' >&2\n\texit 1 \n}}\n{cmd}", + if mail {1} else {0}, + call_place.to_string_lossy() + ) + } +} + +/// Return name for job number +/// +/// None if DateTime < [DateTime::UNIX_EPOCH] +fn job_file_name(next_job: u32, queue: Option, time: &DateTime) -> Option { + let duration = time.signed_duration_since(DateTime::UNIX_EPOCH); + let duration_seconds = u32::try_from(duration.num_seconds()).ok()? / 60; + let queue = queue.unwrap_or('a'); + let result = format!("{queue}{next_job:05x}{duration_seconds:08x}"); + + Some(result) +} + +#[derive(Debug)] +pub enum NextJobError { + Io(std::io::Error), + FromStr(std::num::ParseIntError), +} + +impl std::fmt::Display for NextJobError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error reading id of next job. Reason: ")?; + match self { + NextJobError::Io(err) => writeln!(f, "{err}"), + NextJobError::FromStr(err) => writeln!(f, "invalid number - {err}"), + } + } +} + +impl std::error::Error for NextJobError {} + +fn next_job_id() -> Result> { + let mut file_opt = std::fs::OpenOptions::new(); + file_opt.read(true).write(true); + + let mut buf = String::new(); + let job_file_number = format!("{}.SEQ", get_job_dir()?); + + let (next_job_id, mut file) = match file_opt.open(&job_file_number) { + Ok(mut file) => { + file.read_to_string(&mut buf).map_err(NextJobError::Io)?; + file.rewind().map_err(NextJobError::Io)?; + + ( + u32::from_str_radix(buf.trim_end_matches("\n"), 16) + .map_err(NextJobError::FromStr)?, + file, + ) + } + Err(err) => match err.kind() { + std::io::ErrorKind::NotFound => ( + 0, + std::fs::File::create_new(job_file_number).map_err(NextJobError::Io)?, + ), + + _ => Err(NextJobError::Io(err))?, + }, + }; + + // Limit range of jobs to 2^20 jobs + let next_job_id = (1 + next_job_id) % 0xfffff; + + file.write_all(format!("{next_job_id:05x}").as_bytes()) + .map_err(NextJobError::Io)?; + + Ok(next_job_id) +} + +fn read_user_file(file_path: &str) -> std::io::Result> { + let content = std::fs::read_to_string(file_path)?; + Ok(content + .lines() + .map(|line| line.trim().to_string()) + .collect()) +} + +fn is_user_allowed(user: &str) -> bool { + let allow_file = "/etc/at.allow"; + let deny_file = "/etc/at.deny"; + + if let Ok(allowed_users) = read_user_file(allow_file) { + // If at.allow exists, only users from this file have access + return allowed_users.contains(user); + } + + if let Ok(denied_users) = read_user_file(deny_file) { + // If there is no at.allow, but there is at.deny, check if the user is blacklisted + return !denied_users.contains(user); + } + + // If there are no files, access is allowed to all + true +} + +fn login_name() -> Option { + // Try to get the login name using getlogin + unsafe { + let login_ptr = getlogin(); + if !login_ptr.is_null() { + if let Ok(c_str) = CStr::from_ptr(login_ptr).to_str() { + return Some(c_str.to_string()); + } + } + } + + // Fall back to checking the LOGNAME environment variable + env::var("LOGNAME").ok() +} + +pub struct User { + pub name: String, + pub shell: String, + pub uid: u32, + pub gid: u32, +} + +impl User { + pub fn new() -> Option { + const DEFAULT_SHELL: &str = "/bin/sh"; + + let login_name = login_name()?; + + let passwd { + pw_uid, + pw_gid, + pw_shell, + .. + } = user_info_by_name(&login_name)?; + + let pw_shell = match pw_shell.is_null() { + true => std::env::var("SHELL") + .ok() + .unwrap_or(DEFAULT_SHELL.to_owned()), + false => unsafe { + CStr::from_ptr(pw_shell) + .to_str() + .ok() + .unwrap_or(DEFAULT_SHELL) + .to_owned() + }, + }; + + Some(Self { + shell: pw_shell, + uid: pw_uid, + gid: pw_gid, + name: login_name, + }) + } +} + +fn user_info_by_name(name: &str) -> Option { + let c_name = CString::new(name).unwrap(); + let pw_ptr = unsafe { getpwnam(c_name.as_ptr()) }; + if pw_ptr.is_null() { + None + } else { + Some(unsafe { *pw_ptr }) + } +} + +mod time { + use chrono::{offset::LocalResult, DateTime, Datelike, TimeZone, Utc}; + + // Copy from `touch` + pub fn parse_time_posix(time: &str) -> Result, Box> { + let mut time = String::from(time); + let mut seconds = String::from("0"); + + // split into YYYYMMDDhhmm and [.SS] components + let mut tmp_time = String::new(); + + if let Some((t, secs)) = time.split_once('.') { + tmp_time = t.to_string(); + seconds = secs.to_string(); + } + + if !tmp_time.is_empty() { + time = tmp_time; + } + + // extract date and time elements, with length implying format + let tmp_year; + let (year_str, month_str, day_str, hour_str, minute_str) = match time.len() { + // format: MMDDhhmm[.SS] + 8 => { + tmp_year = Utc::now().year().to_string(); + ( + tmp_year.as_str(), + &time[0..2], + &time[2..4], + &time[4..6], + &time[6..8], + ) + } + + // format: YYMMDDhhmm[.SS] + 10 => { + let mut yearling = time[0..2].parse::()?; + if yearling <= 68 { + yearling += 2000; + } else { + yearling += 1900; + } + tmp_year = yearling.to_string(); + ( + tmp_year.as_str(), + &time[2..4], + &time[4..6], + &time[6..8], + &time[8..10], + ) + } + + // format: YYYYMMDDhhmm[.SS] + 12 => ( + &time[0..4], + &time[4..6], + &time[6..8], + &time[8..10], + &time[10..12], + ), + _ => { + return Err("Invalid time format".into()); + } + }; + + // convert strings to integers + let year = year_str.parse::()?; + let month = month_str.parse::()?; + let day = day_str.parse::()?; + let hour = hour_str.parse::()?; + let minute = minute_str.parse::()?; + let secs = seconds.parse::()?; + + // convert to DateTime and validate input + let res = Utc.with_ymd_and_hms(year, month, day, hour, minute, secs); + if res == LocalResult::None { + return Err("Invalid time".into()); + } + + // return parsed date + let dt = res.unwrap(); + Ok(dt) + } +} + +mod timespec { + use std::str::FromStr; + + use chrono::{ + DateTime, Datelike, Days, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc, + }; + + use crate::tokens::{ + AmPm, DayNumber, DayOfWeek, Hr24Clock, Hr24ClockHour, Minute, Month, TimezoneName, + TokenParsingError, WallClock, WallClockHour, YearNumber, + }; + + #[derive(Debug, PartialEq)] + pub enum TimespecParsingError { + IncPeriodPatternNotFound(String), + IncrementParsing { + err: std::num::ParseIntError, + input: String, + }, + IncrementPatternNotFound(String), + DateTokenParsing(TokenParsingError), + DatePatternNotFound(String), + TimeTokenParsing(TokenParsingError), + TimePatternNotFound(String), + NowspecParsing(String), + NowspecPatternNotFound(String), + TimespecPatternNotFound(String), + } + + impl std::fmt::Display for TimespecParsingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Failed to parse token in str") + } + } + + impl std::error::Error for TimespecParsingError {} + + #[derive(Debug, PartialEq)] + pub enum IncPeriod { + Minute, + Hour, + Day, + Week, + Month, + Year, + } + + impl FromStr for IncPeriod { + type Err = TimespecParsingError; + + fn from_str(s: &str) -> Result { + let result = match s { + "minute" | "minutes" => Self::Minute, + "hour" | "hours" => Self::Hour, + "day" | "days" => Self::Day, + "week" | "weeks" => Self::Week, + "month" | "months" => Self::Month, + "year" | "years" => Self::Year, + _ => Err(TimespecParsingError::IncPeriodPatternNotFound(s.to_owned()))?, + }; + + Ok(result) + } + } + + #[derive(Debug, PartialEq)] + pub enum Increment { + Next(IncPeriod), + Plus { number: u16, period: IncPeriod }, + } + + impl Increment { + pub fn to_duration(&self) -> std::time::Duration { + fn increment_date(number: Option, period: &IncPeriod) -> std::time::Duration { + let number = u64::from(number.unwrap_or(1)); + + match period { + IncPeriod::Minute => std::time::Duration::from_secs(60 * number), + IncPeriod::Hour => std::time::Duration::from_secs((60 * 60) * number), + IncPeriod::Day => std::time::Duration::from_secs((60 * 60 * 24) * number), + IncPeriod::Week => std::time::Duration::from_secs((60 * 60 * 24 * 7) * number), + IncPeriod::Month => std::time::Duration::from_secs(2628000 * number), + IncPeriod::Year => std::time::Duration::from_secs(31557600 * number), + } + } + + match self { + Increment::Next(period) => increment_date(None, period), + Increment::Plus { number, period } => increment_date(Some(*number), period), + } + } + } + + impl FromStr for Increment { + type Err = TimespecParsingError; + + fn from_str(s: &str) -> Result { + let result = match s.starts_with("+") { + true => { + let number: u16 = s + .chars() + .skip(1) + .take_while(|this| this.is_numeric()) + .collect::() + .parse() + .map_err(|err| TimespecParsingError::IncrementParsing { + err, + input: s.to_owned(), + })?; + + let period = s + .chars() + .skip(1) + .skip_while(|this| this.is_numeric()) + .collect::() + .parse::()?; + + Self::Plus { number, period } + } + false => match s.starts_with("next") { + true => Self::Next(IncPeriod::from_str(&s.replace("next", ""))?), + false => Err(TimespecParsingError::IncrementPatternNotFound(s.to_owned()))?, + }, + }; + + Ok(result) + } + } + + #[derive(Debug, PartialEq)] + pub enum Date { + MontDay { + month_name: Month, + day_number: DayNumber, + }, + MontDayYear { + month_name: Month, + day_number: DayNumber, + year_number: YearNumber, + }, + DayOfWeek(DayOfWeek), + Today, + Tomorrow, + } + + impl Date { + pub fn to_naive_date(&self) -> Option { + match self { + Date::MontDay { + month_name, + day_number, + } => NaiveDate::from_ymd_opt( + Utc::now().year(), + u32::from(month_name.0) + 1, + day_number.0.get().into(), + ), + Date::MontDayYear { + month_name, + day_number, + year_number, + } => NaiveDate::from_ymd_opt( + year_number.0.into(), + u32::from(month_name.0) + 1, + day_number.0.get().into(), + ), + Date::DayOfWeek(day_of_week) => { + let now = Utc::now(); + + // Check if date should be in current week or next + let n = match day_of_week.0.num_days_from_sunday() + > now.weekday().number_from_sunday() + { + true => 1, + false => 2, + }; + + NaiveDate::from_weekday_of_month_opt(now.year(), now.month(), day_of_week.0, n) + } + Date::Today => Some(Utc::now().date_naive()), + Date::Tomorrow => Utc::now() + .checked_add_days(Days::new(1)) + .map(|this| this.date_naive()), + } + } + } + + impl FromStr for Date { + type Err = TimespecParsingError; + + fn from_str(s: &str) -> Result { + let result = match s { + "today" => Self::Today, + "tomorrow" => Self::Tomorrow, + _ => match s.contains(",") { + true => { + let parts = s.split(',').collect::>(); + + if parts.len() != 2 { + Err(TimespecParsingError::DatePatternNotFound(s.to_owned()))? + } + + let (month_name, day_number) = parse_month_and_day(parts[0])?; + let year_number = YearNumber::from_str(parts[1]) + .map_err(TimespecParsingError::DateTokenParsing)?; + + Self::MontDayYear { + month_name, + day_number, + year_number, + } + } + false => match DayOfWeek::from_str(s) { + Ok(day) => Self::DayOfWeek(day), + Err(_) => { + let (month_name, day_number) = parse_month_and_day(s)?; + + Self::MontDay { + month_name, + day_number, + } + } + }, + }, + }; + + fn parse_month_and_day(s: &str) -> Result<(Month, DayNumber), TimespecParsingError> { + let month = s + .chars() + .take_while(|this| !this.is_numeric()) + .collect::() + .parse::() + .map_err(TimespecParsingError::DateTokenParsing)?; + + let day = s + .chars() + .skip_while(|this| !this.is_numeric()) + .collect::() + .parse::() + .map_err(TimespecParsingError::DateTokenParsing)?; + + Ok((month, day)) + } + + Ok(result) + } + } + + #[derive(Debug, PartialEq)] + pub enum Time { + Midnight, + Noon, + Hr24clockHour(Hr24Clock), + Hr24clockHourTimezone { + hr24_clock: Hr24Clock, + timezone: TimezoneName, + }, + Hr24clockHourMinute { + hour: Hr24ClockHour, + minute: Minute, + }, + Hr24clockHourMinuteTimezone { + hour: Hr24ClockHour, + minute: Minute, + timezone: TimezoneName, + }, + WallclockHour { + clock: WallClock, + am: AmPm, + }, + WallclockHourTimezone { + clock: WallClock, + am: AmPm, + timezone: TimezoneName, + }, + WallclockHourMinute { + clock: WallClockHour, + minute: Minute, + am: AmPm, + }, + WallclockHourMinuteTimezone { + clock: WallClockHour, + minute: Minute, + am: AmPm, + timezone: TimezoneName, + }, + } + + impl Time { + pub fn to_naive_time(&self) -> Option { + match self { + Time::Midnight => NaiveTime::from_hms_opt(0, 0, 0), + Time::Noon => NaiveTime::from_hms_opt(12, 0, 0), + Time::Hr24clockHour(hr24_clock) => { + let Hr24Clock([hour, minute]) = *hr24_clock; + + NaiveTime::from_hms_opt(hour.into(), minute.into(), 0) + } + Time::Hr24clockHourTimezone { + hr24_clock, + timezone, + } => { + let Hr24Clock([hour, minute]) = *hr24_clock; + + if let Some(tz) = timezone.to_timezone() { + let today = Utc::now().date_naive(); + let custom_time = + NaiveTime::from_hms_opt(hour.into(), minute.into(), 0).unwrap(); + let utc_time = Utc + .from_local_datetime(&today.and_time(custom_time)) + .unwrap(); + let tz_time = utc_time.with_timezone(&tz); + let local_time = tz_time.with_timezone(&Local); + Some(local_time.time()) + } else { + None + } + } + Time::Hr24clockHourMinute { hour, minute } => { + let Hr24ClockHour(hour) = *hour; + let Minute(minute) = *minute; + + NaiveTime::from_hms_opt(hour.into(), minute.into(), 0) + } + Time::Hr24clockHourMinuteTimezone { + hour, + minute, + timezone, + } => { + let Hr24ClockHour(hour) = *hour; + let Minute(minute) = *minute; + + if let Some(tz) = timezone.to_timezone() { + let today = Utc::now().date_naive(); + let custom_time = + NaiveTime::from_hms_opt(hour.into(), minute.into(), 0).unwrap(); + let utc_time = Utc + .from_local_datetime(&today.and_time(custom_time)) + .unwrap(); + let tz_time = utc_time.with_timezone(&tz); + let local_time = tz_time.with_timezone(&Local); + Some(local_time.time()) + } else { + None + } + } + Time::WallclockHour { clock, am } => { + let WallClock { hour, minutes } = *clock; + + chrono::NaiveTime::from_hms_opt( + u32::from(match am { + AmPm::Am => hour.get(), + AmPm::Pm => hour.get() + 12, + }), + u32::from(minutes), + 0, + ) + } + Time::WallclockHourTimezone { + clock, + am, + timezone, + } => { + let WallClock { hour, minutes } = *clock; + + if let Some(tz) = timezone.to_timezone() { + let hour_24 = match am { + AmPm::Am => hour.get(), + AmPm::Pm => hour.get() + 12, + }; + let today = Utc::now().date_naive(); + let custom_time = + NaiveTime::from_hms_opt(hour_24.into(), minutes.into(), 0).unwrap(); + let utc_time = Utc + .from_local_datetime(&today.and_time(custom_time)) + .unwrap(); + let tz_time = utc_time.with_timezone(&tz); + let local_time = tz_time.with_timezone(&Local); + Some(local_time.time()) + } else { + None + } + } + Time::WallclockHourMinute { clock, minute, am } => { + let WallClockHour(hour) = *clock; + let Minute(minutes) = *minute; + + chrono::NaiveTime::from_hms_opt( + u32::from(match am { + AmPm::Am => hour.get(), + AmPm::Pm => hour.get() + 12, + }), + u32::from(minutes), + 0, + ) + } + Time::WallclockHourMinuteTimezone { + clock, + minute, + am, + timezone, + } => { + let WallClockHour(hour) = *clock; + let Minute(minutes) = *minute; + + if let Some(tz) = timezone.to_timezone() { + let hour_24 = match am { + AmPm::Am => hour.get(), + AmPm::Pm => hour.get() + 12, + }; + let today = Utc::now().date_naive(); + let custom_time = + NaiveTime::from_hms_opt(hour_24.into(), minutes.into(), 0).unwrap(); + let utc_time = Utc + .from_local_datetime(&today.and_time(custom_time)) + .unwrap(); + let tz_time = utc_time.with_timezone(&tz); + let local_time = tz_time.with_timezone(&Local); + Some(local_time.time()) + } else { + None + } + } + } + } + } + + impl FromStr for Time { + type Err = TimespecParsingError; + + fn from_str(s: &str) -> Result { + match s { + "noon" => return Ok(Self::Noon), + "midnight" => return Ok(Self::Midnight), + _ => (), + }; + + if let Ok(hour) = Hr24Clock::from_str(s) { + return Ok(Self::Hr24clockHour(hour)); + } + + if let Some((possible_hour, other)) = s.split_once(':') { + if let Ok(clock) = WallClockHour::from_str(possible_hour) { + let minute = other + .chars() + .take_while(|this: &char| this.is_numeric()) + .collect::(); + let minutes_len = minute.len(); + let minute = Minute::from_str(&minute) + .map_err(TimespecParsingError::TimeTokenParsing)?; + + let other = other.chars().skip(minutes_len).collect::(); + + if let Ok(am) = AmPm::from_str(&other) { + return Ok(Self::WallclockHourMinute { clock, minute, am }); + } + + let am = AmPm::from_str(&other[..2]) + .map_err(TimespecParsingError::TimeTokenParsing)?; + let timezone = TimezoneName::from_str(&other[2..]) + .map_err(TimespecParsingError::TimeTokenParsing)?; + + return Ok(Self::WallclockHourMinuteTimezone { + clock, + minute, + am, + timezone, + }); + } + + if let Ok(hour) = Hr24ClockHour::from_str(possible_hour) { + let result = match Minute::from_str(other) { + Ok(minute) => Self::Hr24clockHourMinute { hour, minute }, + Err(_) => { + let minute = other + .chars() + .take_while(|this| this.is_numeric()) + .collect::() + .parse::() + .map_err(TimespecParsingError::TimeTokenParsing)?; + + let timezone = other + .chars() + .skip_while(|this| this.is_numeric()) + .collect::() + .parse::() + .map_err(TimespecParsingError::TimeTokenParsing)?; + + Self::Hr24clockHourMinuteTimezone { + hour, + minute, + timezone, + } + } + }; + + return Ok(result); + } + } + + let number = s + .chars() + .take_while(|this| this.is_numeric()) + .collect::(); + let other = s + .chars() + .skip_while(|this| this.is_numeric()) + .collect::(); + + if let Ok(clock) = WallClock::from_str(&number) { + if let Ok(am) = AmPm::from_str(&other[..2]) { + let timezone = TimezoneName::from_str(&other[2..]); + if let Ok(timezone) = timezone { + return Ok(Self::WallclockHourTimezone { + clock, + am, + timezone, + }); + } else { + return Ok(Self::WallclockHour { clock, am }); + } + } + } + + if let Ok(hr24_clock) = Hr24Clock::from_str(&number) { + let timezone = TimezoneName::from_str(&other); + + if let Ok(timezone) = timezone { + return Ok(Self::Hr24clockHourTimezone { + hr24_clock, + timezone, + }); + } + } + + Err(TimespecParsingError::TimePatternNotFound(s.to_owned())) + } + } + + #[derive(Debug, PartialEq)] + pub enum Nowspec { + Now, + NowIncrement(Increment), + } + + impl FromStr for Nowspec { + type Err = TimespecParsingError; + + fn from_str(s: &str) -> Result { + const NOW: &str = "now"; + + let result = match s { + NOW => Self::Now, + _ if s.starts_with(NOW) => { + let (_, increment) = s + .split_once(NOW) + .ok_or(TimespecParsingError::NowspecParsing(s.to_owned()))?; + + Self::NowIncrement(Increment::from_str(increment)?) + } + _ => Err(TimespecParsingError::NowspecPatternNotFound(s.to_owned()))?, + }; + + Ok(result) + } + } + + #[derive(Debug, PartialEq)] + pub enum Timespec { + Time(Time), + TimeDate { + time: Time, + date: Date, + }, + TimeDateIncrement { + time: Time, + date: Date, + inrement: Increment, + }, + Nowspec(Nowspec), + } + + impl FromStr for Timespec { + type Err = TimespecParsingError; + + fn from_str(s: &str) -> Result { + if let Ok(time) = Time::from_str(s) { + return Ok(Self::Time(time)); + } + + if let Ok(time) = Nowspec::from_str(s) { + return Ok(Self::Nowspec(time)); + } + + let string_length = s.len(); + let mut time_index = 0; + + for slice_index in (0..string_length).rev() { + let time = Time::from_str(&s[..slice_index]); + if time.is_ok() { + time_index = slice_index; + break; + } + + if slice_index == 0 { + Err(TimespecParsingError::TimespecPatternNotFound(s.to_owned()))?; + } + } + + let time = Time::from_str(&s[..time_index])?; + + let mut date_index = 0; + for slice_index in (time_index..=string_length).rev() { + let date = Date::from_str(&s[time_index..slice_index]); + if date.is_ok() { + date_index = slice_index; + break; + } + + if slice_index == time_index { + Err(TimespecParsingError::TimespecPatternNotFound(s.to_owned()))?; + } + } + + let date = Date::from_str(&s[time_index..date_index])?; + + if date_index != string_length { + let inrement = Increment::from_str(&s[date_index..])?; + + return Ok(Self::TimeDateIncrement { + time, + date, + inrement, + }); + } + + Ok(Self::TimeDate { time, date }) + } + } + + impl Timespec { + pub fn to_date_time(&self) -> Option> { + let date_time = match self { + Timespec::Time(time) => { + let time = time.to_naive_time()?; + let now = Utc::now(); + + match time < now.time() { + true => now + .checked_add_days(Days::new(1))? + .with_time(time) + .single()? + .to_utc(), + false => now.with_time(time).single()?.to_utc(), + } + } + Timespec::TimeDate { time, date } => { + let time = time.to_naive_time()?; + let date = date.to_naive_date()?; + + let date_time = NaiveDateTime::new(date, time).and_utc(); + match date_time < Utc::now() { + true => date_time.checked_add_months(chrono::Months::new(12))?, + false => date_time, + } + } + Timespec::TimeDateIncrement { + time, + date, + inrement, + } => { + let time = time.to_naive_time()?; + let date = date.to_naive_date()?; + + let date_time = NaiveDateTime::new(date, time) + .and_utc() + .checked_add_signed( + chrono::TimeDelta::from_std(inrement.to_duration()).ok()?, + )?; + + match date_time < Utc::now() { + true => date_time.checked_add_months(chrono::Months::new(12))?, + false => date_time, + } + } + Timespec::Nowspec(nowspec) => match nowspec { + Nowspec::Now => { + let datetime_utc = Utc::now(); + let datetime_local = datetime_utc.with_timezone(&Local); + Utc.from_local_datetime(&datetime_local.naive_local()) + .unwrap() + } + Nowspec::NowIncrement(increment) => { + let datetime_utc = Utc::now(); + let datetime_local = datetime_utc.with_timezone(&Local); + Utc.from_local_datetime(&datetime_local.naive_local()) + .unwrap() + .checked_add_signed( + chrono::TimeDelta::from_std(increment.to_duration()).ok()?, + )? + } + }, + }; + + Some(date_time) + } + } + + #[cfg(test)] + mod test { + use std::num::NonZero; + + use super::*; + + // increment + #[test] + fn increment_period_simple() { + let actual = Increment::from_str("+1day"); + + assert_eq!( + Ok(Increment::Plus { + number: 1, + period: IncPeriod::Day + }), + actual + ) + } + + #[test] + fn increment_period_two_numbers() { + let actual = Increment::from_str("+12days"); + + assert_eq!( + Ok(Increment::Plus { + number: 12, + period: IncPeriod::Day + }), + actual + ) + } + + #[test] + fn increment_period_next() { + let actual = Increment::from_str("nextday"); + + assert_eq!(Ok(Increment::Next(IncPeriod::Day)), actual) + } + + #[test] + fn increment_period_no_number_after_sign() { + let actual = Increment::from_str("+day"); + + assert!(actual.is_err()) + } + + #[test] + fn increment_period_empty() { + let actual = Increment::from_str(""); + + assert_eq!( + Err(TimespecParsingError::IncrementPatternNotFound(String::new())), + actual + ) + } + + #[test] + fn increment_period_next_no_perid_fails() { + let actual = Increment::from_str("next"); + + assert_eq!( + Err(TimespecParsingError::IncPeriodPatternNotFound(String::new())), + actual + ) + } + + // nowspec + + #[test] + fn nowspec_simple_now() { + let actual = Nowspec::from_str("now"); + + assert_eq!(Ok(Nowspec::Now), actual) + } + + #[test] + fn nowspec_incremented_number() { + let actual = Nowspec::from_str("now+1day"); + + assert_eq!( + Ok(Nowspec::NowIncrement(Increment::Plus { + number: 1, + period: IncPeriod::Day + })), + actual + ); + } + + #[test] + fn nowspec_incremented_by_next() { + let actual = Nowspec::from_str("nownextday"); + + assert_eq!( + Ok(Nowspec::NowIncrement(Increment::Next(IncPeriod::Day))), + actual + ); + } + + // time + + #[test] + fn time_simple_24_hour_single_number() { + let actual = Time::from_str("1"); + + assert_eq!(Ok(Time::Hr24clockHour(Hr24Clock([1, 0]))), actual) + } + + #[test] + fn time_24_hour_full() { + let actual = Time::from_str("1453"); + + assert_eq!(Ok(Time::Hr24clockHour(Hr24Clock([14, 53]))), actual) + } + + #[test] + fn time_24_hour_noon() { + let actual = Time::from_str("noon"); + + assert_eq!(Ok(Time::Noon), actual) + } + + #[test] + fn time_24_hour_midnight() { + let actual = Time::from_str("midnight"); + + assert_eq!(Ok(Time::Midnight), actual) + } + + #[test] + fn time_24_hour_plus_timezone() { + let actual = Time::from_str("1453UTC"); + + assert_eq!( + Ok(Time::Hr24clockHourTimezone { + hr24_clock: Hr24Clock([14, 53]), + timezone: TimezoneName("UTC".to_owned()) + }), + actual + ) + } + + #[test] + fn time_24_hour_plus_timezone_2_digits() { + let actual = Time::from_str("14UTC"); + + assert_eq!( + Ok(Time::Hr24clockHourTimezone { + hr24_clock: Hr24Clock([14, 00]), + timezone: TimezoneName("UTC".to_owned()) + }), + actual + ) + } + + #[test] + fn time_hr24clock_hour_plus_minute() { + let actual = Time::from_str("14:53"); + + assert_eq!( + Ok(Time::Hr24clockHourMinute { + hour: Hr24ClockHour(14), + minute: Minute(53) + }), + actual + ) + } + + #[test] + fn time_hr24clock_hour_plus_minute_timezone() { + let actual = Time::from_str("14:53UTC"); + + assert_eq!( + Ok(Time::Hr24clockHourMinuteTimezone { + hour: Hr24ClockHour(14), + minute: Minute(53), + timezone: TimezoneName("UTC".to_owned()) + }), + actual + ) + } + + #[test] + fn time_wallclock_hr_min_am() { + let actual = Time::from_str("0500am"); + + assert_eq!( + Ok(Time::WallclockHour { + clock: WallClock { + hour: NonZero::new(5).expect("valid"), + minutes: 0 + }, + am: AmPm::Am + }), + actual + ) + } + + #[test] + fn time_wallclock_hr_min_plus_am_timezone() { + let actual = Time::from_str("0500amUTC"); + + assert_eq!( + Ok(Time::WallclockHourTimezone { + clock: WallClock { + hour: NonZero::new(5).expect("valid"), + minutes: 0 + }, + am: AmPm::Am, + timezone: TimezoneName("UTC".to_owned()) + }), + actual + ) + } + + #[test] + fn time_wallclock_hour_minute_am() { + let actual = Time::from_str("05:53am"); + + assert_eq!( + Ok(Time::WallclockHourMinute { + clock: WallClockHour(NonZero::new(5).expect("valid")), + minute: Minute(53), + am: AmPm::Am, + }), + actual + ) + } + + #[test] + fn time_wallclock_hour_minute_am_timezone() { + let actual = Time::from_str("05:53amUTC"); + + assert_eq!( + Ok(Time::WallclockHourMinuteTimezone { + clock: WallClockHour(NonZero::new(5).expect("valid")), + minute: Minute(53), + am: AmPm::Am, + timezone: TimezoneName("UTC".to_owned()) + }), + actual + ) + } + + // date + + #[test] + fn date_month_name_day_number() { + let actual = Date::from_str("NOV4"); + + assert_eq!( + Ok(Date::MontDay { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")) + }), + actual + ); + } + + #[test] + fn date_month_name_day_number_year_number() { + let actual = Date::from_str("NOV4,2024"); + + assert_eq!( + Ok(Date::MontDayYear { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")), + year_number: YearNumber(2024) + }), + actual + ); + } + + #[test] + fn date_day_of_week() { + let actual = Date::from_str("MON"); + + assert_eq!(Ok(Date::DayOfWeek(DayOfWeek(chrono::Weekday::Mon))), actual); + } + + #[test] + fn date_today() { + let actual = Date::from_str("today"); + + assert_eq!(Ok(Date::Today), actual); + } + + #[test] + fn date_tomorrow() { + let actual = Date::from_str("tomorrow"); + + assert_eq!(Ok(Date::Tomorrow), actual); + } + + // timespec + + #[test] + fn timespec_time_24_hour_full() { + let actual = Timespec::from_str("1453"); + + assert_eq!( + Ok(Timespec::Time(Time::Hr24clockHour(Hr24Clock([14, 53])))), + actual + ) + } + + #[test] + fn timespec_time_24_hour_plus_timezone_2_digits() { + let actual = Timespec::from_str("14UTC"); + + assert_eq!( + Ok(Timespec::Time(Time::Hr24clockHourTimezone { + hr24_clock: Hr24Clock([14, 00]), + timezone: TimezoneName("UTC".to_owned()) + })), + actual + ) + } + + #[test] + fn timespec_time_hr24clock_hour_plus_minute() { + let actual = Timespec::from_str("14:53"); + + assert_eq!( + Ok(Timespec::Time(Time::Hr24clockHourMinute { + hour: Hr24ClockHour(14), + minute: Minute(53) + })), + actual + ) + } + + #[test] + fn timespec_time_wallclock_hour_minute_am_timezone() { + let actual = Timespec::from_str("05:53amUTC"); + + assert_eq!( + Ok(Timespec::Time(Time::WallclockHourMinuteTimezone { + clock: WallClockHour(NonZero::new(5).expect("valid")), + minute: Minute(53), + am: AmPm::Am, + timezone: TimezoneName("UTC".to_owned()) + })), + actual + ) + } + + #[test] + fn timespec_nowspec() { + let actual = Timespec::from_str("now"); + + assert_eq!(Ok(Timespec::Nowspec(Nowspec::Now)), actual) + } + + #[test] + fn timespec_time_date() { + let actual = Timespec::from_str("05:53amUTCtoday"); + + assert_eq!( + Ok(Timespec::TimeDate { + time: Time::WallclockHourMinuteTimezone { + clock: WallClockHour(NonZero::new(5).expect("valid")), + minute: Minute(53), + am: AmPm::Am, + timezone: TimezoneName("UTC".to_owned()) + }, + date: Date::Today + }), + actual + ) + } + + #[test] + fn timespec_time_date_harder() { + let actual = Timespec::from_str("05:53amUTCNOV4,2024"); + + assert_eq!( + Ok(Timespec::TimeDate { + time: Time::WallclockHourMinuteTimezone { + clock: WallClockHour(NonZero::new(5).expect("valid")), + minute: Minute(53), + am: AmPm::Am, + timezone: TimezoneName("UTC".to_owned()) + }, + date: Date::MontDayYear { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")), + year_number: YearNumber(2024) + } + }), + actual + ) + } + + #[test] + fn timespec_time_date_harder_plus_increment() { + let actual = Timespec::from_str("05:53amUTCNOV4,2024+1day"); + + assert_eq!( + Ok(Timespec::TimeDateIncrement { + time: Time::WallclockHourMinuteTimezone { + clock: WallClockHour(NonZero::new(5).expect("valid")), + minute: Minute(53), + am: AmPm::Am, + timezone: TimezoneName("UTC".to_owned()) + }, + date: Date::MontDayYear { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")), + year_number: YearNumber(2024) + }, + inrement: Increment::Plus { + number: 1, + period: IncPeriod::Day + } + }), + actual + ) + } + + #[test] + fn timespec_time_date_harder_to_date_time() { + let timespec = Timespec::TimeDate { + time: Time::WallclockHourMinute { + clock: WallClockHour(NonZero::new(5).expect("valid")), + minute: Minute(53), + am: AmPm::Am, + }, + date: Date::MontDayYear { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")), + year_number: YearNumber(3000), + }, + }; + + let expected = DateTime::parse_from_rfc3339("3000-11-04T05:53:00Z") + .expect("expected is valid") + .to_utc(); + + assert_eq!(Some(expected), timespec.to_date_time()) + } + + #[test] + fn timespec_time_date_harder_plus_increment_to_date_time() { + let timespec = Timespec::TimeDateIncrement { + time: Time::WallclockHourMinute { + clock: WallClockHour(NonZero::new(5).expect("valid")), + minute: Minute(53), + am: AmPm::Am, + }, + date: Date::MontDayYear { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")), + year_number: YearNumber(3000), + }, + inrement: Increment::Plus { + number: 1, + period: IncPeriod::Day, + }, + }; + + let expected = DateTime::parse_from_rfc3339("3000-11-05T05:53:00Z") + .expect("expected is valid") + .to_utc(); + + assert_eq!(Some(expected), timespec.to_date_time()) + } + + #[test] + fn timespec_to_date_time_wallclock_hour_minute_with_timezone() { + let timespec = Timespec::TimeDate { + time: Time::WallclockHourMinuteTimezone { + clock: WallClockHour(NonZero::new(5).expect("valid")), + minute: Minute(53), + am: AmPm::Am, + timezone: TimezoneName("UTC".to_owned()), + }, + date: Date::MontDayYear { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")), + year_number: YearNumber(3000), + }, + }; + + let expected = DateTime::parse_from_rfc3339("3000-11-04T05:53:00Z") + .expect("expected is valid") + .to_utc() + .with_timezone(&Local); + + assert_eq!( + expected.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + timespec + .to_date_time() + .unwrap() + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() + ) + } + + #[test] + fn timespec_to_date_time_wallclock_hour_with_timezone() { + let timespec = Timespec::TimeDate { + time: Time::WallclockHourTimezone { + clock: WallClock { + hour: NonZero::new(5).expect("valid"), + minutes: 53, + }, + am: AmPm::Am, + timezone: TimezoneName("UTC".to_owned()), + }, + date: Date::MontDayYear { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")), + year_number: YearNumber(3000), + }, + }; + + let expected = DateTime::parse_from_rfc3339("3000-11-04T05:53:00Z") + .expect("expected is valid") + .to_utc() + .with_timezone(&Local); + + assert_eq!( + expected.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + timespec + .to_date_time() + .unwrap() + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() + ) + } + + #[test] + fn timespec_to_date_time_hr24clock_hour_minute_with_timezone() { + let timespec = Timespec::TimeDate { + time: Time::Hr24clockHourMinuteTimezone { + hour: Hr24ClockHour(5), + minute: Minute(53), + + timezone: TimezoneName("UTC".to_owned()), + }, + date: Date::MontDayYear { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")), + year_number: YearNumber(3000), + }, + }; + + let expected = DateTime::parse_from_rfc3339("3000-11-04T05:53:00Z") + .expect("expected is valid") + .to_utc() + .with_timezone(&Local); + + assert_eq!( + expected.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + timespec + .to_date_time() + .unwrap() + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() + ) + } + + #[test] + fn timespec_to_date_time_hr24clock_hour_with_timezone() { + let timespec = Timespec::TimeDate { + time: Time::Hr24clockHourTimezone { + hr24_clock: Hr24Clock([5, 53]), + timezone: TimezoneName("UTC".to_owned()), + }, + date: Date::MontDayYear { + month_name: Month(10), + day_number: DayNumber(NonZero::new(4).expect("valid")), + year_number: YearNumber(3000), + }, + }; + + let expected = DateTime::parse_from_rfc3339("3000-11-04T05:53:00Z") + .expect("expected is valid") + .to_utc() + .with_timezone(&Local); + + assert_eq!( + expected.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + timespec + .to_date_time() + .unwrap() + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() + ) + } + } +} + +mod tokens { + use std::{num::NonZero, str::FromStr}; + + use chrono_tz::Tz; + + #[derive(Debug, PartialEq)] + pub enum TokenParsingError { + TimeOperand(String), + DateOperant(String), + H24HourParsing { + err: std::num::ParseIntError, + input: String, + }, + H24HourOverflow(&'static str), + H24HourPatternNotFound(String), + WallClockParsing { + err: std::num::ParseIntError, + input: String, + }, + WallClockOverflow(&'static str), + WallClockPatternNotFound(String), + MinuteParsing { + err: std::num::ParseIntError, + input: String, + }, + MinuteOverflow, + DayNumberParsing { + err: std::num::ParseIntError, + input: String, + }, + DayNumberOverflow, + YearNumberParsing { + err: std::num::ParseIntError, + input: String, + }, + YearNumberInvalid, + TimezonePatternNotFound(String), + MonthPatternNotFound(String), + DayOfWeekPatternNotFound(String), + AmPmPatternNotFound(String), + } + + impl std::fmt::Display for TokenParsingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenParsingError::TimeOperand(input) => { + writeln!(f, "Failed to parse `time` operand in {input}") + } + TokenParsingError::DateOperant(input) => { + writeln!(f, "Failed to parse `date` operand in {input}") + } + TokenParsingError::H24HourParsing { err: _, input } => { + writeln!(f, "Failed to parse `hr24clock_hour` token in `{input}`") + } + TokenParsingError::H24HourOverflow(who) => writeln!( + f, + "Failed to parse `hr24clock_hour` token due overflow in `{who}`" + ), + TokenParsingError::H24HourPatternNotFound(input) => { + writeln!(f, "Failed to find `hr24clock_hour` token in `{input}`") + } + TokenParsingError::WallClockParsing { err: _, input } => { + writeln!(f, "Failed to parse `wallclock_hour` token in `{input}`") + } + TokenParsingError::WallClockOverflow(who) => writeln!( + f, + "Failed to parse `wallclock_hour` token due overflow in `{who}`" + ), + TokenParsingError::WallClockPatternNotFound(input) => { + writeln!(f, "Failed to find `wallclock_hour` token in `{input}`") + } + TokenParsingError::MinuteParsing { err: _, input } => { + writeln!(f, "Failed to parse `minute` token in `{input}`") + } + TokenParsingError::MinuteOverflow => { + writeln!(f, "Failed to parse `minute` token due overflow") + } + TokenParsingError::DayNumberParsing { err: _, input } => { + writeln!(f, "Failed to parse `day_number` token in `{input}`") + } + TokenParsingError::DayNumberOverflow => { + writeln!(f, "Failed to parse `day_number` token due overflow") + } + TokenParsingError::YearNumberParsing { err: _, input } => { + writeln!(f, "Failed to parse `year_number` token in `{input}`") + } + TokenParsingError::YearNumberInvalid => writeln!( + f, + "Failed to parse `year_number` token. Year should be 4 digit number" + ), + TokenParsingError::TimezonePatternNotFound(input) => { + writeln!(f, "Failed to find `timezone_name` token in `{input}`") + } + TokenParsingError::MonthPatternNotFound(input) => { + writeln!(f, "Failed to find `month_name` token in `{input}`") + } + TokenParsingError::DayOfWeekPatternNotFound(input) => { + writeln!(f, "Failed to find `day_of_week` token in `{input}`") + } + TokenParsingError::AmPmPatternNotFound(input) => { + writeln!(f, "Failed to find `am_pm` token in `{input}`") + } + } + } + } + + /// Specific operands in timespec for time + pub enum TimespecTimeOperands { + /// Indicates the time 12:00 am (00:00). + Midnight, + /// Indicates the time 12:00 pm. + Noon, + /// Indicates the current day and time. + Now, + } + + impl FromStr for TimespecTimeOperands { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let result = match s { + "midnight" => Self::Midnight, + "noon" => Self::Noon, + "now" => Self::Now, + _ => Err(TokenParsingError::TimeOperand(s.to_string()))?, + }; + + Ok(result) + } + } + + /// Specific operands in timespec for date + pub enum TimespecDateOperands { + /// Indicates the current day. + Today, + /// Indicates the day following the current day. + Tomorrow, + } + + impl FromStr for TimespecDateOperands { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let result = match s { + "today" => Self::Today, + "tomorrow" => Self::Tomorrow, + _ => Err(TokenParsingError::DateOperant(s.to_owned()))?, + }; + + Ok(result) + } + } + + /// An `Hr24clockHour` is a one, two, or four-digit number. A one-digit + /// or two-digit number constitutes an `Hr24clockHour`. An `Hr24clockHour` + /// may be any of the single digits `[0,9]`, or may be double digits, ranging + /// from `[00,23]`. If an `Hr24clockHour` is a four-digit number, the + /// first two digits shall be a valid `Hr24clockHour`, while the last two + /// represent the number of minutes, from `[00,59]`. + #[derive(Debug, PartialEq)] + pub struct Hr24Clock(pub(crate) [u8; 2]); + + #[cfg(test)] + impl Hr24Clock { + pub fn into_inner(self) -> [u8; 2] { + self.0 + } + } + + impl FromStr for Hr24Clock { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let chars_count = s.chars().count(); + + let result = match chars_count { + 1 | 2 => { + let hour = + u8::from_str(s).map_err(|err| TokenParsingError::H24HourParsing { + err, + input: s.to_owned(), + })?; + if hour > 23 { + Err(TokenParsingError::H24HourOverflow("hour"))? + } + + [hour, 0] + } + 4 => { + let hour = &s[..2]; + let minutes = &s[2..]; + + let hour = + u8::from_str(hour).map_err(|err| TokenParsingError::H24HourParsing { + err, + input: s.to_owned(), + })?; + let minutes = + u8::from_str(minutes).map_err(|err| TokenParsingError::H24HourParsing { + err, + input: s.to_owned(), + })?; + + if hour > 23 { + Err(TokenParsingError::H24HourOverflow("hour"))? + } + + if minutes > 59 { + Err(TokenParsingError::H24HourOverflow("minute"))? + } + + [hour, minutes] + } + _ => Err(TokenParsingError::H24HourPatternNotFound(s.to_owned()))?, + }; + + Ok(Self(result)) + } + } + + #[derive(Debug, PartialEq)] + pub struct Hr24ClockHour(pub(crate) u8); + + impl FromStr for Hr24ClockHour { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let number = u8::from_str(s).map_err(|err| TokenParsingError::H24HourParsing { + err, + input: s.to_owned(), + })?; + + if number > 23 { + Err(TokenParsingError::H24HourOverflow("hour"))? + } + + Ok(Self(number)) + } + } + + /// A `WallclockHour` is a one, two-digit, or four-digit number. + /// A one-digit or two-digit number constitutes a `WallclockHour`. + /// A `WallclockHour` may be any of the single digits `[1..=9]`, or may + /// be double digits, ranging from `[01..=12]`. If a `WallclockHour` + /// is a four-digit number, the first two digits shall be a valid + /// `WallclockHour`, while the last two represent the number of + /// minutes, from `[00..=59]`. + #[derive(Debug, PartialEq)] + pub struct WallClock { + pub(crate) hour: NonZero, + pub(crate) minutes: u8, + } + + #[cfg(test)] + impl WallClock { + pub fn into_inner(self) -> (NonZero, u8) { + (self.hour, self.minutes) + } + } + + impl FromStr for WallClock { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let chars_count = s.chars().count(); + + let result = match chars_count { + 1 | 2 => { + let hour = NonZero::::from_str(s).map_err(|err| { + TokenParsingError::WallClockParsing { + err, + input: s.to_owned(), + } + })?; + + if hour.get() > 23 { + Err(TokenParsingError::WallClockOverflow("hour"))? + } + + Self { hour, minutes: 0 } + } + 4 => { + let hour = &s[..2]; + let minutes = &s[2..]; + + let hour = NonZero::::from_str(hour).map_err(|err| { + TokenParsingError::WallClockParsing { + err, + input: s.to_owned(), + } + })?; + let minutes = u8::from_str(minutes).map_err(|err| { + TokenParsingError::WallClockParsing { + err, + input: s.to_owned(), + } + })?; + + if hour.get() > 23 { + Err(TokenParsingError::WallClockOverflow("hour"))? + } + + if minutes > 59 { + Err(TokenParsingError::WallClockOverflow("minute"))? + } + + Self { hour, minutes } + } + _ => Err(TokenParsingError::WallClockPatternNotFound(s.to_owned()))?, + }; + + Ok(result) + } + } + + #[derive(Debug, PartialEq)] + pub struct WallClockHour(pub(crate) NonZero); + + impl FromStr for WallClockHour { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let number = + NonZero::from_str(s).map_err(|err| TokenParsingError::WallClockParsing { + err, + input: s.to_owned(), + })?; + + if number.get() > 12 { + Err(TokenParsingError::WallClockOverflow("hour"))? + } + + Ok(Self(number)) + } + } + + /// A `Minute` is a one or two-digit number whose value + /// can be `[0..=9]` or` [00..=59]`. + #[derive(Debug, PartialEq)] + pub struct Minute(pub(crate) u8); + + #[cfg(test)] + impl Minute { + pub fn into_inner(self) -> u8 { + self.0 + } + } + + impl FromStr for Minute { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let minute = u8::from_str(s).map_err(|err| TokenParsingError::MinuteParsing { + err, + input: s.to_owned(), + })?; + + if minute > 59 { + Err(TokenParsingError::MinuteOverflow)? + } + + Ok(Self(minute)) + } + } + + #[derive(Debug, PartialEq)] + pub struct DayNumber(pub(crate) NonZero); + + impl FromStr for DayNumber { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let number = + NonZero::from_str(s).map_err(|err| TokenParsingError::DayNumberParsing { + err, + input: s.to_owned(), + })?; + + if number.get() > 31 { + Err(TokenParsingError::DayNumberOverflow)?; + } + + Ok(Self(number)) + } + } + + /// A `YearNumber` is a four-digit number representing the year A.D., in + /// which the at_job is to be run. + #[derive(Debug, PartialEq)] + pub struct YearNumber(pub(crate) u16); + + impl FromStr for YearNumber { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let year = u16::from_str(s).map_err(|err| TokenParsingError::YearNumberParsing { + err, + input: s.to_owned(), + })?; + if !(1970..=9999).contains(&year) { + // it should be 4 number, so yeah... + Err(TokenParsingError::YearNumberInvalid)? + } + + Ok(Self(year)) + } + } + + /// The name of an optional timezone suffix to the time field, in an + /// implementation-defined format. + #[derive(Debug, PartialEq)] + pub struct TimezoneName(pub(crate) String); + + impl FromStr for TimezoneName { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let tz = std::env::var("TZ").ok().unwrap_or("UTC".to_owned()); + + match s == tz { + true => Ok(Self(tz)), // TODO: Seems like implementation only reads UTC, but it should be influenced by TZ variable + false => Err(TokenParsingError::TimezonePatternNotFound(tz)), + } + } + } + + impl TimezoneName { + /// Returns the time zone based on the string (so far only UTC) + pub fn to_timezone(&self) -> Option { + match self.0.as_str() { + "UTC" => Some(chrono_tz::UTC), // Only UTC is currently supported + _ => None, // For other time zones, the implementation has not yet been added + } + } + } + + /// One of the values from the mon or abmon keywords in the LC_TIME + /// locale category. + #[derive(Debug, PartialEq)] + pub struct Month(pub(crate) u8); + + impl FromStr for Month { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let number = match s { + "JAN" => 0, + "FEB" => 1, + "MAR" => 2, + "APR" => 3, + "MAY" => 4, + "JUN" => 5, + "JUL" => 6, + "AUG" => 7, + "SEP" => 8, + "OCT" => 9, + "NOV" => 10, + "DEC" => 11, + _ => Err(TokenParsingError::MonthPatternNotFound(s.to_owned()))?, + }; + + Ok(Self(number)) + } + } + + /// One of the values from the day or abday keywords in the LC_TIME + /// locale category. + #[derive(Debug, PartialEq)] + pub struct DayOfWeek(pub(crate) chrono::Weekday); + + impl FromStr for DayOfWeek { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + let day = match s { + "SUN" => chrono::Weekday::Sun, + "MON" => chrono::Weekday::Mon, + "TUE" => chrono::Weekday::Tue, + "WED" => chrono::Weekday::Wed, + "THU" => chrono::Weekday::Thu, + "FRI" => chrono::Weekday::Fri, + "SAT" => chrono::Weekday::Sat, + _ => Err(TokenParsingError::DayOfWeekPatternNotFound(s.to_owned()))?, + }; + + Ok(Self(day)) + } + } + + /// One of the values from the am_pm keyword in the LC_TIME locale + /// category. + #[derive(Debug, PartialEq, Clone, Copy)] + pub enum AmPm { + Am, + Pm, + } + + impl FromStr for AmPm { + type Err = TokenParsingError; + + fn from_str(s: &str) -> Result { + Ok(match s.to_lowercase().as_str() { + "am" => Self::Am, + "pm" => Self::Pm, + _ => Err(TokenParsingError::AmPmPatternNotFound(s.to_owned()))?, + }) + } + } + + #[cfg(test)] + mod test { + use super::*; + + #[test] + fn hour24_empty_char() { + let actual = Hr24Clock::from_str("").map(Hr24Clock::into_inner); + + assert_eq!( + Err(TokenParsingError::H24HourPatternNotFound("".to_owned())), + actual + ) + } + + #[test] + fn hour24_single_char_ok() { + for value in 0..=9 { + let actual = Hr24Clock::from_str(&value.to_string()).map(Hr24Clock::into_inner); + + assert_eq!(Ok([value, 0,]), actual) + } + } + + #[test] + fn hour24_single_char_not_a_number() { + let actual = Hr24Clock::from_str("a").map(Hr24Clock::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn hour24_two_chars_ok() { + for hour in 10..23 { + let actual = Hr24Clock::from_str(&hour.to_string()).map(Hr24Clock::into_inner); + + assert_eq!(Ok([hour, 0]), actual) + } + } + + #[test] + fn hour24_two_chars_out_of_range() { + let actual = Hr24Clock::from_str("24").map(Hr24Clock::into_inner); + + assert_eq!(Err(TokenParsingError::H24HourOverflow("hour")), actual) + } + + #[test] + fn hour24_two_chars_not_a_number() { + let actual = Hr24Clock::from_str("aa").map(Hr24Clock::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn hour24_four_chars() { + for hour in 10..23 { + for minute in 0..10 { + let actual = + Hr24Clock::from_str(&format!("{hour}0{minute}")).map(Hr24Clock::into_inner); + + assert_eq!(Ok([hour, minute]), actual) + } + + for minute in 10..=59 { + let actual = + Hr24Clock::from_str(&format!("{hour}{minute}")).map(Hr24Clock::into_inner); + + assert_eq!(Ok([hour, minute]), actual) + } + } + } + + #[test] + fn hour24_four_chars_out_of_range() { + let actual = Hr24Clock::from_str("2400").map(Hr24Clock::into_inner); + + assert_eq!(Err(TokenParsingError::H24HourOverflow("hour")), actual); + + let actual = Hr24Clock::from_str("2360").map(Hr24Clock::into_inner); + + assert_eq!(Err(TokenParsingError::H24HourOverflow("minute")), actual); + } + + #[test] + fn hour24_four_chars_not_a_number() { + let actual = Hr24Clock::from_str("aaaa").map(Hr24Clock::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn wallclock_hour_empty_char() { + let actual = WallClock::from_str("").map(WallClock::into_inner); + + assert_eq!( + Err(TokenParsingError::WallClockPatternNotFound("".to_owned())), + actual + ) + } + + #[test] + fn wallclock_hour_single_char_ok() { + for value in 1..=9 { + let actual = WallClock::from_str(&value.to_string()).map(WallClock::into_inner); + + assert_eq!( + Ok((NonZero::new(value).expect("not a zero"), 0_u8,)), + actual + ) + } + } + + #[test] + fn wallclock_hour_single_char_not_a_number() { + let actual = WallClock::from_str("a").map(WallClock::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn wallclock_hour_two_chars_ok() { + for hour in 10..23 { + let actual = WallClock::from_str(&hour.to_string()).map(WallClock::into_inner); + + assert_eq!(Ok((NonZero::new(hour).expect("not a zero"), 0)), actual) + } + } + + #[test] + fn wallclock_hour_two_chars_out_of_range() { + let actual = WallClock::from_str("24").map(WallClock::into_inner); + + assert_eq!(Err(TokenParsingError::WallClockOverflow("hour")), actual) + } + + #[test] + fn wallclock_hour_two_chars_not_a_number() { + let actual = WallClock::from_str("aa").map(WallClock::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn wallclock_hour_four_chars() { + for hour in 10..23 { + for minute in 0..10 { + let actual = + WallClock::from_str(&format!("{hour}0{minute}")).map(WallClock::into_inner); + + assert_eq!( + Ok((NonZero::new(hour).expect("not a zero"), minute)), + actual + ) + } + + for minute in 10..=59 { + let actual = + WallClock::from_str(&format!("{hour}{minute}")).map(WallClock::into_inner); + + assert_eq!( + Ok((NonZero::new(hour).expect("not a zero"), minute)), + actual + ) + } + } + } + + #[test] + fn wallclock_hour_four_chars_out_of_range() { + let actual = WallClock::from_str("2400").map(WallClock::into_inner); + + assert_eq!(Err(TokenParsingError::WallClockOverflow("hour")), actual); + + let actual = WallClock::from_str("2360").map(WallClock::into_inner); + + assert_eq!(Err(TokenParsingError::WallClockOverflow("minute")), actual); + } + + #[test] + fn wallclock_hour_four_chars_not_a_number() { + let actual = WallClock::from_str("aaaa").map(WallClock::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn wallclock_hour_singe_char_zero_fails() { + let actual = WallClock::from_str("0").map(WallClock::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn wallclock_hour_two_chars_zero_fails() { + let actual = WallClock::from_str("00").map(WallClock::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn wallclock_hour_four_chars_zero_fails() { + let actual = WallClock::from_str("0035").map(WallClock::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn minute_empty_char() { + let actual = Minute::from_str("").map(Minute::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn minute_single_char_ok() { + for value in 1..=9 { + let actual = Minute::from_str(&value.to_string()).map(Minute::into_inner); + + assert_eq!(Ok(value), actual) + } + } + + #[test] + fn minute_single_char_not_a_number() { + let actual = Minute::from_str("a").map(Minute::into_inner); + + assert!(actual.is_err()) + } + + #[test] + fn minute_two_chars_ok() { + for value in 10..=59 { + let actual = Minute::from_str(&value.to_string()).map(Minute::into_inner); + + assert_eq!(Ok(value), actual) + } + } + + #[test] + fn minute_two_chars_out_of_range() { + let actual = Minute::from_str("60").map(Minute::into_inner); + + assert_eq!(Err(TokenParsingError::MinuteOverflow), actual) + } + + #[test] + fn minute_two_chars_not_a_number() { + let actual = Minute::from_str("aa").map(Minute::into_inner); + + assert!(actual.is_err()) + } + } +} diff --git a/cron/batch.rs b/cron/batch.rs new file mode 100644 index 00000000..b19e5058 --- /dev/null +++ b/cron/batch.rs @@ -0,0 +1,396 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use chrono::{DateTime, Local, TimeZone, Utc}; +use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; +use libc::{getlogin, getpwnam, passwd}; + +use std::{ + collections::HashSet, + env, + ffi::{CStr, CString}, + fs::{self}, + io::{BufRead, Read, Seek, Write}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process, +}; + +#[cfg(target_os = "linux")] +const SPOOL_DIRECTORIES: &[&str] = &[ + "/var/spool/cron/atjobs/", + "/var/spool/at/", + "/var/spool/atjobs/", +]; + +#[cfg(target_os = "macos")] +const MACOS_DIRECTORY: &str = "/var/at/jobs/"; + +#[cfg(target_os = "linux")] +const DEFAULT_DIRECTORY: &str = "/var/spool/atjobs/"; + +fn main() -> Result<(), Box> { + setlocale(LocaleCategory::LcAll, ""); + textdomain("posixutils-rs")?; + bind_textdomain_codeset("posixutils-rs", "UTF-8")?; + + let time = { + let datetime_utc = Utc::now(); + let datetime_local = datetime_utc.with_timezone(&Local); + Utc.from_local_datetime(&datetime_local.naive_local()) + .unwrap() + }; + + let cmd = { + let stdout = std::io::stdout(); + let mut stdout_lock = stdout.lock(); + + writeln!(&mut stdout_lock, "at {}", time.to_rfc2822())?; + write!(&mut stdout_lock, "at> ")?; + stdout_lock.flush()?; + + let stdin = std::io::stdin(); + let mut stdin_lock = stdin.lock(); + + let mut result = Vec::new(); + let mut buf = String::new(); + + while stdin_lock.read_line(&mut buf)? != 0 { + write!(&mut stdout_lock, "at> ")?; + stdout_lock.flush()?; + + result.push(buf.to_owned()); + } + + writeln!(&mut stdout_lock, "")?; + stdout_lock.flush()?; + + result.join("\n") + }; + + let _ = at(Some('b'), &time, cmd, true).inspect_err(|err| print_err_and_exit(1, err)); + + Ok(()) +} + +/// Returns the path to the jobs directory, adjusted for the operating system. +/// On Linux: checks the `AT_JOB_DIR` environment variable, then predefined directories. +/// On macOS: checks or creates the `/var/at/jobs` directory. +fn get_job_dir() -> Result { + // Check `AT_JOB_DIR` environment variable + if let Ok(env_dir) = env::var("AT_JOB_DIR") { + if Path::new(&env_dir).exists() { + return Ok(env_dir); + } + } + #[cfg(target_os = "linux")] + { + // Check the predefined spool directories + for dir in SPOOL_DIRECTORIES { + if Path::new(dir).exists() { + return Ok(dir.to_string()); + } + } + + // Create the default directory if none exist + let default_path = Path::new(DEFAULT_DIRECTORY); + if !default_path.exists() { + if let Err(err) = fs::create_dir_all(default_path) { + return Err(format!( + "Failed to create directory {}: {}", + DEFAULT_DIRECTORY, err + )); + } + } + + Ok(DEFAULT_DIRECTORY.to_string()) + } + #[cfg(target_os = "macos")] + { + let macos_path = Path::new(MACOS_DIRECTORY); + + if !macos_path.exists() { + if let Err(err) = fs::create_dir_all(macos_path) { + return Err(format!( + "Failed to create directory {}: {}", + MACOS_DIRECTORY, err + )); + } + } + + Ok(MACOS_DIRECTORY.to_string()) + } +} + +fn print_err_and_exit(exit_code: i32, err: impl std::fmt::Display) -> ! { + eprintln!("{}", err); + process::exit(exit_code) +} + +fn at( + queue: Option, + execution_time: &DateTime, + cmd: impl Into, + mail: bool, +) -> Result<(), Box> { + let jobno = next_job_id()?; + let job_filename = job_file_name(jobno, queue, execution_time) + .ok_or("Failed to generate file name for job")?; + + let user = User::new().ok_or("Failed to get current user")?; + if !is_user_allowed(&user.name) { + return Err(format!("Access denied for user: {}", &user.name).into()); + } + + let job = Job::new(&user, std::env::current_dir()?, std::env::vars(), cmd, mail).into_script(); + + let mut file_opt = std::fs::OpenOptions::new(); + file_opt.read(true).write(true).create_new(true); + + let file_path = PathBuf::from(format!("{}/{job_filename}", get_job_dir()?)); + + let mut file = file_opt + .open(&file_path) + .map_err(|e| format!("Failed to create file with job. Reason: {e}"))?; + + file.write_all(job.as_bytes())?; + + file.set_permissions(std::fs::Permissions::from_mode(0o700))?; + + println!( + "job {} at {}", + jobno, + execution_time.format("%a %b %d %H:%M:%S %Y") + ); + + Ok(()) +} + +/// Structure to represent future job or script to be saved +pub struct Job { + shell: String, + user_uid: u32, + user_gid: u32, + user_name: String, + env: std::env::Vars, + call_place: PathBuf, + cmd: String, + mail: bool, +} + +impl Job { + pub fn new( + User { + shell, + uid, + gid, + name, + }: &User, + call_place: PathBuf, + env: std::env::Vars, + cmd: impl Into, + mail: bool, + ) -> Self { + Self { + shell: shell.to_owned(), + user_uid: *uid, + user_gid: *gid, + user_name: name.to_owned(), + env, + call_place, + cmd: cmd.into(), + mail, + } + } + + pub fn into_script(self) -> String { + let Self { + shell, + user_uid, + user_gid, + user_name, + env, + call_place, + cmd, + mail, + } = self; + + let env = env + .into_iter() + .map(|(key, value)| format!("{}={}; export {}", key, value, key)) + .collect::>() + .join("\n"); + + format!( + "#!{shell}\n# atrun uid={user_uid} gid={user_gid}\n# mail {user_name} {}\numask 22\n{env}\ncd {} || {{\n\techo 'Execution directory inaccessible' >&2\n\texit 1 \n}}\n{cmd}", + if mail {1} else {0}, + call_place.to_string_lossy() + ) + } +} + +/// Return name for job number +/// +/// None if DateTime < [DateTime::UNIX_EPOCH] +fn job_file_name(next_job: u32, queue: Option, time: &DateTime) -> Option { + let duration = time.signed_duration_since(DateTime::UNIX_EPOCH); + let duration_seconds = u32::try_from(duration.num_seconds()).ok()? / 60; + let queue = queue.unwrap_or('a'); + let result = format!("{queue}{next_job:05x}{duration_seconds:08x}"); + + Some(result) +} + +#[derive(Debug)] +pub enum NextJobError { + Io(std::io::Error), + FromStr(std::num::ParseIntError), +} + +impl std::fmt::Display for NextJobError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error reading id of next job. Reason: ")?; + match self { + NextJobError::Io(err) => writeln!(f, "{err}"), + NextJobError::FromStr(err) => writeln!(f, "invalid number - {err}"), + } + } +} + +impl std::error::Error for NextJobError {} + +fn next_job_id() -> Result> { + let mut file_opt = std::fs::OpenOptions::new(); + file_opt.read(true).write(true); + + let mut buf = String::new(); + let job_file_number = format!("{}.SEQ", get_job_dir()?); + + let (next_job_id, mut file) = match file_opt.open(&job_file_number) { + Ok(mut file) => { + file.read_to_string(&mut buf).map_err(NextJobError::Io)?; + file.rewind().map_err(NextJobError::Io)?; + + ( + u32::from_str_radix(buf.trim_end_matches("\n"), 16) + .map_err(NextJobError::FromStr)?, + file, + ) + } + Err(err) => match err.kind() { + std::io::ErrorKind::NotFound => ( + 0, + std::fs::File::create_new(job_file_number).map_err(NextJobError::Io)?, + ), + + _ => Err(NextJobError::Io(err))?, + }, + }; + + // Limit range of jobs to 2^20 jobs + let next_job_id = (1 + next_job_id) % 0xfffff; + + file.write_all(format!("{next_job_id:05x}").as_bytes()) + .map_err(NextJobError::Io)?; + + Ok(next_job_id) +} + +fn read_user_file(file_path: &str) -> std::io::Result> { + let content = std::fs::read_to_string(file_path)?; + Ok(content + .lines() + .map(|line| line.trim().to_string()) + .collect()) +} + +fn is_user_allowed(user: &str) -> bool { + let allow_file = "/etc/at.allow"; + let deny_file = "/etc/at.deny"; + + if let Ok(allowed_users) = read_user_file(allow_file) { + // If at.allow exists, only users from this file have access + return allowed_users.contains(user); + } + + if let Ok(denied_users) = read_user_file(deny_file) { + // If there is no at.allow, but there is at.deny, check if the user is blacklisted + return !denied_users.contains(user); + } + + // If there are no files, access is allowed to all + true +} + +fn login_name() -> Option { + // Try to get the login name using getlogin + unsafe { + let login_ptr = getlogin(); + if !login_ptr.is_null() { + if let Ok(c_str) = CStr::from_ptr(login_ptr).to_str() { + return Some(c_str.to_string()); + } + } + } + + // Fall back to checking the LOGNAME environment variable + env::var("LOGNAME").ok() +} + +pub struct User { + pub name: String, + pub shell: String, + pub uid: u32, + pub gid: u32, +} + +impl User { + pub fn new() -> Option { + const DEFAULT_SHELL: &str = "/bin/sh"; + + let login_name = login_name()?; + + let passwd { + pw_uid, + pw_gid, + pw_shell, + .. + } = user_info_by_name(&login_name)?; + + let pw_shell = match pw_shell.is_null() { + true => std::env::var("SHELL") + .ok() + .unwrap_or(DEFAULT_SHELL.to_owned()), + false => unsafe { + CStr::from_ptr(pw_shell) + .to_str() + .ok() + .unwrap_or(DEFAULT_SHELL) + .to_owned() + }, + }; + + Some(Self { + shell: pw_shell, + uid: pw_uid, + gid: pw_gid, + name: login_name, + }) + } +} + +fn user_info_by_name(name: &str) -> Option { + let c_name = CString::new(name).unwrap(); + let pw_ptr = unsafe { getpwnam(c_name.as_ptr()) }; + if pw_ptr.is_null() { + None + } else { + Some(unsafe { *pw_ptr }) + } +} diff --git a/cron/crond.rs b/cron/crond.rs new file mode 100644 index 00000000..53c180ee --- /dev/null +++ b/cron/crond.rs @@ -0,0 +1,164 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use chrono::Local; +use cron::job::Database; +use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; +use std::cmp::Ordering::{Greater, Less}; +use std::env; +use std::error::Error; +use std::fmt; +use std::fs; +use std::str::FromStr; +use std::sync::Mutex; +use std::time::UNIX_EPOCH; + +static CRONTAB: Mutex> = Mutex::new(None); +static LAST_MODIFIED: Mutex> = Mutex::new(None); + +#[derive(Debug)] +enum CronError { + Fork, + NoLogname, + NoCrontab, +} + +impl Error for CronError {} + +impl fmt::Display for CronError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoLogname => write!(f, "Could not obtain the user's logname"), + Self::NoCrontab => write!(f, "Could not format database"), + Self::Fork => write!(f, "Could not create child process"), + } + } +} + +/// Check if logname file is changed +fn is_file_changed(filepath: &str) -> Result> { + let last_modified = fs::metadata(filepath)? + .modified()? + .duration_since(UNIX_EPOCH)? + .as_secs(); + + let Some(last_checked) = *LAST_MODIFIED.lock().unwrap() else { + *LAST_MODIFIED.lock().unwrap() = Some(last_modified); + return Ok(true); + }; + if last_checked <= last_modified { + *LAST_MODIFIED.lock().unwrap() = Some(last_modified); + Ok(true) + } else { + Ok(false) + } +} + +/// Update [`CRONTAB`] if logname file is changed +fn sync_cronfile() -> Result<(), Box> { + let Ok(logname) = env::var("LOGNAME") else { + return Err(Box::new(CronError::NoLogname)); + }; + #[cfg(target_os = "linux")] + let file = format!("/var/spool/cron/{logname}"); + #[cfg(target_os = "macos")] + let file = format!("/var/at/tabs/{logname}"); + if (*CRONTAB.lock().unwrap()).is_none() || is_file_changed(&file)? { + let s = fs::read_to_string(&file)?; + let crontab = s + .lines() + .filter_map(|x| Database::from_str(x).ok()) + .fold(Database(vec![]), |acc, next| acc.merge(next)); + *CRONTAB.lock().unwrap() = Some(crontab); + } + Ok(()) +} + +/// Create new daemon process of crond +fn setup() -> i32 { + unsafe { + use libc::*; + + let pid = fork(); + if pid != 0 { + return pid; + } + + setsid(); + chdir(b"/\0" as *const _ as *const c_char); + + close(STDIN_FILENO); + close(STDOUT_FILENO); + close(STDERR_FILENO); + + pid + } +} + +/// Handles incoming signals +fn handle_signals(signal_code: libc::c_int) { + if signal_code == libc::SIGHUP { + if let Err(err) = sync_cronfile() { + eprintln!("{err}"); + std::process::exit(1); + } + } +} + +/// Daemon loop +fn daemon_loop() -> Result<(), Box> { + loop { + sync_cronfile()?; + let Some(db) = CRONTAB.lock().unwrap().clone() else { + return Err(Box::new(CronError::NoCrontab)); + }; + let Some(x) = db.nearest_job() else { + sleep(60); + continue; + }; + let Some(next_exec) = x.next_execution(&Local::now().naive_local()) else { + sleep(60); + continue; + }; + let now = Local::now(); + let diff = now.naive_local() - next_exec; + let sleep_time = diff.num_seconds(); + + if sleep_time < 60 { + sleep(sleep_time as u32); + x.run_job()?; + } else { + sleep(60); + } + } +} + +fn main() -> Result<(), Box> { + setlocale(LocaleCategory::LcAll, ""); + textdomain("posixutils-rs")?; + bind_textdomain_codeset("posixutils-rs", "UTF-8")?; + + let pid = setup(); + + match pid.cmp(&0) { + Less => return Err(Box::new(CronError::Fork)), + Greater => return Ok(()), + _ => {} + } + + unsafe { + libc::signal(libc::SIGHUP, handle_signals as usize); + } + + daemon_loop() +} + +fn sleep(target: u32) { + unsafe { libc::sleep(target) }; +} diff --git a/cron/crontab.rs b/cron/crontab.rs new file mode 100644 index 00000000..7cb1c327 --- /dev/null +++ b/cron/crontab.rs @@ -0,0 +1,172 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use clap::Parser; + +use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; +use std::env; +use std::fs; +use std::fs::File; +use std::io::{ErrorKind, Read, Result, Write}; +use std::process::{exit, ExitStatus}; + +#[derive(Parser)] +struct CronArgs { + #[arg(short, long, help = gettext("edit user's crontab"))] + edit: bool, + #[arg(short, long, help = gettext("list user's crontab"))] + list: bool, + #[arg(short, long, help = gettext("delete user's crontab"))] + remove: bool, + #[arg(name = "FILE", help = gettext("file to replace user's current crontab with"))] + file: Option, +} + +fn print_usage(err: &str) { + let name = env::args().next().unwrap(); + eprintln!("{name}: usage error: {err}"); + eprintln!("usage:\t{name} [ FILE ]"); + eprintln!("\t{name} [ -e | -l | -r ]"); + eprintln!("\t-e\t- edit user's crontab"); + eprintln!("\t-l\t- list user's crontab"); + eprintln!("\t-r\t- delete user's crontab"); +} + +fn list_crontab(path: &str) -> Result { + fs::read_to_string(path) +} + +fn remove_crontab(path: &str) -> Result<()> { + fs::remove_file(path) +} + +fn edit_crontab(path: &str) -> Result { + File::create(path)?; + let editor = env::var("EDITOR").unwrap_or("vi".to_string()); + let shell = env::var("SHELL").unwrap_or("sh".to_string()); + let args = ["-c".to_string(), format!("{editor} {path}")]; + std::process::Command::new(shell).args(args).status() +} + +fn replace_crontab(from: &str, to: &str) -> Result<()> { + let mut source = File::open(from)?; + let mut target = File::create(to)?; + let mut buffer = Vec::new(); + + source.read_to_end(&mut buffer)?; + target.write_all(&buffer)?; + + Ok(()) +} + +fn main() -> std::result::Result<(), Box> { + setlocale(LocaleCategory::LcAll, ""); + textdomain("posixutils-rs")?; + bind_textdomain_codeset("posixutils-rs", "UTF-8")?; + + let args = CronArgs::parse(); + let Ok(logname) = env::var("LOGNAME") else { + println!("Could not obtain the user's logname."); + exit(1); + }; + #[cfg(target_os = "linux")] + let path = format!("/var/spool/cron/{logname}"); + #[cfg(target_os = "macos")] + let path = format!("/var/spool/cron/{logname}"); + + let opt_count = [args.edit, args.list, args.remove, args.file.is_some()] + .into_iter() + .map(|x| x as i32) + .sum::(); + if opt_count > 1 { + print_usage("Too many options specified."); + exit(1); + } + + if opt_count < 1 { + print_usage("Not enough options specified."); + exit(1); + } + + if args.edit { + match edit_crontab(&path) { + Ok(status) => exit(status.code().unwrap_or(0)), + Err(err) => { + match err.kind() { + ErrorKind::NotFound => println!("No crontab file has been found."), + ErrorKind::PermissionDenied => { + println!("Permission to access user's crontab file denied.") + } + ErrorKind::Interrupted => println!("crontab was interrupted."), + ErrorKind::OutOfMemory => println!("crontab exceeded available memory."), + _ => println!("Unknown error: {}", err), + } + exit(1); + } + } + } + + if args.list { + match list_crontab(&path) { + Ok(content) => println!("{}", content), + Err(err) => { + match err.kind() { + ErrorKind::NotFound => println!("No crontab file has been found."), + ErrorKind::PermissionDenied => { + println!("Permission to access user's crontab file denied.") + } + ErrorKind::Interrupted => println!("crontab was interrupted."), + ErrorKind::OutOfMemory => println!("crontab exceeded available memory."), + _ => println!("Unknown error: {}", err), + } + exit(1); + } + } + } + + if args.remove { + match remove_crontab(&path) { + Ok(()) => println!("Removed crontab file"), + Err(err) => { + match err.kind() { + ErrorKind::NotFound => println!("No crontab file has been found."), + ErrorKind::PermissionDenied => { + println!("Permission to access user's crontab file denied.") + } + ErrorKind::Interrupted => println!("crontab was interrupted."), + ErrorKind::OutOfMemory => println!("crontab exceeded available memory."), + _ => println!("Unknown error: {}", err), + } + exit(1); + } + } + } + + if let Some(file) = args.file { + match replace_crontab(&file, &path) { + Ok(()) => println!("Replaced crontab file with {file}"), + Err(err) => { + match err.kind() { + ErrorKind::NotFound => { + println!("Crontab file or user-specified file has not been found.") + } + ErrorKind::PermissionDenied => println!( + "Permission to access user's crontab file or user-specified file denied." + ), + ErrorKind::Interrupted => println!("crontab was interrupted."), + ErrorKind::OutOfMemory => println!("crontab exceeded available memory."), + _ => println!("Unknown error: {}", err), + } + exit(1); + } + } + } + + Ok(()) +} diff --git a/cron/job.rs b/cron/job.rs new file mode 100644 index 00000000..0b53e3c8 --- /dev/null +++ b/cron/job.rs @@ -0,0 +1,301 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use chrono::{Datelike, Local, NaiveDate, NaiveDateTime, Timelike}; +use std::collections::BTreeSet; +use std::iter::Peekable; +use std::process::Command; +use std::str::FromStr; + +trait TimeUnit {} + +#[derive(Ord, PartialOrd, Eq, PartialEq, Copy, Clone, Debug)] +enum Value { + Number(i32), + Range { min: i32, max: i32, step: i32 }, +} + +macro_rules! time_unit { + ($name:ident from $min:literal to $max:literal) => { + #[derive(Ord, PartialOrd, Eq, PartialEq, Clone, Debug)] + pub struct $name(Option>); + + impl $name { + const fn range() -> std::ops::RangeInclusive { + $min..=$max + } + + fn to_vec(&self) -> Vec { + if let Some(x) = &self.0 { + let mut v = x + .iter() + .map(|x| match x { + Value::Number(x) => vec![*x], + Value::Range { min, max, step } => { + (*min..=*max).step_by(*step as usize).collect() + } + }) + .fold(vec![], |mut acc, x| { + acc.extend(x); + acc + }); + v.sort(); + v + } else { + Vec::from_iter(Self::range()) + } + } + + fn merge(self, other: Self) -> Self { + let mut a = self.to_vec(); + let b = other.to_vec(); + a.extend(b); + a.sort(); + a.dedup(); + Self(Some(a.into_iter().map(|x| Value::Number(x)).collect())) + } + + fn parse(s: impl AsRef) -> Result { + let s = s.as_ref(); + let mut v = Vec::new(); + let mut src = s.chars().peekable(); + + if expect(&mut src, '*') { + return Ok(Self(None)); + } + + loop { + if let None | Some(' ') = src.peek() { + break; + } + + let Some(min) = get_number(&mut src) else { + return Err(()); + }; + + if expect(&mut src, '-') { + let Some(max) = get_number(&mut src) else { + return Err(()); + }; + if expect(&mut src, '/') { + let Some(step) = get_number(&mut src) else { + return Err(()); + }; + v.push(Value::Range { min, max, step }); + } + v.push(Value::Range { min, max, step: 1 }); + } + v.push(Value::Number(min)); + } + + Ok(Self(Some(v))) + } + } + + impl TimeUnit for $name {} + }; +} + +time_unit!(Minute from 0 to 59); +time_unit!(Hour from 0 to 23); +time_unit!(MonthDay from 1 to 31); +time_unit!(Month from 1 to 12); +time_unit!(WeekDay from 0 to 6); + +#[derive(Eq, PartialEq, Clone, Debug)] +pub struct CronJob { + pub minute: Minute, + pub hour: Hour, + pub monthday: MonthDay, + pub month: Month, + pub weekday: WeekDay, + pub command: String, +} + +#[derive(Clone)] +pub struct Database(pub Vec); + +impl Database { + pub fn merge(mut self, other: Database) -> Database { + self.0.extend(other.0); + self + } + + pub fn nearest_job(&self) -> Option { + let now = Local::now().naive_local(); + self.0 + .iter() + .filter(|x| x.next_execution(&now).is_some()) + .min_by_key(|x| x.next_execution(&now)) + .cloned() + } +} + +impl FromStr for Database { + type Err = (); + + fn from_str(s: &str) -> Result { + let mut result = vec![]; + + for line in s.lines() { + let mut fields = line.split_ascii_whitespace(); + + let Some(minutes_field) = fields.next() else { + return Err(()); + }; + let Some(hours_field) = fields.next() else { + return Err(()); + }; + let Some(monthdays_field) = fields.next() else { + return Err(()); + }; + let Some(months_field) = fields.next() else { + return Err(()); + }; + let Some(weekdays_field) = fields.next() else { + return Err(()); + }; + let Some(command) = fields.next() else { + return Err(()); + }; + + let Ok(minute) = Minute::parse(minutes_field) else { + return Err(()); + }; + let Ok(hour) = Hour::parse(hours_field) else { + return Err(()); + }; + let Ok(monthday) = MonthDay::parse(monthdays_field) else { + return Err(()); + }; + let Ok(month) = Month::parse(months_field) else { + return Err(()); + }; + let Ok(weekday) = WeekDay::parse(weekdays_field) else { + return Err(()); + }; + + result.push(CronJob { + minute, + hour, + monthday, + month, + weekday, + command: command.to_string(), + }) + } + + Ok(Self(result)) + } +} + +impl CronJob { + pub fn next_execution(&self, now: &NaiveDateTime) -> Option { + let Self { + minute: minutes, + hour: hours, + month: months, + monthday: monthdays, + weekday: weekdays, + command: _, + } = self; + + let monthdays = monthdays.clone(); + let weekdays = weekdays.clone(); + + let monthdays = match (monthdays.clone().0, weekdays.clone().0) { + (Some(_), Some(_)) => { + monthdays.merge(convert_weekdays_to_monthdays(now.date(), weekdays)) + } + (None, Some(_)) => convert_weekdays_to_monthdays(now.date(), weekdays), + (Some(_), None) => monthdays, + (None, None) => MonthDay(None), + }; + + println!("{:?}", monthdays.to_vec()); + + for month in &months.to_vec() { + for monthday in &monthdays.to_vec() { + for hour in &hours.to_vec() { + for minute in &minutes.to_vec() { + let mut next_exec = *now; + let Some(date) = next_exec.with_minute(*minute as u32) else { + continue; + }; + next_exec = date; + let Some(date) = next_exec.with_hour(*hour as u32) else { + continue; + }; + next_exec = date; + let Some(date) = next_exec.with_day(*monthday as u32) else { + continue; + }; + next_exec = date; + let Some(date) = next_exec.with_month(*month as u32) else { + continue; + }; + next_exec = date; + + if next_exec > *now { + return Some(next_exec); + } + } + } + } + } + + None + } + + pub fn run_job(&self) -> std::io::Result { + Command::new("sh").args(["-c", &self.command]).output() + } +} + +fn get_number(src: &mut Peekable>) -> Option { + let mut number = String::new(); + + while let Some(&c) = src.peek() { + if c.is_ascii_digit() { + number.push(c); + src.next(); + } else { + break; + } + } + + number.parse().ok() +} + +fn expect(src: &mut Peekable>, expected: char) -> bool { + let Some(&c) = src.peek() else { return false }; + if c == expected { + src.next(); + true + } else { + false + } +} + +fn convert_weekdays_to_monthdays(date: NaiveDate, days: WeekDay) -> MonthDay { + let days = BTreeSet::from_iter(days.to_vec()); + let mut result = vec![]; + + for i in 1..=31 { + let Some(date) = date.with_day(i) else { + continue; + }; + let number = date.weekday().num_days_from_sunday() as i32; + if days.contains(&number) { + result.push(Value::Number(date.day() as i32)); + } + } + + MonthDay(Some(result)) +} diff --git a/cron/lib.rs b/cron/lib.rs new file mode 100644 index 00000000..5238b5a9 --- /dev/null +++ b/cron/lib.rs @@ -0,0 +1,10 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +pub mod job; diff --git a/cron/test_files/at/cmd_for_job.txt b/cron/test_files/at/cmd_for_job.txt new file mode 100644 index 00000000..565f50b9 --- /dev/null +++ b/cron/test_files/at/cmd_for_job.txt @@ -0,0 +1 @@ +echo 'echo "Hello, world!"' \ No newline at end of file diff --git a/cron/tests/at/mod.rs b/cron/tests/at/mod.rs new file mode 100644 index 00000000..105fb8d7 --- /dev/null +++ b/cron/tests/at/mod.rs @@ -0,0 +1,338 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use std::sync::Mutex; + +static TEST_MUTEX: Mutex<()> = Mutex::new(()); + +use std::{fs, path::Path}; + +use plib::testing::{run_test, TestPlan}; +use tempfile::{tempdir, TempDir}; + +fn setup_test_env() -> (TempDir, String) { + let temp_dir = tempdir().expect("Unable to create temporary directory"); + let dir_path = temp_dir.path().join("testdir"); + (temp_dir, dir_path.to_str().unwrap().to_string()) +} + +fn run_test_at( + args: &[&str], + expected_output: &str, + expected_error: &str, + expected_exit_code: i32, +) { + let str_args: Vec = args.iter().map(|s| String::from(*s)).collect(); + + run_test(TestPlan { + cmd: String::from("at"), + args: str_args, + stdin_data: String::new(), + expected_out: String::from(expected_output), + expected_err: String::from(expected_error), + expected_exit_code, + }); +} + +#[test] +fn test1() { + let _lock = TEST_MUTEX.lock().unwrap(); + + let (_temp_dir, dir_path) = setup_test_env(); + + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + let args = ["05:53amNOV4,2100", "-f", &file]; + + let expected_output = "job 1 at Thu Nov 04 05:53:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let res_file = Path::new(&dir_path).join("a00001041a0e81"); + assert!(res_file.exists()); + + fs::remove_file(res_file).expect("Unable to remove test file"); +} + +#[test] +fn test2() { + let _lock = TEST_MUTEX.lock().unwrap(); + + let (_temp_dir, dir_path) = setup_test_env(); + + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["05:53amNOV4,2100+30minutes", "-f", &file]; + + let expected_output = "job 1 at Thu Nov 04 06:23:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let res_file = Path::new(&dir_path).join("a00001041a0e9f"); + assert!(res_file.exists()); + + fs::remove_file(res_file).expect("Unable to remove test file"); +} + +#[test] +fn test3() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["05:53amNOV4,2100+1day", "-f", &file]; + + let expected_output = "job 1 at Fri Nov 05 05:53:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let res_file = Path::new(&dir_path).join("a00001041a1421"); + assert!(res_file.exists()); + + fs::remove_file(res_file).expect("Unable to remove test file"); +} + +#[test] +fn test4() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["midnightNOV4,2100", "-f", &file]; + + let expected_output = "job 1 at Thu Nov 04 00:00:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let res_file = Path::new(&dir_path).join("a00001041a0d20"); + assert!(res_file.exists()); + + fs::remove_file(res_file).expect("Unable to remove test file"); +} + +#[test] +fn test6() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["05:53pmNOV4,2100+1day", "-f", &file]; + + let expected_output = "job 1 at Fri Nov 05 17:53:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let res_file = Path::new(&dir_path).join("a00001041a16f1"); + assert!(res_file.exists()); + + fs::remove_file(res_file).expect("Unable to remove test file"); +} + +#[test] +fn test7() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["15:53NOV4,2100+1day", "-f", &file]; + + let expected_output = "job 1 at Fri Nov 05 15:53:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let res_file = Path::new(&dir_path).join("a00001041a1679"); + assert!(res_file.exists()); + + fs::remove_file(res_file).expect("Unable to remove test file"); +} + +#[test] +fn test8() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["midnightNOV4,2100", "-f", &file, "-q", "b"]; + + let expected_output = "job 1 at Thu Nov 04 00:00:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let res_file = Path::new(&dir_path).join("b00001041a0d20"); + assert!(res_file.exists()); + + fs::remove_file(res_file).expect("Unable to remove test file"); +} + +#[test] +fn test9() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["-t", "210012131200", "-f", &file]; + + let expected_output = "job 1 at Mon Dec 13 12:00:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let res_file = Path::new(&dir_path).join("a00001041aeb50"); + assert!(res_file.exists()); + + fs::remove_file(res_file).expect("Unable to remove test file"); +} + +#[test] +fn test10() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["midnightNOV4,2100", "-f", &file, "-q", "b"]; + + let expected_output = "job 1 at Thu Nov 04 00:00:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let args2 = ["-r", "1"]; + let expected_output2 = ""; + run_test_at(&args2, expected_output2, "", 0); +} + +#[test] +fn test11() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["midnightNOV4,2100", "-f", &file, "-q", "b"]; + + let expected_output = "job 1 at Thu Nov 04 00:00:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let args2 = ["midnightNOV4,2099", "-f", &file]; + + let expected_output2 = "job 2 at Wed Nov 04 00:00:00 2099\n"; + + run_test_at(&args2, expected_output2, "", 0); + + let args3 = ["-l", "-q", "b"]; + + let expected_output3 = "1 Thu Nov 04 00:00:00 2100 b\n"; + run_test_at(&args3, expected_output3, "", 0); +} + +#[test] +fn test12() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["midnightNOV4,2100", "-f", &file, "-q", "b"]; + + let expected_output = "job 1 at Thu Nov 04 00:00:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let args2 = ["midnightNOV4,2099", "-f", &file]; + + let expected_output2 = "job 2 at Wed Nov 04 00:00:00 2099\n"; + + run_test_at(&args2, expected_output2, "", 0); + + let args3 = ["-l"]; + + let expected_output3 = + "1 Thu Nov 04 00:00:00 2100 b\n2 Wed Nov 04 00:00:00 2099 a\n"; + run_test_at(&args3, expected_output3, "", 0); +} + +#[test] +fn test13() { + let _lock = TEST_MUTEX.lock().unwrap(); + let (_temp_dir, dir_path) = setup_test_env(); + fs::create_dir(&dir_path).expect("Unable to create test directory"); + + std::env::set_var("AT_JOB_DIR", &dir_path); + std::env::set_var("LOGNAME", "root"); + + let file = "test_files/at/cmd_for_job.txt".to_string(); + + let args = ["midnightNOV4,2100", "-f", &file, "-q", "b"]; + + let expected_output = "job 1 at Thu Nov 04 00:00:00 2100\n"; + + run_test_at(&args, expected_output, "", 0); + + let args2 = ["midnightNOV4,2099", "-f", &file]; + + let expected_output2 = "job 2 at Wed Nov 04 00:00:00 2099\n"; + + run_test_at(&args2, expected_output2, "", 0); + + let args3 = ["-l", "2"]; + + let expected_output3 = "2 Wed Nov 04 00:00:00 2099 a\n"; + run_test_at(&args3, expected_output3, "", 0); +} diff --git a/cron/tests/cron-tests.rs b/cron/tests/cron-tests.rs new file mode 100644 index 00000000..a032ec43 --- /dev/null +++ b/cron/tests/cron-tests.rs @@ -0,0 +1,12 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +mod at; +mod crond; +mod crontab; diff --git a/cron/tests/crond/mod.rs b/cron/tests/crond/mod.rs new file mode 100644 index 00000000..09e3253b --- /dev/null +++ b/cron/tests/crond/mod.rs @@ -0,0 +1,252 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +mod pid; + +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; +use cron::job::Database; +use std::io::Write; +use std::process::{Command, Output, Stdio}; +use std::str::FromStr; +use std::thread; +use std::time::Duration; + +fn run_test_base(cmd: &str, args: &Vec, stdin_data: &[u8]) -> Output { + let relpath = if cfg!(debug_assertions) { + format!("target/debug/{}", cmd) + } else { + format!("target/release/{}", cmd) + }; + let test_bin_path = std::env::current_dir() + .unwrap() + .parent() + .unwrap() // Move up to the workspace root from the current package directory + .join(relpath); // Adjust the path to the binary + + let mut command = Command::new(test_bin_path); + let mut child = command + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect(format!("failed to spawn command {}", cmd).as_str()); + + // Separate the mutable borrow of stdin from the child process + if let Some(mut stdin) = child.stdin.take() { + let chunk_size = 1024; // Arbitrary chunk size, adjust if needed + for chunk in stdin_data.chunks(chunk_size) { + // Write each chunk + if let Err(e) = stdin.write_all(chunk) { + eprintln!("Error writing to stdin: {}", e); + break; + } + // Flush after writing each chunk + if let Err(e) = stdin.flush() { + eprintln!("Error flushing stdin: {}", e); + break; + } + + // Sleep briefly to avoid CPU spinning + thread::sleep(Duration::from_millis(10)); + } + // Explicitly drop stdin to close the pipe + drop(stdin); + } + + // Ensure we wait for the process to complete after writing to stdin + let output = child.wait_with_output().expect("failed to wait for child"); + output +} + +#[test] +fn no_args() { + std::env::set_var("LOGNAME", "root"); + let output = run_test_base("crond", &vec![], b""); + assert_eq!(output.status.code(), Some(0)); +} + +#[test] +fn test_leap_year() { + let database = "* * 29 * * echo Ok".parse::().unwrap(); + + let start_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2028, 1, 30).unwrap(), + NaiveTime::from_hms_opt(15, 38, 00).unwrap(), + ); + + let expected_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2028, 2, 29).unwrap(), + NaiveTime::from_hms_opt(00, 00, 0).unwrap(), + ); + + assert_eq!( + expected_date, + database + .nearest_job() + .unwrap() + .next_execution(&start_date) + .unwrap() + ); +} + +#[test] +fn test_minute() { + let database = "10 * * * * echo Ok".parse::().unwrap(); + + let start_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), + NaiveTime::from_hms_opt(15, 38, 00).unwrap(), + ); + + let expected_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), + NaiveTime::from_hms_opt(16, 10, 0).unwrap(), + ); + + assert_eq!( + expected_date, + database + .nearest_job() + .unwrap() + .next_execution(&start_date) + .unwrap() + ); +} + +#[test] +fn test_hour() { + let database = "* 1 * * * echo Ok".parse::().unwrap(); + + let start_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), + NaiveTime::from_hms_opt(15, 38, 00).unwrap(), + ); + + let expected_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 1, 2).unwrap(), + NaiveTime::from_hms_opt(1, 0, 0).unwrap(), + ); + + assert_eq!( + expected_date, + database + .nearest_job() + .unwrap() + .next_execution(&start_date) + .unwrap() + ); +} + +#[test] +fn test_weekday() { + let database = "* * * * 0 echo Ok".parse::().unwrap(); + + let start_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 2, 1).unwrap(), + NaiveTime::from_hms_opt(15, 38, 00).unwrap(), + ); + + let expected_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 2, 6).unwrap(), + NaiveTime::from_hms_opt(00, 00, 0).unwrap(), + ); + + assert_eq!( + expected_date, + database + .nearest_job() + .unwrap() + .next_execution(&start_date) + .unwrap() + ); +} + +#[test] +fn test_monthday() { + let database = "* * 20 * * echo Ok".parse::().unwrap(); + + let start_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), + NaiveTime::from_hms_opt(15, 38, 00).unwrap(), + ); + + let expected_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 1, 20).unwrap(), + NaiveTime::from_hms_opt(00, 00, 0).unwrap(), + ); + + assert_eq!( + expected_date, + database + .nearest_job() + .unwrap() + .next_execution(&start_date) + .unwrap() + ); +} + +#[test] +fn test_month() { + let database = "* * * 12 * echo Ok".parse::().unwrap(); + + let start_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), + NaiveTime::from_hms_opt(15, 38, 00).unwrap(), + ); + + let expected_date = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 12, 1).unwrap(), + NaiveTime::from_hms_opt(00, 00, 0).unwrap(), + ); + + assert_eq!( + expected_date, + database + .nearest_job() + .unwrap() + .next_execution(&start_date) + .unwrap() + ); +} + +#[test] +fn test_signal() { + std::env::set_var("LOGNAME", "root"); + + let logname = std::env::var("LOGNAME").unwrap_or("root".to_string()); + #[cfg(target_os = "linux")] + let file = format!("/var/spool/cron/{logname}"); + #[cfg(target_os = "macos")] + let file = format!("/var/at/tabs/{logname}"); + + let output = run_test_base("crond", &vec![], b""); + assert_eq!(output.status.code(), Some(0)); + + let pids = pid::get_pids("target/debug/crond").unwrap(); + + if std::path::PathBuf::from_str(&file).unwrap().exists() { + assert!(!pids.is_empty()); + } + + for pid in &pids { + unsafe { + libc::kill(*pid, libc::SIGHUP); + } + } + + let mut old_pids = pids; + let mut pids = pid::get_pids("target/debug/crond").unwrap(); + + pids.sort(); + old_pids.sort(); + assert!(pids == old_pids || !pids.is_empty()); + + let _ = pid::kill("target/debug/crond").unwrap(); +} diff --git a/cron/tests/crond/pid.rs b/cron/tests/crond/pid.rs new file mode 100644 index 00000000..58d51eca --- /dev/null +++ b/cron/tests/crond/pid.rs @@ -0,0 +1,99 @@ +use std::process::{Command, Output, Stdio}; +use std::string::FromUtf8Error; + +fn run_command(cmd: &mut Command) -> Result { + cmd.stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("Failed to execute command: {}", e)) +} + +pub fn get_pids(process_name: &str) -> Result, String> { + // Use '[v]i' pattern to prevent grep from matching itself + let grep_pattern = + format!("[{}]", process_name.chars().next().unwrap_or('?')) + &process_name[1..]; + let list_cmd_str = format!("ps aux | grep '{}'", grep_pattern); + + let mut list_cmd = Command::new("sh"); + list_cmd.arg("-c").arg(&list_cmd_str); + + let output = run_command(&mut list_cmd)?; + + if !output.status.success() { + // `grep` returns non-zero if no lines match, which isn't necessarily a hard error here. + // Check stderr for actual execution errors from `ps` or `sh`. + if !output.stderr.is_empty() && output.stdout.is_empty() { + return Err(format!( + "Process listing command failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + } + + let stdout_str = String::from_utf8(output.stdout.clone()) + .map_err(|e: FromUtf8Error| format!("Failed to parse stdout as UTF-8: {}", e))?; + + println!("{}", stdout_str); + let mut pids = Vec::new(); + for line in stdout_str.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + // `ps aux` typically has PID in the second column (index 1) + if parts.len() > 1 { + if let Ok(pid) = parts[1].parse::() { + // Double-check it's not the grep command itself if the simple pattern was used + if !line.contains("grep") { + pids.push(pid); + } + } + } + } + + Ok(pids) +} + +pub fn kill(process_name: &str) -> Result<(), String> { + let pids = get_pids(process_name)?; + + if pids.is_empty() { + return Ok(()); + } + + let mut kill_errors = Vec::new(); + + for pid in pids { + let mut kill_cmd = Command::new("kill"); + kill_cmd.arg("-9").arg(pid.to_string()); // Send SIGKILL + + match run_command(&mut kill_cmd) { + Ok(kill_output) => { + if !kill_output.status.success() { + // Kill can fail due to permissions or if the process died already + let err_msg = format!( + "Failed to kill PID {}: Exit status: {}, Stderr: {}", + pid, + kill_output.status, + String::from_utf8_lossy(&kill_output.stderr).trim() + ); + eprintln!("{}", err_msg); // Print immediately + kill_errors.push(err_msg); + } + } + Err(e) => { + // Error executing the kill command itself + let err_msg = format!("Failed to execute kill command for PID {}: {}", pid, e); + eprintln!("{}", err_msg); + kill_errors.push(err_msg); + } + } + } + + if kill_errors.is_empty() { + Ok(()) + } else { + Err(format!( + "Encountered errors while killing processes:\n{}", + kill_errors.join("\n") + )) + } +} diff --git a/cron/tests/crontab/mod.rs b/cron/tests/crontab/mod.rs new file mode 100644 index 00000000..20ecb29a --- /dev/null +++ b/cron/tests/crontab/mod.rs @@ -0,0 +1,91 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use std::io::Write; +use std::process::{Command, Output, Stdio}; +use std::thread; +use std::time::Duration; + +fn run_test_base(cmd: &str, args: &Vec, stdin_data: &[u8]) -> Output { + let relpath = if cfg!(debug_assertions) { + format!("target/debug/{}", cmd) + } else { + format!("target/release/{}", cmd) + }; + let test_bin_path = std::env::current_dir() + .unwrap() + .parent() + .unwrap() // Move up to the workspace root from the current package directory + .join(relpath); // Adjust the path to the binary + + let mut command = Command::new(test_bin_path); + let mut child = command + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect(format!("failed to spawn command {}", cmd).as_str()); + + // Separate the mutable borrow of stdin from the child process + if let Some(mut stdin) = child.stdin.take() { + let chunk_size = 1024; // Arbitrary chunk size, adjust if needed + for chunk in stdin_data.chunks(chunk_size) { + // Write each chunk + if let Err(e) = stdin.write_all(chunk) { + eprintln!("Error writing to stdin: {}", e); + break; + } + // Flush after writing each chunk + if let Err(e) = stdin.flush() { + eprintln!("Error flushing stdin: {}", e); + break; + } + + // Sleep briefly to avoid CPU spinning + thread::sleep(Duration::from_millis(10)); + } + // Explicitly drop stdin to close the pipe + drop(stdin); + } + + // Ensure we wait for the process to complete after writing to stdin + let output = child.wait_with_output().expect("failed to wait for child"); + output +} + +#[test] +fn no_args() { + let output = run_test_base("crontab", &vec![], b""); + assert_eq!(output.status.code(), Some(1)); +} + +#[test] +fn dash_e() { + let output = run_test_base("crontab", &vec!["-e".to_string()], b""); + assert_eq!(output.status.code(), Some(1)); +} + +#[test] +fn dash_l() { + let output = run_test_base("crontab", &vec!["-l".to_string()], b""); + assert_eq!(output.status.code(), Some(1)); +} + +#[test] +fn dash_r() { + let output = run_test_base("crontab", &vec!["-r".to_string()], b""); + assert_eq!(output.status.code(), Some(1)); +} + +#[test] +fn too_many_args() { + let output = run_test_base("crontab", &vec!["-erl".to_string()], b""); + assert_eq!(output.status.code(), Some(1)); +}