use clap::{Parser, Subcommand};
use std::io;
mod color;
mod job;
mod paths;
mod wait;
mod worker;
mod tui;
mod process;
use job::do_job;
use wait::wait_jobs;
use worker::run_worker;
fn parse_size(s: &str) -> Result<u64, String> {
let s = s.trim();
if s.is_empty() {
return Err("size string is empty".into());
}
let mut num_part = String::new();
let mut unit_part = String::new();
for c in s.chars() {
if c.is_ascii_digit() {
if !unit_part.is_empty() {
return Err("invalid size string".into());
}
num_part.push(c);
} else {
unit_part.push(c);
}
}
let base: u64 = num_part
.parse()
.map_err(|_| "invalid numeric component in size string")?;
let multiplier = match unit_part.to_ascii_uppercase().as_str() {
"" => 1,
"K" | "KB" => 1 << 10,
"M" | "MB" => 1 << 20,
"G" | "GB" => 1 << 30,
_ => return Err("unknown size unit".into()),
};
Ok(base * multiplier)
}
#[derive(Parser)]
#[command(author, version, about)]
struct Cli {
#[arg(long, global = true, value_name = "DIR")]
dir: Option<std::path::PathBuf>,
#[arg(long, global = true)]
no_color: bool,
#[arg(long, value_name = "SIZE", global = true)]
max_log_size: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Do {
job_name: String,
#[arg(required = true, trailing_var_arg = true)]
cmd: Vec<String>,
#[arg(long, value_name = "SECS")]
timeout: Option<u64>,
#[arg(long, value_name = "N")]
retries: Option<u32>,
},
Wait {
#[arg(required = true)]
job_names: Vec<String>,
},
#[command(hide = true)]
Worker {
job_name: String,
#[arg(trailing_var_arg = true)]
cmd: Vec<String>,
},
Clean {
#[arg(long)]
all: bool,
#[arg(value_name = "JOB", required_unless_present = "all")]
jobs: Vec<String>,
},
Tui,
}
fn main() {
if let Err(err) = try_main() {
eprintln!("Error: {}", err);
std::process::exit(1);
}
}
fn try_main() -> io::Result<()> {
let cli = Cli::parse();
if let Some(dir) = &cli.dir {
std::env::set_var("PEND_DIR", dir);
}
if cli.no_color {
std::env::set_var("NO_COLOR", "1");
}
if let Some(size_str) = &cli.max_log_size {
let bytes =
parse_size(size_str).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
std::env::set_var("PEND_MAX_LOG_SIZE", bytes.to_string());
}
match cli.command {
Commands::Do {
job_name,
cmd,
timeout,
retries,
} => do_job(&job_name, &cmd, timeout, retries),
Commands::Wait { job_names } => {
let code = wait_jobs(&job_names)?;
std::process::exit(code);
}
Commands::Worker { job_name, cmd } => run_worker(&job_name, &cmd),
Commands::Clean { all, jobs } => {
use crate::paths::jobs_root;
use std::fs;
let root = jobs_root()?;
let targets: Vec<String> = if all {
let mut set = std::collections::HashSet::new();
if let Ok(entries) = fs::read_dir(&root) {
const EXTENSIONS: [&str; 7] = [
"out", "err", "log", "exit", "json", "signal", "lock",
];
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
let mut base = name;
loop {
if let Some((stem, ext)) = base.rsplit_once('.') {
if ext.chars().all(|c| c.is_ascii_digit()) {
base = stem;
continue;
}
}
break;
}
if let Some((job, ext)) = base.rsplit_once('.') {
if EXTENSIONS.contains(&ext) {
set.insert(job.to_string());
}
}
}
}
}
set.into_iter().collect()
} else {
jobs
};
if targets.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"no jobs to clean – use --all or supply at least one job name",
));
}
for job in &targets {
let paths = crate::paths::JobPaths::new(job)?;
if paths.lock.exists() {
use fs2::FileExt;
if let Ok(file) = fs::OpenOptions::new().read(true).open(&paths.lock) {
if file.try_lock_exclusive().is_err() {
let mut skip = true;
if let Ok(meta_bytes) = fs::read(&paths.meta) {
if let Ok(meta_json) = serde_json::from_slice::<serde_json::Value>(&meta_bytes) {
if let Some(pid) = meta_json.get("pid").and_then(|v| v.as_u64()) {
if !crate::process::process_is_alive(pid as u32) {
skip = false;
}
}
}
}
if skip {
eprintln!("warning: job '{job}' appears to be running – skipping");
continue;
}
}
}
}
const EXTENSIONS: [&str; 7] = [
"out", "err", "log", "exit", "json", "signal", "lock",
];
for p in [
&paths.out,
&paths.err,
&paths.log,
&paths.exit,
&paths.meta,
&paths.signal,
&paths.lock,
] {
let _ = fs::remove_file(p);
}
if let Ok(entries) = fs::read_dir(&root) {
for entry in entries.flatten() {
if let Some(fname) = entry.file_name().to_str() {
for ext in &EXTENSIONS {
let prefix = format!("{job}.{ext}.");
if fname.starts_with(&prefix) {
let _ = fs::remove_file(entry.path());
break;
}
}
}
}
}
}
Ok(())
}
Commands::Tui => {
crate::tui::run_tui()?;
Ok(())
}
}
}