launchify 0.2.0

Easy launch agents for macOS
use handlebars::Handlebars;
use regex::Regex;
use serde_json::json;
use std::env;
use std::fmt;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::process::Command;
use std::str::FromStr;
use structopt::StructOpt;

const PLIST_TEMPLATE: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"
  \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
<dict>
    <key>Label</key>
    <string>{{name}}</string>
    <key>ProgramArguments</key>
    <array>
        <string>{{program_path}}</string>{{#each args}}
        <string>{{this}}</string>{{/each}}
    </array>
    <key>StartInterval</key>
    <integer>{{interval}}</integer>
    <key>StandardOutPath</key>
    <string>{{stdout}}</string>
    <key>StandardErrorPath</key>
    <string>{{stderr}}</string>
    <key>WorkingDirectory</key>
    <string>{{working_dir}}</string>
</dict>
</plist>";

fn main() {
    let args = Cli::from_args();

    if let Err(err) = run(args) {
        println!("Error: {}", err);
        std::process::exit(1);
    }
}

fn run(args: Cli) -> io::Result<()> {
    let path = fs::canonicalize(&args.program)?;

    let cfg = match LaunchConfig::from_cli(&args, path) {
        Some(c) => c,
        None => {
            return Err(io::Error::new(
                io::ErrorKind::Other,
                "could not generate launch config",
            ))
        }
    };

    let plist_file = match PlistFile::from(&cfg) {
        Some(plist) => plist,
        None => {
            return Err(io::Error::new(
                io::ErrorKind::Other,
                "could not generate plist file",
            ))
        }
    };

    if args.dry_run {
        println!("Dry run: would write {}", plist_file);
        return Ok(());
    }

    cfg.dirs.ensure()?;
    plist_file.write()?;
    plist_file.load()?;
    println!("successfuly scheduled {}", cfg.name);

    Ok(())
}

#[derive(Debug)]
enum Period {
    Day(u64),
    Hour(u64),
    Minute(u64),
    Second(u64),
}

impl Period {
    fn to_seconds(&self) -> u64 {
        match self {
            Self::Day(days) => 24 * 60 * 60 * days,
            Self::Hour(hours) => 60 * 60 * hours,
            Self::Minute(mins) => 60 * mins,
            &Self::Second(secs) => secs,
        }
    }
}

#[derive(Debug, Clone)]
struct ParsePeriodError;

impl fmt::Display for ParsePeriodError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Error parsing period")
    }
}

impl FromStr for Period {
    type Err = ParsePeriodError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let re = Regex::new(r"^(\d+)(d|h|m|s)$").unwrap();
        let caps = match re.captures(s) {
            Some(caps) => caps,
            None => return Err(ParsePeriodError),
        };

        if caps.len() != 3 {
            return Err(ParsePeriodError);
        }

        let value: u64 = match caps[1].parse() {
            Ok(val) => val,
            Err(_) => return Err(ParsePeriodError),
        };

        let period = match &caps[2] {
            "d" => Period::Day,
            "h" => Period::Hour,
            "m" => Period::Minute,
            "s" => Period::Second,
            _ => return Err(ParsePeriodError),
        };

        Ok(period(value))
    }
}

#[derive(StructOpt)]
struct Cli {
    period: Period,
    program: String,

    #[structopt(long)]
    dry_run: bool,

    #[structopt(long)]
    name: Option<String>,

    #[structopt(long)]
    args: Option<String>,

    #[structopt(long)]
    working_dir: Option<String>,
}

struct LaunchConfig {
    name: String,
    program_path: PathBuf,
    start_interval: u64,
    dirs: LaunchDirs,
    args: Vec<String>,
    working_dir: String,
}

impl LaunchConfig {
    fn from_cli(args: &Cli, path: PathBuf) -> Option<Self> {
        let name = match &args.name {
            Some(name) => name.to_owned(),
            None => path.file_stem()?.to_str()?.to_owned(),
        };

        let start_interval = args.period.to_seconds();
        let dirs = LaunchDirs::from(&name)?;

        let program_args = match &args.args {
            Some(a) => a.split_ascii_whitespace().map(|s| s.to_string()).collect(),
            None => Vec::new(),
        };

        let working_dir = match &args.working_dir {
            Some(dir) => dir.to_owned(),
            None => match env::current_dir() {
                Ok(dir) => dir.to_str()?.to_owned(),
                Err(_) => return None,
            },
        };

        Some(Self {
            name,
            program_path: path,
            start_interval,
            dirs,
            args: program_args,
            working_dir,
        })
    }

    fn plist_contents(&self) -> Option<String> {
        let program_path = self.program_path.to_str()?;
        let stdout_path = self.log_path("stdout")?;
        let stderr_path = self.log_path("stderr")?;

        let reg = Handlebars::new();
        match reg.render_template(
            PLIST_TEMPLATE,
            &json!(
                {
                    "name": self.name,
                    "program_path": program_path,
                    "args": self.args,
                    "interval": self.start_interval,
                    "stdout": stdout_path,
                    "stderr": stderr_path,
                    "working_dir": self.working_dir,
                }
            ),
        ) {
            Ok(contents) => Some(contents),
            Err(_) => None,
        }
    }

    fn log_path(&self, filename: &str) -> Option<String> {
        let mut path = self.dirs.log_dir.to_owned();
        path.push(filename);
        match path.to_str() {
            Some(p) => Some(p.to_owned()),
            None => None,
        }
    }

    fn plist_filepath(&self) -> Option<PathBuf> {
        let filename = format!("com.{}.plist", self.name);
        let mut filepath = dirs::home_dir()?;

        filepath.push("Library");
        filepath.push("LaunchAgents");
        filepath.push(filename);
        Some(filepath)
    }
}

struct LaunchDirs {
    log_dir: PathBuf,
    plist_dir: PathBuf,
}

impl LaunchDirs {
    fn from(name: &str) -> Option<Self> {
        let mut log_dir = dirs::home_dir()?;
        let mut plist_dir = log_dir.clone();

        log_dir.push("logs");
        log_dir.push(name);

        plist_dir.push("Library");
        plist_dir.push("LaunchAgents");

        Some(Self { log_dir, plist_dir })
    }

    fn ensure(&self) -> io::Result<()> {
        fs::create_dir_all(&self.log_dir)?;
        fs::create_dir_all(&self.plist_dir)?;
        Ok(())
    }
}

struct PlistFile {
    filepath: PathBuf,
    contents: String,
}

impl PlistFile {
    fn from(cfg: &LaunchConfig) -> Option<Self> {
        let filepath = cfg.plist_filepath()?;
        let contents = cfg.plist_contents()?;
        Some(Self { filepath, contents })
    }

    fn write(&self) -> io::Result<()> {
        fs::write(&self.filepath, &self.contents)
    }

    fn load(&self) -> io::Result<()> {
        let filepath = match self.filepath.to_str() {
            Some(path) => path,
            None => {
                return Err(io::Error::new(
                    io::ErrorKind::Other,
                    "could not convert filepath to str",
                ))
            }
        };
        let status = Command::new("launchctl")
            .args(&["load", "-w", filepath])
            .status()?;

        if status.success() {
            Ok(())
        } else {
            Err(io::Error::new(
                io::ErrorKind::Other,
                "failed to load plist file",
            ))
        }
    }
}

impl fmt::Display for PlistFile {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let filepath = match self.filepath.to_str() {
            Some(path) => path,
            None => return Err(fmt::Error),
        };

        write!(f, "{}:\n{}", filepath, self.contents)
    }
}