cronrunner/
crontab.rs

1pub(crate) mod hash;
2
3pub mod parser;
4pub mod reader;
5pub mod tokens;
6
7use std::borrow::Cow;
8use std::collections::HashMap;
9use std::env;
10use std::fmt::Write;
11use std::path::PathBuf;
12use std::process::{Command, Stdio};
13
14use self::parser::Parser;
15use self::reader::{ReadError, Reader};
16use self::tokens::{CronJob, Token};
17
18/// Default shell used if not overridden by a variable in the crontab.
19const DEFAULT_SHELL: &str = "/bin/sh";
20
21#[derive(Debug)]
22struct ShellCommand {
23    env: HashMap<String, String>,
24    shell: String,
25    home: PathBuf,
26    command: String,
27}
28
29/// Low level detail about the run result.
30///
31/// This is only meant to be used attached to a [`RunResult`], provided
32/// by [`Crontab`].
33#[derive(Debug, Eq, PartialEq)]
34pub enum RunResultDetail {
35    /// If the command could be run.
36    DidRun {
37        /// The exit code, or `None` if the process was killed early.
38        exit_code: Option<i32>,
39    },
40    /// If the command failed to execute at all (e.g., executable not
41    /// found).
42    DidNotRun {
43        /// Explanation of the error in plain English.
44        reason: String,
45    },
46    /// If the command is run in detached mode and the child process got
47    /// spawned successfully.
48    IsRunning { pid: u32 },
49}
50
51/// Info about a run, provided by [`Crontab`] once it is finished.
52#[derive(Debug, Eq, PartialEq)]
53pub struct RunResult {
54    /// Whether the command was successful or not. _Successful_ means
55    /// the command ran _AND_ exited without errors (exit 0).
56    ///
57    /// <div class="warning">
58    ///
59    /// Commands ran in detached mode will set `was_successful` to
60    /// `false`. This is not a special case according to the previous
61    /// definition (the command did not yet exit), but it can be
62    /// surprising. Instead, detached commands take advantage of
63    /// `detail` to tell whether it was launched successfully, and
64    /// provide a PID in that case.
65    ///
66    /// </div>
67    pub was_successful: bool,
68    /// Detail about the run. May contain exit code or reason of
69    /// failure, see [`RunResultDetail`].
70    pub detail: RunResultDetail,
71}
72
73/// Do things with jobs found in the crontab.
74///
75/// Chiefly, [`Crontab`] provides the [`run()`](Crontab::run()) method,
76/// and takes a [`Vec<Token>`](Token) as input, usually from [`Parser`].
77#[derive(Debug)]
78pub struct Crontab {
79    pub tokens: Vec<Token>,
80    env: Option<HashMap<String, String>>,
81}
82
83impl Crontab {
84    #[must_use]
85    pub fn new(tokens: Vec<Token>) -> Self {
86        Self { tokens, env: None }
87    }
88
89    /// Whether there are jobs in the crontab at all.
90    ///
91    /// Crontab could be empty or only contain variables, comments or
92    /// unrecognized tokens.
93    #[must_use]
94    pub fn has_runnable_jobs(&self) -> bool {
95        self.tokens
96            .iter()
97            .any(|token| matches!(token, Token::CronJob(_)))
98    }
99
100    /// All the jobs, and only the jobs.
101    #[must_use]
102    pub fn jobs(&self) -> Vec<&CronJob> {
103        self.tokens
104            .iter()
105            .filter_map(|token| {
106                if let Token::CronJob(job) = token {
107                    Some(job)
108                } else {
109                    None
110                }
111            })
112            .collect()
113    }
114
115    /// Whether a given job is in the crontab or not.
116    #[must_use]
117    pub fn has_job(&self, job: &CronJob) -> bool {
118        self.jobs().contains(&job)
119    }
120
121    /// Get a job object from its [`UID`](CronJob::uid).
122    #[must_use]
123    pub fn get_job_from_uid(&self, uid: usize) -> Option<&CronJob> {
124        self.jobs().into_iter().find(|job| job.uid == uid)
125    }
126
127    /// Get a job object from its [`fingerprint`](CronJob::fingerprint).
128    #[must_use]
129    pub fn get_job_from_fingerprint(&self, fingerprint: u64) -> Option<&CronJob> {
130        self.jobs()
131            .into_iter()
132            .find(|job| job.fingerprint == fingerprint)
133    }
134
135    /// Get a job object from its [`tag`](CronJob::tag).
136    #[must_use]
137    pub fn get_job_from_tag(&self, tag: &str) -> Option<&CronJob> {
138        self.jobs()
139            .into_iter()
140            .find(|job| job.tag.as_ref().is_some_and(|job_tag| job_tag == tag))
141    }
142
143    /// Override `Crontab`'s default inherited environment.
144    ///
145    /// By default, jobs are run inheriting the env from the parent
146    /// process. This method lets you set a custom environment instead.
147    ///
148    /// <div class="warning">
149    ///
150    /// Environments are not additive. The job's env is _replaced_ by
151    /// `env`, and not merged with it. If you want to merge the envs,
152    /// you will have to do that yourself beforehand.
153    ///
154    /// </div>
155    ///
156    /// Note that `set_env()` has no effect on variables declared inside
157    /// the crontab or those set on a per-job basis. It only overrides
158    /// the default parent-process-inherited environment.
159    ///
160    /// This requires the `Crontab` instance to be _mutable_.
161    ///
162    /// # Examples
163    ///
164    /// ```rust
165    /// # use std::collections::HashMap;
166    /// # use cronrunner::crontab::Crontab;
167    /// # let mut crontab: Crontab = Crontab::new(Vec::new());
168    /// // let mut crontab = crontab::make_instance()?;
169    ///
170    /// crontab.set_env(HashMap::from([
171    ///     (String::from("FOO"), String::from("bar")),
172    ///     (String::from("BAZ"), String::from("42")),
173    /// ]));
174    ///
175    /// // let res = crontab.run(/* ... */);
176    /// ```
177    pub fn set_env(&mut self, env: HashMap<String, String>) {
178        self.env = Some(env);
179    }
180
181    /// Run a job.
182    ///
183    /// By default, the job inherits the environment from the parent
184    /// process. Use [`Crontab::set_env()`] to set a custom environment
185    /// instead.
186    ///
187    /// # Examples
188    ///
189    /// ```rust
190    /// # use cronrunner::crontab::{Crontab, RunResult};
191    /// # use cronrunner::tokens::{CronJob, Token};
192    /// #
193    /// # let crontab: Crontab = Crontab::new(vec![Token::CronJob(CronJob {
194    /// #     uid: 1,
195    /// #     fingerprint: 13_376_942,
196    /// #     tag: None,
197    /// #     schedule: String::new(),
198    /// #     command: String::new(),
199    /// #     description: None,
200    /// #     section: None,
201    /// # })]);
202    /// #
203    /// let job: &CronJob = crontab.get_job_from_uid(1).expect("pretend it exists");
204    ///
205    /// let result: RunResult = crontab.run(job);
206    ///
207    /// if result.was_successful {
208    ///     // ...
209    /// }
210    /// ```
211    ///
212    /// # Errors
213    ///
214    /// [`Crontab::run()`] will return a [`RunResult`] regardless of
215    /// whether the run succeeded or not.
216    ///
217    /// [`RunResult::was_successful`] will be set to `true` if the
218    /// command ran _AND_ returned `0`, and will be set to `false` in
219    /// any other case.
220    ///
221    /// Unless a run failed (as in the command was not run at all), the
222    /// exit code will be provided in [`RunResult`] (but can be `None`
223    /// if the process got killed).
224    ///
225    /// A run can fail if:
226    ///
227    /// - An invalid job UID was provided.
228    /// - The Home directory cannot be read from the environment.
229    /// - The shell executable cannot be found.
230    #[must_use]
231    pub fn run(&self, job: &CronJob) -> RunResult {
232        let mut command = match self.prepare_command(job) {
233            Ok(command) => command,
234            Err(res) => return res,
235        };
236
237        let status = command.status();
238
239        match status {
240            Ok(status) => RunResult {
241                was_successful: status.success(),
242                detail: RunResultDetail::DidRun {
243                    exit_code: status.code(),
244                },
245            },
246            Err(_) => RunResult {
247                was_successful: false,
248                detail: RunResultDetail::DidNotRun {
249                    reason: String::from("Failed to run command (does shell exist?)."),
250                },
251            },
252        }
253    }
254
255    /// Run and detach job.
256    ///
257    /// Mostly the same as [`Crontab::run()`], but doesn't wait for the
258    /// job to be finished (returns immediately).
259    ///
260    /// # Examples
261    ///
262    /// ```rust
263    /// # use cronrunner::crontab::{Crontab, RunResult, RunResultDetail};
264    /// # use cronrunner::tokens::{CronJob, Token};
265    /// #
266    /// # let crontab: Crontab = Crontab::new(vec![Token::CronJob(CronJob {
267    /// #     uid: 1,
268    /// #     fingerprint: 13_376_942,
269    /// #     tag: None,
270    /// #     schedule: String::new(),
271    /// #     command: String::new(),
272    /// #     description: None,
273    /// #     section: None,
274    /// # })]);
275    /// #
276    /// let job: &CronJob = crontab.get_job_from_fingerprint(13_376_942).expect("pretend it exists");
277    ///
278    /// let result: RunResult = crontab.run_detached(job);
279    ///
280    /// if let RunResultDetail::IsRunning { pid } = result.detail {
281    ///     // ...
282    /// }
283    /// ```
284    ///
285    /// # Errors
286    ///
287    /// [`Crontab::run()`] will return a [`RunResult`] regardless of
288    /// whether the run succeeded or not.
289    ///
290    /// [`RunResult::was_successful`] will always be set to `false`,
291    /// because the job is only spawned, we don't wait for it to finish.
292    ///
293    /// [`RunResult::detail`] will be [`RunResultDetail::IsRunning`],
294    /// which will contain the PID of the spawned process.
295    #[must_use]
296    pub fn run_detached(&self, job: &CronJob) -> RunResult {
297        let mut command = match self.prepare_command(job) {
298            Ok(command) => command,
299            Err(res) => return res,
300        };
301
302        #[cfg(not(tarpaulin_include))] // Wrongly marked uncovered.
303        let child = command
304            .stdin(Stdio::null())
305            .stdout(Stdio::null())
306            .stderr(Stdio::null())
307            .spawn();
308
309        match child {
310            Ok(child) => RunResult {
311                // We don't know yet, and `false` enables us to take
312                // advantage of `detail` more easily, as calling code
313                // will naturally fall back to it.
314                was_successful: false,
315                detail: RunResultDetail::IsRunning { pid: child.id() },
316            },
317            Err(_) => RunResult {
318                was_successful: false,
319                detail: RunResultDetail::DidNotRun {
320                    reason: String::from("Failed to run command (does shell exist?)."),
321                },
322            },
323        }
324    }
325
326    fn prepare_command(&self, job: &CronJob) -> Result<Command, RunResult> {
327        let shell_command = match self.make_shell_command(job) {
328            Ok(shell_command) => shell_command,
329            Err(reason) => {
330                return Err(RunResult {
331                    was_successful: false,
332                    detail: RunResultDetail::DidNotRun { reason },
333                });
334            }
335        };
336
337        #[cfg(not(tarpaulin_include))] // Wrongly marked uncovered.
338        {
339            let mut command = Command::new(shell_command.shell);
340
341            if let Some(env) = self.env.as_ref() {
342                command.env_clear().envs(env);
343            }
344
345            command
346                .envs(&shell_command.env)
347                .current_dir(shell_command.home)
348                .arg("-c")
349                .arg(shell_command.command);
350
351            Ok(command)
352        }
353    }
354
355    fn make_shell_command(&self, job: &CronJob) -> Result<ShellCommand, String> {
356        self.ensure_job_exists(job)?;
357
358        let mut env = self.extract_variables(job);
359        let shell = Self::determine_shell_to_use(&mut env);
360        let home = Self::determine_home_to_use(&mut env)?;
361        let command = job.command.clone();
362
363        Ok(ShellCommand {
364            env,
365            shell,
366            home,
367            command,
368        })
369    }
370
371    fn ensure_job_exists(&self, job: &CronJob) -> Result<(), String> {
372        if !self.has_job(job) {
373            return Err(String::from("The given job is not in the crontab."));
374        }
375        Ok(())
376    }
377
378    fn extract_variables(&self, target_job: &CronJob) -> HashMap<String, String> {
379        let mut variables: HashMap<String, String> = HashMap::new();
380        for token in &self.tokens {
381            if let Token::Variable(variable) = token {
382                variables.insert(variable.identifier.clone(), variable.value.clone());
383            } else if let Token::CronJob(job) = token {
384                if job == target_job {
385                    break; // Variables coming after the job are not used.
386                }
387            }
388        }
389        variables
390    }
391
392    fn determine_shell_to_use(env: &mut HashMap<String, String>) -> String {
393        if let Some(shell) = env.remove("SHELL") {
394            // Set explicitly in Crontab's env.
395            shell
396        } else {
397            String::from(DEFAULT_SHELL)
398        }
399    }
400
401    fn determine_home_to_use(env: &mut HashMap<String, String>) -> Result<PathBuf, String> {
402        if let Some(home) = env.remove("HOME") {
403            // Set explicitly in Crontab's env.
404            Ok(PathBuf::from(home))
405        } else {
406            Ok(Self::get_home_directory()?)
407        }
408    }
409
410    fn get_home_directory() -> Result<PathBuf, String> {
411        if let Some(home_directory) = env::home_dir() {
412            Ok(home_directory)
413        } else {
414            Err(String::from("Could not determine Home directory."))
415        }
416    }
417}
418
419impl Crontab {
420    #[must_use]
421    pub fn to_json(&self) -> String {
422        let jobs = self.jobs();
423
424        let mut json = String::with_capacity(jobs.len() * 250);
425        let mut jobs = jobs.iter().peekable();
426
427        _ = write!(json, "[");
428        while let Some(job) = jobs.next() {
429            _ = write!(json, "{{");
430            _ = write!(json, r#""uid":{},"#, job.uid);
431            _ = write!(json, r#""fingerprint":"{:x}","#, job.fingerprint);
432            _ = write!(
433                json,
434                r#""tag":{},"#,
435                job.tag.as_ref().map_or_else(
436                    || Cow::Borrowed("null"),
437                    |tag| { Cow::Owned(format!(r#""{}""#, tag.replace('"', r#"\""#))) }
438                )
439            );
440            _ = write!(json, r#""schedule":"{}","#, job.schedule);
441            _ = write!(
442                json,
443                r#""command":"{}","#,
444                job.command.replace('"', r#"\""#)
445            );
446            _ = write!(
447                json,
448                r#""description":{},"#,
449                job.description.as_ref().map_or_else(
450                    || Cow::Borrowed("null"),
451                    |description| {
452                        Cow::Owned(format!(r#""{}""#, description.0.replace('"', r#"\""#)))
453                    }
454                )
455            );
456            _ = write!(
457                json,
458                r#""section":{}"#,
459                job.section.as_ref().map_or_else(
460                    || Cow::Borrowed("null"),
461                    |section| Cow::Owned(format!(
462                        r#"{{"uid":{},"title":"{}"}}"#,
463                        section.uid,
464                        section.title.replace('"', r#"\""#)
465                    ))
466                )
467            );
468            _ = write!(json, "}}");
469
470            if jobs.peek().is_some() {
471                _ = write!(json, ",");
472            }
473        }
474        _ = write!(json, "]");
475
476        json
477    }
478}
479
480/// Create an instance of [`Crontab`].
481///
482/// This helper reads the current user's crontab and creates a
483/// [`Crontab`] instance out of it.
484///
485/// # Examples
486///
487/// ```rust
488/// use cronrunner::crontab;
489///
490/// let crontab = match crontab::make_instance() {
491///     Ok(crontab) => crontab,
492///     Err(_) => return (),
493/// };
494/// ```
495///
496/// # Errors
497///
498/// Will forward [`ReadError`] from [`Reader`] if any.
499pub fn make_instance() -> Result<Crontab, ReadError> {
500    let crontab: String = Reader::read()?;
501    let tokens: Vec<Token> = Parser::parse(&crontab);
502
503    Ok(Crontab::new(tokens))
504}
505
506#[cfg(test)]
507mod tests {
508    use self::tokens::{Comment, CommentKind, JobDescription, Variable};
509    use super::*;
510
511    // Warning: These tests MUST be run sequentially. Running them in
512    // parallel threads may cause conflicts with environment variables,
513    // as a variable may be overridden before it is used.
514
515    fn tokens() -> Vec<Token> {
516        vec![
517            Token::Comment(Comment {
518                value: String::from("# CronRunner Demo"),
519                kind: CommentKind::Regular,
520            }),
521            Token::Comment(Comment {
522                value: String::from("# ---------------"),
523                kind: CommentKind::Regular,
524            }),
525            Token::CronJob(CronJob {
526                uid: 1,
527                fingerprint: 13_376_942,
528                tag: None,
529                schedule: String::from("@reboot"),
530                command: String::from("/usr/bin/bash ~/startup.sh"),
531                description: None,
532                section: None,
533            }),
534            Token::Comment(Comment {
535                value: String::from(
536                    "# Double-hash comments (##) immediately preceding a job are used as",
537                ),
538                kind: CommentKind::Regular,
539            }),
540            Token::Comment(Comment {
541                value: String::from("# description. See below:"),
542                kind: CommentKind::Regular,
543            }),
544            Token::Comment(Comment {
545                value: String::from("## Update brew."),
546                kind: CommentKind::Description,
547            }),
548            Token::CronJob(CronJob {
549                uid: 2,
550                fingerprint: 13_376_942,
551                tag: None,
552                schedule: String::from("30 20 * * *"),
553                command: String::from("/usr/local/bin/brew update && /usr/local/bin/brew upgrade"),
554                description: Some(JobDescription(String::from("Update brew."))),
555                section: None,
556            }),
557            Token::Variable(Variable {
558                identifier: String::from("FOO"),
559                value: String::from("bar"),
560            }),
561            Token::Comment(Comment {
562                value: String::from("## Print variable."),
563                kind: CommentKind::Description,
564            }),
565            Token::CronJob(CronJob {
566                uid: 3,
567                fingerprint: 13_376_942,
568                tag: None,
569                schedule: String::from("* * * * *"),
570                command: String::from("echo $FOO"),
571                description: Some(JobDescription(String::from("Print variable."))),
572                section: None,
573            }),
574            Token::Comment(Comment {
575                value: String::from("# Do nothing (this is a regular comment)."),
576                kind: CommentKind::Regular,
577            }),
578            Token::CronJob(CronJob {
579                uid: 4,
580                fingerprint: 13_376_942,
581                tag: None,
582                schedule: String::from("@reboot"),
583                command: String::from(":"),
584                description: None,
585                section: None,
586            }),
587            Token::Variable(Variable {
588                identifier: String::from("SHELL"),
589                value: String::from("/bin/bash"),
590            }),
591            Token::CronJob(CronJob {
592                uid: 5,
593                fingerprint: 13_376_942,
594                tag: None,
595                schedule: String::from("@hourly"),
596                command: String::from("echo 'I am echoed by bash!'"),
597                description: None,
598                section: None,
599            }),
600            Token::Variable(Variable {
601                identifier: String::from("HOME"),
602                value: String::from("/home/<custom>"),
603            }),
604            Token::CronJob(CronJob {
605                uid: 6,
606                fingerprint: 13_376_942,
607                tag: None,
608                schedule: String::from("@yerly"),
609                command: String::from("./cleanup.sh"),
610                description: None,
611                section: None,
612            }),
613        ]
614    }
615
616    #[test]
617    fn has_runnable_jobs() {
618        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
619            uid: 1,
620            fingerprint: 13_376_942,
621            tag: None,
622            schedule: String::from("@hourly"),
623            command: String::from("echo 'hello, world'"),
624            description: None,
625            section: None,
626        })]);
627
628        assert!(crontab.has_runnable_jobs());
629    }
630
631    #[test]
632    fn has_no_runnable_jobs() {
633        let crontab = Crontab::new(vec![
634            Token::Comment(Comment {
635                value: String::from("# This is a comment"),
636                kind: CommentKind::Regular,
637            }),
638            Token::Variable(Variable {
639                identifier: String::from("SHELL"),
640                value: String::from("/bin/bash"),
641            }),
642        ]);
643
644        assert!(!crontab.has_runnable_jobs());
645    }
646
647    #[test]
648    fn has_no_runnable_jobs_because_crontab_is_empty() {
649        let crontab = Crontab::new(vec![]);
650
651        assert!(!crontab.has_runnable_jobs());
652    }
653
654    #[test]
655    fn list_of_jobs() {
656        let crontab = Crontab::new(tokens());
657
658        let tokens = tokens();
659        let jobs: Vec<&CronJob> = tokens
660            .iter()
661            .filter_map(|token| {
662                if let Token::CronJob(job) = token {
663                    Some(job)
664                } else {
665                    None
666                }
667            })
668            .collect();
669
670        assert_eq!(crontab.jobs(), jobs);
671    }
672
673    #[test]
674    fn has_job() {
675        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
676            uid: 1,
677            fingerprint: 13_376_942,
678            tag: None,
679            schedule: String::from("@daily"),
680            command: String::from("docker image prune --force"),
681            description: None,
682            section: None,
683        })]);
684
685        // Same job, same UID.
686        assert!(crontab.has_job(&CronJob {
687            uid: 1,
688            fingerprint: 13_376_942,
689            tag: None,
690            schedule: String::from("@daily"),
691            command: String::from("docker image prune --force"),
692            description: None,
693            section: None,
694        }),);
695        // Same job, different UID.
696        assert!(!crontab.has_job(&CronJob {
697            uid: 0,
698            fingerprint: 13_376_942,
699            tag: None,
700            schedule: String::from("@daily"),
701            command: String::from("docker image prune --force"),
702            description: None,
703            section: None,
704        }),);
705        // Different job, same UID.
706        assert!(!crontab.has_job(&CronJob {
707            uid: 1,
708            fingerprint: 13_376_942,
709            tag: None,
710            schedule: String::from("<invalid>"),
711            command: String::from("<invalid>"),
712            description: None,
713            section: None,
714        }),);
715    }
716
717    #[test]
718    fn get_job_from_uid() {
719        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
720            uid: 1,
721            fingerprint: 13_376_942,
722            tag: None,
723            schedule: String::from("@reboot"),
724            command: String::from("echo 'hello, world'"),
725            description: None,
726            section: None,
727        })]);
728
729        let job = crontab.get_job_from_uid(1).unwrap();
730
731        assert_eq!(
732            *job,
733            CronJob {
734                uid: 1,
735                fingerprint: 13_376_942,
736                tag: None,
737                schedule: String::from("@reboot"),
738                command: String::from("echo 'hello, world'"),
739                description: None,
740                section: None,
741            }
742        );
743    }
744
745    #[test]
746    fn get_job_from_uid_not_in_crontab() {
747        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
748            uid: 1,
749            fingerprint: 13_376_942,
750            tag: None,
751            schedule: String::from("@daily"),
752            command: String::from("echo 'hello, world'"),
753            description: None,
754            section: None,
755        })]);
756
757        let job = crontab.get_job_from_uid(42);
758
759        assert!(job.is_none());
760    }
761
762    #[test]
763    fn get_job_from_fingerprint() {
764        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
765            uid: 1,
766            fingerprint: 13_376_942,
767            tag: None,
768            schedule: String::from("@reboot"),
769            command: String::from("echo 'hello, world'"),
770            description: None,
771            section: None,
772        })]);
773
774        let job = crontab.get_job_from_fingerprint(13_376_942).unwrap();
775
776        assert_eq!(
777            *job,
778            CronJob {
779                uid: 1,
780                fingerprint: 13_376_942,
781                tag: None,
782                schedule: String::from("@reboot"),
783                command: String::from("echo 'hello, world'"),
784                description: None,
785                section: None,
786            }
787        );
788    }
789
790    #[test]
791    fn get_job_from_fingerprint_not_in_crontab() {
792        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
793            uid: 1,
794            fingerprint: 13_376_942,
795            tag: None,
796            schedule: String::from("@daily"),
797            command: String::from("echo 'hello, world'"),
798            description: None,
799            section: None,
800        })]);
801
802        let job = crontab.get_job_from_fingerprint(42);
803
804        assert!(job.is_none());
805    }
806
807    #[test]
808    fn get_job_from_tag() {
809        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
810            uid: 1,
811            fingerprint: 13_376_942,
812            tag: Some(String::from("my-tag")),
813            schedule: String::from("@reboot"),
814            command: String::from("echo 'hello, world'"),
815            description: None,
816            section: None,
817        })]);
818
819        let job = crontab.get_job_from_tag("my-tag").unwrap();
820
821        assert_eq!(
822            *job,
823            CronJob {
824                uid: 1,
825                fingerprint: 13_376_942,
826                tag: Some(String::from("my-tag")),
827                schedule: String::from("@reboot"),
828                command: String::from("echo 'hello, world'"),
829                description: None,
830                section: None,
831            }
832        );
833    }
834
835    #[test]
836    fn get_job_from_tag_not_in_crontab() {
837        let crontab = Crontab::new(vec![
838            Token::CronJob(CronJob {
839                uid: 1,
840                fingerprint: 13_376_942,
841                tag: None,
842                schedule: String::from("@daily"),
843                command: String::from("echo 'hello, world'"),
844                description: None,
845                section: None,
846            }),
847            Token::CronJob(CronJob {
848                uid: 2,
849                fingerprint: 369_108,
850                tag: Some(String::from("MY-TAG")),
851                schedule: String::from("@daily"),
852                command: String::from("echo 'hello, world'"),
853                description: None,
854                section: None,
855            }),
856        ]);
857
858        let job = crontab.get_job_from_tag("my-tag");
859
860        assert!(job.is_none());
861    }
862
863    #[test]
864    fn two_equal_jobs_are_treated_as_different_jobs() {
865        let crontab = Crontab::new(vec![
866            Token::CronJob(CronJob {
867                uid: 1,
868                fingerprint: 13_376_942,
869                tag: None,
870                schedule: String::from("@daily"),
871                command: String::from("df -h > ~/track_disk_usage.txt"),
872                description: Some(JobDescription(String::from("Track disk usage."))),
873                section: None,
874            }),
875            Token::Variable(Variable {
876                identifier: String::from("FOO"),
877                value: String::from("bar"),
878            }),
879            Token::CronJob(CronJob {
880                uid: 2,
881                fingerprint: 108_216_215,
882                tag: None,
883                schedule: String::from("@daily"),
884                command: String::from("df -h > ~/track_disk_usage.txt"),
885                description: Some(JobDescription(String::from("Track disk usage."))),
886                section: None,
887            }),
888        ]);
889
890        let job = crontab.get_job_from_uid(2).unwrap();
891        let command = crontab.make_shell_command(job).unwrap();
892
893        // If 'FOO=bar' is not included, it means the first of the twin
894        // jobs was used instead of the second that we selected.
895        assert_eq!(
896            command.env,
897            HashMap::from([(String::from("FOO"), String::from("bar"))])
898        );
899        assert_eq!(command.command, "df -h > ~/track_disk_usage.txt");
900    }
901
902    #[test]
903    fn set_env() {
904        let mut crontab = Crontab::new(Vec::new());
905
906        assert!(crontab.env.is_none());
907
908        crontab.set_env(HashMap::from([(String::from("FOO"), String::from("bar"))]));
909
910        assert!(
911            crontab.env.is_some_and(
912                |env| env == HashMap::from([(String::from("FOO"), String::from("bar"))])
913            )
914        );
915    }
916
917    #[test]
918    fn set_env_replaces_previous_one() {
919        let mut crontab = Crontab::new(Vec::new());
920
921        let env1 = HashMap::from([(String::from("FOO"), String::from("bar"))]);
922        let env2 = HashMap::from([(String::from("BAZ"), String::from("42"))]);
923
924        crontab.set_env(env1);
925        crontab.set_env(env2.clone());
926
927        assert!(crontab.env.is_some_and(|env| env == env2));
928    }
929
930    #[test]
931    fn working_directory_is_home_directory() {
932        unsafe {
933            env::set_var("HOME", "/home/<test>");
934        }
935
936        let home_directory = Crontab::get_home_directory().unwrap();
937
938        assert_eq!(home_directory.to_string_lossy(), "/home/<test>");
939    }
940
941    #[test]
942    fn run_cron_without_variable() {
943        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
944            uid: 1,
945            fingerprint: 13_376_942,
946            tag: None,
947            schedule: String::from("@reboot"),
948            command: String::from("/usr/bin/bash ~/startup.sh"),
949            description: Some(JobDescription(String::from("Description."))),
950            section: None,
951        })]);
952
953        let job = crontab.get_job_from_uid(1).unwrap();
954        let command = crontab.make_shell_command(job).unwrap();
955
956        assert_eq!(command.command, "/usr/bin/bash ~/startup.sh");
957    }
958
959    #[test]
960    fn run_cron_with_variable() {
961        let crontab = Crontab::new(vec![
962            Token::Variable(Variable {
963                identifier: String::from("FOO"),
964                value: String::from("bar"),
965            }),
966            Token::CronJob(CronJob {
967                uid: 1,
968                fingerprint: 13_376_942,
969                tag: None,
970                schedule: String::from("* * * * *"),
971                command: String::from("echo $FOO"),
972                description: Some(JobDescription(String::from("Print variable."))),
973                section: None,
974            }),
975        ]);
976
977        let job = crontab.get_job_from_uid(1).unwrap();
978        let command = crontab.make_shell_command(job).unwrap();
979
980        assert_eq!(
981            command.env,
982            HashMap::from([(String::from("FOO"), String::from("bar"))])
983        );
984        assert_eq!(command.command, "echo $FOO");
985    }
986
987    #[test]
988    fn run_cron_after_variable_but_not_right_after_it() {
989        let crontab = Crontab::new(vec![
990            Token::Variable(Variable {
991                identifier: String::from("FOO"),
992                value: String::from("bar"),
993            }),
994            Token::Comment(Comment {
995                value: String::from("## Print variable."),
996                kind: CommentKind::Description,
997            }),
998            Token::CronJob(CronJob {
999                uid: 1,
1000                fingerprint: 13_376_942,
1001                tag: None,
1002                schedule: String::from("* * * * *"),
1003                command: String::from("echo $FOO"),
1004                description: Some(JobDescription(String::from("Print variable."))),
1005                section: None,
1006            }),
1007            Token::Comment(Comment {
1008                value: String::from("# Do nothing (this is a regular comment)."),
1009                kind: CommentKind::Regular,
1010            }),
1011            Token::CronJob(CronJob {
1012                uid: 2,
1013                fingerprint: 13_376_942,
1014                tag: None,
1015                schedule: String::from("@reboot"),
1016                command: String::from(":"),
1017                description: None,
1018                section: None,
1019            }),
1020        ]);
1021
1022        let job = crontab.get_job_from_uid(2).unwrap();
1023        let command = crontab.make_shell_command(job).unwrap();
1024
1025        assert_eq!(
1026            command.env,
1027            HashMap::from([(String::from("FOO"), String::from("bar"))])
1028        );
1029        assert_eq!(command.command, ":");
1030    }
1031
1032    #[test]
1033    fn double_variable_change() {
1034        let crontab = Crontab::new(vec![
1035            Token::Variable(Variable {
1036                identifier: String::from("FOO"),
1037                value: String::from("bar"),
1038            }),
1039            Token::Variable(Variable {
1040                identifier: String::from("FOO"),
1041                value: String::from("baz"),
1042            }),
1043            Token::CronJob(CronJob {
1044                uid: 1,
1045                fingerprint: 13_376_942,
1046                tag: None,
1047                schedule: String::from("30 9 * * * "),
1048                command: String::from("echo 'gm'"),
1049                description: None,
1050                section: None,
1051            }),
1052        ]);
1053
1054        let job = crontab.get_job_from_uid(1).unwrap();
1055        let command = crontab.make_shell_command(job).unwrap();
1056
1057        assert_eq!(
1058            command.env,
1059            HashMap::from([(String::from("FOO"), String::from("baz"))])
1060        );
1061        assert_eq!(command.command, "echo 'gm'");
1062    }
1063
1064    #[test]
1065    fn run_cron_with_default_shell() {
1066        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
1067            uid: 1,
1068            fingerprint: 13_376_942,
1069            tag: None,
1070            schedule: String::from("@reboot"),
1071            command: String::from("cat a-file.txt"),
1072            description: None,
1073            section: None,
1074        })]);
1075
1076        let job = crontab.get_job_from_uid(1).unwrap();
1077        let command = crontab.make_shell_command(job).unwrap();
1078
1079        assert_eq!(command.shell, DEFAULT_SHELL);
1080        assert_eq!(command.command, "cat a-file.txt");
1081    }
1082
1083    #[test]
1084    fn run_cron_with_different_shell() {
1085        let crontab = Crontab::new(vec![
1086            Token::Variable(Variable {
1087                identifier: String::from("SHELL"),
1088                value: String::from("/bin/bash"),
1089            }),
1090            Token::CronJob(CronJob {
1091                uid: 1,
1092                fingerprint: 13_376_942,
1093                tag: None,
1094                schedule: String::from("@hourly"),
1095                command: String::from("echo 'I am echoed by bash!'"),
1096                description: None,
1097                section: None,
1098            }),
1099        ]);
1100
1101        let job = crontab.get_job_from_uid(1).unwrap();
1102        let command = crontab.make_shell_command(job).unwrap();
1103
1104        assert_eq!(command.env, HashMap::new());
1105        assert_eq!(command.shell, "/bin/bash");
1106        assert_eq!(command.command, "echo 'I am echoed by bash!'");
1107    }
1108
1109    #[test]
1110    fn shell_variable_is_removed_from_env() {
1111        let crontab = Crontab::new(vec![
1112            Token::Variable(Variable {
1113                identifier: String::from("SHELL"),
1114                value: String::from("/bin/<custom>"),
1115            }),
1116            Token::CronJob(CronJob {
1117                uid: 1,
1118                fingerprint: 13_376_942,
1119                tag: None,
1120                schedule: String::from("@hourly"),
1121                command: String::from("echo 'I am echoed by a custom shell!'"),
1122                description: None,
1123                section: None,
1124            }),
1125        ]);
1126
1127        let job = crontab.get_job_from_uid(1).unwrap();
1128        let command = crontab.make_shell_command(job).unwrap();
1129
1130        assert!(!command.env.contains_key("SHELL"));
1131        assert_eq!(command.shell, "/bin/<custom>");
1132    }
1133
1134    #[test]
1135    fn double_shell_change() {
1136        let crontab = Crontab::new(vec![
1137            Token::Variable(Variable {
1138                identifier: String::from("SHELL"),
1139                value: String::from("/bin/bash"),
1140            }),
1141            Token::CronJob(CronJob {
1142                uid: 1,
1143                fingerprint: 13_376_942,
1144                tag: None,
1145                schedule: String::from("@hourly"),
1146                command: String::from("echo 'I am echoed by bash!'"),
1147                description: None,
1148                section: None,
1149            }),
1150            Token::Variable(Variable {
1151                identifier: String::from("SHELL"),
1152                value: String::from("/bin/zsh"),
1153            }),
1154            Token::CronJob(CronJob {
1155                uid: 2,
1156                fingerprint: 13_376_942,
1157                tag: None,
1158                schedule: String::from("@hourly"),
1159                command: String::from("echo 'I am echoed by zsh!'"),
1160                description: None,
1161                section: None,
1162            }),
1163        ]);
1164
1165        let job = crontab.get_job_from_uid(2).unwrap();
1166        let command = crontab.make_shell_command(job).unwrap();
1167
1168        assert_eq!(command.shell, "/bin/zsh");
1169        assert_eq!(command.command, "echo 'I am echoed by zsh!'");
1170    }
1171
1172    #[test]
1173    fn run_cron_with_default_home() {
1174        unsafe {
1175            env::set_var("HOME", "/home/<default>");
1176        }
1177
1178        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
1179            uid: 1,
1180            fingerprint: 13_376_942,
1181            tag: None,
1182            schedule: String::from("@daily"),
1183            command: String::from("/usr/bin/bash ~/startup.sh"),
1184            description: None,
1185            section: None,
1186        })]);
1187
1188        let job = crontab.get_job_from_uid(1).unwrap();
1189        let command = crontab.make_shell_command(job).unwrap();
1190
1191        assert_eq!(command.home.to_string_lossy(), "/home/<default>");
1192    }
1193
1194    #[test]
1195    fn run_cron_with_different_home() {
1196        unsafe {
1197            env::set_var("HOME", "/home/<default>");
1198        }
1199
1200        let crontab = Crontab::new(vec![
1201            Token::Variable(Variable {
1202                identifier: String::from("HOME"),
1203                value: String::from("/home/<custom>"),
1204            }),
1205            Token::CronJob(CronJob {
1206                uid: 1,
1207                fingerprint: 13_376_942,
1208                tag: None,
1209                schedule: String::from("@yearly"),
1210                command: String::from("./cleanup.sh"),
1211                description: None,
1212                section: None,
1213            }),
1214        ]);
1215
1216        let job = crontab.get_job_from_uid(1).unwrap();
1217        let command = crontab.make_shell_command(job).unwrap();
1218
1219        assert_eq!(command.env, HashMap::new());
1220        assert_eq!(command.home.to_string_lossy(), "/home/<custom>");
1221        assert_eq!(command.command, "./cleanup.sh");
1222    }
1223
1224    #[test]
1225    fn home_variable_is_removed_from_env() {
1226        let crontab = Crontab::new(vec![
1227            Token::Variable(Variable {
1228                identifier: String::from("HOME"),
1229                value: String::from("/home/<custom>"),
1230            }),
1231            Token::CronJob(CronJob {
1232                uid: 1,
1233                fingerprint: 13_376_942,
1234                tag: None,
1235                schedule: String::from("@hourly"),
1236                command: String::from("echo 'I am echoed in a different Home!'"),
1237                description: None,
1238                section: None,
1239            }),
1240        ]);
1241
1242        let job = crontab.get_job_from_uid(1).unwrap();
1243        let command = crontab.make_shell_command(job).unwrap();
1244
1245        assert!(!command.env.contains_key("HOME"));
1246        assert_eq!(command.home.to_string_lossy(), "/home/<custom>");
1247    }
1248
1249    // Failure is too hard, and too platform-specific to provoke.
1250    //#[test]
1251    //fn get_home_directory_error() {
1252    //    unsafe {
1253    //        env::remove_var("HOME");
1254    //    }
1255    //
1256    //    let crontab = Crontab::new(vec![Token::CronJob(CronJob {
1257    //        uid: 1,
1258    //        fingerprint: 13_376_942,
1259    //        tag: None,
1260    //        schedule: String::from("@reboot"),
1261    //        command: String::from("/usr/bin/bash ~/startup.sh"),
1262    //        description: None,
1263    //        section: None,
1264    //    })]);
1265    //
1266    //    let job = crontab.get_job_from_uid(1).unwrap();
1267    //    let error = crontab.make_shell_command(job).unwrap_err();
1268    //
1269    //    assert_eq!(error, "Could not read Home directory from environment.");
1270    //
1271    //    // If we don't re-create it, other tests will fail.
1272    //    unsafe {
1273    //        env::set_var("HOME", "/home/<test>");
1274    //    }
1275    //}
1276
1277    #[test]
1278    fn double_home_change() {
1279        let crontab = Crontab::new(vec![
1280            Token::Variable(Variable {
1281                identifier: String::from("HOME"),
1282                value: String::from("/home/user1"),
1283            }),
1284            Token::CronJob(CronJob {
1285                uid: 1,
1286                fingerprint: 13_376_942,
1287                tag: None,
1288                schedule: String::from("@hourly"),
1289                command: String::from("echo 'I run is user1's Home!'"),
1290                description: None,
1291                section: None,
1292            }),
1293            Token::Variable(Variable {
1294                identifier: String::from("HOME"),
1295                value: String::from("/home/user2"),
1296            }),
1297            Token::CronJob(CronJob {
1298                uid: 2,
1299                fingerprint: 13_376_942,
1300                tag: None,
1301                schedule: String::from("@hourly"),
1302                command: String::from("echo 'I run is user2's Home!'"),
1303                description: None,
1304                section: None,
1305            }),
1306        ]);
1307
1308        let job = crontab.get_job_from_uid(2).unwrap();
1309        let command = crontab.make_shell_command(job).unwrap();
1310
1311        assert_eq!(command.home.to_string_lossy(), "/home/user2");
1312        assert_eq!(command.command, "echo 'I run is user2's Home!'");
1313    }
1314
1315    #[test]
1316    fn run_cron_with_non_existing_job() {
1317        let crontab = Crontab::new(vec![Token::CronJob(CronJob {
1318            uid: 1,
1319            fingerprint: 13_376_942,
1320            tag: None,
1321            schedule: String::from("@hourly"),
1322            command: String::from("echo 'I am echoed by bash!'"),
1323            description: None,
1324            section: None,
1325        })]);
1326        let job_not_in_crontab = CronJob {
1327            uid: 42,
1328            fingerprint: 13_376_942,
1329            tag: None,
1330            schedule: String::from("@never"),
1331            command: String::from("sleep infinity"),
1332            description: None,
1333            section: None,
1334        };
1335
1336        let error = crontab.make_shell_command(&job_not_in_crontab).unwrap_err();
1337
1338        assert_eq!(error, "The given job is not in the crontab.");
1339    }
1340
1341    #[test]
1342    fn to_json() {
1343        let crontab = Crontab::new(vec![
1344            Token::Variable(Variable {
1345                identifier: String::from("HOME"),
1346                value: String::from("/home/user1"),
1347            }),
1348            Token::CronJob(CronJob {
1349                uid: 1,
1350                fingerprint: 13_376_942,
1351                tag: Some(String::from("taggy \"tag\"")),
1352                schedule: String::from("@daily"),
1353                command: String::from("/usr/bin/bash ~/startup.sh"),
1354                description: None,
1355                section: None,
1356            }),
1357            Token::Variable(Variable {
1358                identifier: String::from("HOME"),
1359                value: String::from("/home/user2"),
1360            }),
1361            Token::CronJob(CronJob {
1362                uid: 2,
1363                fingerprint: 17_118_619_922_108_271_534,
1364                tag: None,
1365                schedule: String::from("* * * * *"),
1366                command: String::from("echo \"$FOO\""),
1367                description: Some(JobDescription(String::from("Print \"variable\"."))),
1368                section: Some(tokens::JobSection {
1369                    uid: 1,
1370                    title: String::from("Some \"testing\" going on here..."),
1371                }),
1372            }),
1373        ]);
1374
1375        let json = crontab.to_json();
1376
1377        println!("{}", &json);
1378        assert_eq!(
1379            json,
1380            r#"[{"uid":1,"fingerprint":"cc1dae","tag":"taggy \"tag\"","schedule":"@daily","command":"/usr/bin/bash ~/startup.sh","description":null,"section":null},{"uid":2,"fingerprint":"ed918e1eee304bae","tag":null,"schedule":"* * * * *","command":"echo \"$FOO\"","description":"Print \"variable\".","section":{"uid":1,"title":"Some \"testing\" going on here..."}}]"#
1381        );
1382    }
1383}