use std::io::{Read as _, Write as _};
use std::net::SocketAddr;
use std::time::Duration;
pub const BIND_ADDR: &str = "127.0.0.1:5784";
const PROBE_TIMEOUT: Duration = Duration::from_millis(750);
const DAEMONIZED_ENV: &str = "MOADIM_DAEMONIZED";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Foreground,
Background,
Stop,
Status,
Help,
Version,
}
pub fn parse(args: impl IntoIterator<Item = String>) -> Command {
let args: Vec<String> = args.into_iter().collect();
match args.first().map(String::as_str) {
None => Command::Background,
Some("stop") => Command::Stop,
Some("status") => Command::Status,
Some("-h" | "--help" | "help") => Command::Help,
Some("-V" | "--version" | "version") => Command::Version,
Some("-i" | "--interactive" | "-f" | "--foreground") => Command::Foreground,
Some("-b" | "--background" | "-d" | "--detach" | "--daemon") => Command::Background,
Some(_) => Command::Help,
}
}
pub fn print_help() {
println!(
"moadim — cron/MCP/REST server with a web control panel\n\
\n\
USAGE:\n\
\x20 moadim [MODE]\n\
\x20 moadim <COMMAND>\n\
\n\
MODES:\n\
\x20 (default) start the server in the background and exit\n\
\x20 -i, --interactive run in the foreground, attached to the terminal (Ctrl-C to stop)\n\
\x20 -b, --background start the server detached in the background (explicit default)\n\
\n\
COMMANDS:\n\
\x20 stop stop a running background server\n\
\x20 status show whether a server is running\n\
\x20 help, -h, --help show this help\n\
\x20 version, -V show the version\n\
\n\
Once running, manage the server from the web client at http://{BIND_ADDR}/ui\n\
(the STOP button) or with `moadim stop`."
);
}
pub fn print_version() {
println!("moadim {}", env!("CARGO_PKG_VERSION"));
}
pub fn run_background() -> anyhow::Result<()> {
if is_running() {
let pid = read_pid_file()
.map(|p| format!(" (pid {p})"))
.unwrap_or_default();
println!("moadim is already running{pid} at http://{BIND_ADDR}");
return Ok(());
}
let pid = spawn_detached()?;
println!("moadim started in the background (pid {pid}) at http://{BIND_ADDR}");
println!(" UI http://{BIND_ADDR}/ui");
println!(" stop moadim stop (or use the STOP button in the UI)");
println!(" logs {}", paths_daemon_log());
Ok(())
}
pub fn stop() -> anyhow::Result<()> {
match http_request("POST", "/shutdown") {
Ok(200) => {
println!("moadim is shutting down");
Ok(())
}
Ok(status) => {
anyhow::bail!("unexpected response from server: HTTP {status}");
}
Err(_) => {
println!("moadim is not running");
Ok(())
}
}
}
pub fn status() -> anyhow::Result<()> {
if is_running() {
let pid = read_pid_file()
.map(|p| format!(" (pid {p})"))
.unwrap_or_default();
println!("moadim is running{pid} at http://{BIND_ADDR}");
} else {
println!("moadim is not running");
}
Ok(())
}
pub fn write_pid_file() -> anyhow::Result<()> {
let path = crate::paths::pid_file();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, std::process::id().to_string())?;
Ok(())
}
pub fn clear_pid_file() {
let _ = std::fs::remove_file(crate::paths::pid_file());
}
fn read_pid_file() -> Option<u32> {
std::fs::read_to_string(crate::paths::pid_file())
.ok()?
.trim()
.parse()
.ok()
}
fn paths_daemon_log() -> String {
crate::paths::daemon_log_file().display().to_string()
}
fn is_running() -> bool {
matches!(http_request("GET", "/health"), Ok(200))
}
fn http_request(method: &str, path: &str) -> std::io::Result<u16> {
let addr: SocketAddr = BIND_ADDR
.parse()
.expect("BIND_ADDR is a valid socket address");
let mut stream = std::net::TcpStream::connect_timeout(&addr, PROBE_TIMEOUT)?;
stream.set_read_timeout(Some(PROBE_TIMEOUT))?;
stream.set_write_timeout(Some(PROBE_TIMEOUT))?;
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: {BIND_ADDR}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream.write_all(req.as_bytes())?;
let mut resp = String::new();
let _ = stream.read_to_string(&mut resp);
parse_status_code(&resp).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "no HTTP status line in response")
})
}
fn parse_status_code(resp: &str) -> Option<u16> {
resp.lines()
.next()?
.split_whitespace()
.nth(1)?
.parse()
.ok()
}
fn spawn_detached() -> anyhow::Result<u32> {
use std::process::{Command as Proc, Stdio};
let exe = std::env::current_exe()?;
let log_path = crate::paths::daemon_log_file();
if let Some(dir) = log_path.parent() {
std::fs::create_dir_all(dir)?;
}
let out = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
let err = out.try_clone()?;
let mut cmd = Proc::new(exe);
cmd.arg("--interactive")
.env(DAEMONIZED_ENV, "1")
.stdin(Stdio::null())
.stdout(Stdio::from(out))
.stderr(Stdio::from(err));
detach(&mut cmd);
let child = cmd.spawn()?;
Ok(child.id())
}
#[cfg(unix)]
fn detach(cmd: &mut std::process::Command) {
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
#[cfg(not(unix))]
fn detach(_cmd: &mut std::process::Command) {}
#[cfg(test)]
#[path = "cli_tests.rs"]
mod cli_tests;