sopht 0.3.0

cute program for managing long running processes in a (slightly) more sophisticated way than tmux
Documentation
//! this program implements a sopht client capable of sending one of the
//! currently implemented commands to a server, receiving a response, then
//! exiting
//!
use anyhow::{bail, Result};
use pico_args::Arguments;
use sopht::{
    create_client_connection, ChangeRestartPolicyArgs, Command, ExitStatus,
    Message, ProcessStatus, RestartPolicy, SendArgs, StartArgs, PID,
};
use std::fmt::Write;
use std::fs;
use std::path::PathBuf;

fn main() {
    let usage = usage();

    if Arguments::from_env().finish().is_empty() {
        print!("{}", usage);
        return;
    }

    // handle `--help` and `-h`
    let mut args = Arguments::from_env();
    if args.contains("--help") || args.contains("-h") {
        print!("{}", usage);
        return;
    }

    // pull the command out of the arguments and, if it's the help command,
    // print usage and exit
    let cmd = match args.subcommand().expect("non UTF-8 arguments supplied") {
        Some(cmd) => cmd,
        None => {
            print!("{}", usage);
            return;
        }
    };

    if cmd == "help" {
        print!("{}", usage);
        return;
    }

    // handle the user's request and receive the response!
    let msg = Message::Command(match make_command(&cmd, args) {
        Ok(cmd) => cmd,
        Err(e) => {
            eprintln!("{}", e);
            return;
        }
    });

    let mut conn = match create_client_connection() {
        Ok(conn) => conn,
        Err(e) => {
            eprintln!("failed to connect to server. error: {}", e);
            return;
        }
    };

    if let Err(e) = conn.send(&msg) {
        eprintln!("failed to send message. error: {}", e);
        return;
    }

    let resp = match conn.recv_response() {
        Ok(resp) => resp,
        Err(e) => {
            eprintln!("failed to receive response. error: {}", e);
            return;
        }
    };

    if resp.success {
        println!("{}", resp.message);
        if let Some(status) = resp.status {
            println!(
                "{}",
                format_status(&status).expect("failed to format status")
            );
        }
    } else {
        println!("command failed. error: {}", resp.message)
    }
}

/// format a `Vec<ProcessStatus>` into a string for pretty printing
fn format_status(status: &Vec<ProcessStatus>) -> Result<String> {
    let mut res = String::new();

    writeln!(
        &mut res,
        "{:03} {:10} {:15} {:20} {:25}",
        "PID", "program", "status", "restart policy", "restarted?"
    )?;
    writeln!(
        &mut res,
        "---------------------------------------------------------------"
    )?;
    for proc in status {
        let alive = match proc.exit_status {
            ExitStatus::NotExited => "running",
            _ => {
                if proc.started {
                    "stopped"
                } else {
                    "never started"
                }
            }
        };
        let restarted = match proc.restarted {
            Some(pid) => format!("restarted. new PID: {:03}", pid),
            None => "not restarted".to_string(),
        };
        let restart_policy = format!("{}", proc.restart_policy);
        let prog = if proc.prog.len() >= 10 {
            let mut prog: String =
                proc.prog.chars().skip(proc.prog.len() - 8).collect();
            prog.insert_str(0, "..");
            prog
        } else {
            proc.prog.clone()
        };
        writeln!(
            &mut res,
            "{:03} {:10} {:15} {:20} {:25}",
            proc.pid, prog, alive, restart_policy, restarted
        )?;
    }

    Ok(res)
}

/// generate a command given the subcommand passsed to sopht and the rest of
/// the arguments. this function consumes the args since it might need to call
/// [`Arguments::finish`] on them. this function will fail if argument parsing
/// fails
fn make_command(cmd: &String, mut args: Arguments) -> Result<Command> {
    match cmd.as_str() {
        "hello" => Ok(Command::Hello),
        "shutdown" => Ok(Command::Shutdown),
        "start" => {
            let restart_args: Option<String> =
                args.opt_value_from_str("--restart")?;
            let restart_policy = match restart_args {
                None => RestartPolicy::Never,
                Some(s) => match s.as_str() {
                    "never" => RestartPolicy::Never,
                    "always" => RestartPolicy::Always,
                    "on-failure" => {
                        let retries: u32 = args.value_from_str("--retries")?;
                        RestartPolicy::OnFailure(retries)
                    }
                    _ => bail!("unknown restart policy \"{}\"", s),
                },
            };

            // extract the prog string and attempt to canonicalize it in case
            // it's a local path. if it isn't a local path, then just return the
            // original program string.
            let prog_string = args.free_from_str::<PathBuf>()?;
            let prog = if let Ok(c) = fs::canonicalize(&prog_string) {
                c
            } else {
                prog_string
            }
            .into_os_string()
            .into_string()
            .expect("could not create string from given program name");

            // call `finish` on the `Arguments` struct and create a big string
            // of all the program's arguments
            let cli_args: Vec<String> = args
                .finish()
                .iter()
                .map(|s| s.clone().into_string().expect("invalid UTF-8"))
                .collect::<Vec<String>>();

            let working_dir = std::env::current_dir()?;
            Ok(Command::Start(StartArgs {
                prog,
                args: cli_args,
                restart_policy,
                working_dir,
            }))
        }
        "output" => {
            let pid = args.free_from_str::<PID>()?;
            Ok(Command::Output(pid))
        }
        "status" => {
            let count: Option<usize> = args.opt_value_from_str("--tail")?;
            Ok(Command::Status(count))
        }
        "stop" => {
            let pid = args.free_from_str::<PID>()?;
            Ok(Command::Stop(pid))
        }
        "restart" => {
            let pid = args.free_from_str::<PID>()?;
            Ok(Command::Restart(pid))
        }
        "send" => {
            let pid = args.free_from_str::<PID>()?;
            let input: String = args
                .finish()
                .iter()
                .map(|s| s.clone().into_string().expect("invalid UTF-8"))
                .collect::<Vec<String>>()
                .join(" ");
            Ok(Command::Send(SendArgs { pid, input }))
        }
        "change-policy" => {
            let pid = args.free_from_str::<PID>()?;
            let restart_policy = match args.free_from_str::<String>()?.as_str()
            {
                "never" => RestartPolicy::Never,
                "always" => RestartPolicy::Always,
                "on-failure" => {
                    let retries = args.free_from_str::<u32>()?;
                    RestartPolicy::OnFailure(retries)
                }
                _x => bail!("invalid restart policy \"{}\"", _x),
            };

            Ok(Command::ChangeRestartPolicy(ChangeRestartPolicyArgs {
                pid,
                restart_policy,
            }))
        }
        _ => bail!("unknown command \"{}\"", cmd),
    }
}

/// format the usage string
fn usage() -> String {
    format!(
        r#"sopht v{}
cute process manager
(slightly) more sophisticated than tmux

commands:
  help
    prints this message

  hello
    requests the server greet you. kinda like a ping command, but friendlier

  shutdown
    shuts the server down gracefully

  start <...>
    attempts to run the rest of the arguments <..> as a command and begins
    managing the resulting process. pass "--restart <policy>" to set the restart
    policy of the process. valid restart policies are "never", "always", and
    "on-failure". if setting the restart policy to "on-failure", the
    "--retries <retry-count>" option must also be specified.

  output <pid>
    completely retrives the output of the process with the specified PID

  status
    retrieves the status of all managed processes

  stop <pid>
    stops the process with the specified PID and sets its restart policy to
    "never"

  restart <pid>
    stops the process with the specified PID and runs the same command again,
    creating a new process with a different PID.

  send <pid> <...>
    sends the rest of the arguments <...> as input to the process with PID
    <pid> a newline is automatically appended onto the output given to the
    process.

  change-policy <pid> <policy>  
    changes the policy of process with PID <pid> to the policy given by
    <policy>. unlike the "start" command, the policy must be written as one
    string like "on-failure 3" or "always".

"#,
        env!("CARGO_PKG_VERSION")
    )
}