use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
#[cfg(target_os = "linux")]
use std::thread;
#[cfg(target_os = "linux")]
use std::time::Duration;
use anyhow::{bail, Result};
const SAMPLE_MS: u64 = 200;
#[derive(Debug, Clone)]
struct ProcLite {
pid: u32,
ppid: u32,
utime: u64,
stime: u64,
is_jan: bool,
}
#[derive(Debug, Clone)]
struct ProcRow {
pid: u32,
cpu_pct: f64,
mem_pct: f64,
rss_kb: u64,
command: String,
is_jan: bool,
}
pub fn run_ps(args: &[std::ffi::OsString]) -> Result<i32> {
let mut jan_only = false;
let mut avg = false;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_help();
return Ok(0);
}
"--jan-only" => jan_only = true,
"--avg" => avg = true,
other if other.starts_with('-') => {
bail!("unknown ps flag `{other}` (try `jan ps --help`)");
}
other => bail!("unexpected ps argument `{other}` (try `jan ps --help`)"),
}
i += 1;
}
#[cfg(target_os = "linux")]
{
let rows = if avg {
collect_linux_avg(jan_only)?
} else {
collect_linux_instant(jan_only)?
};
print_report(&rows);
Ok(0)
}
#[cfg(not(target_os = "linux"))]
{
let _ = avg;
let rows = collect_via_ps(jan_only)?;
print_report(&rows);
Ok(0)
}
}
fn print_help() {
print!(
"\
jan ps — show processes belonging to jan and their collective CPU usage
USAGE:
jan ps [OPTIONS]
By default this samples CPU over {SAMPLE_MS} ms, includes every `jan` binary
plus descendants (cron jobs, shells, tools), and prints a process table with
a collective %CPU total (may exceed 100% on multi-core hosts).
OPTIONS:
--jan-only Only `jan` binaries (exclude child processes)
--avg Lifetime average CPU instead of a short sample
-h, --help Show this help
EXAMPLES:
jan ps
jan ps --jan-only
jan ps --avg
"
);
}
fn print_report(rows: &[ProcRow]) {
if rows.is_empty() {
println!("No jan processes found.");
println!("collective CPU: 0.0%");
return;
}
println!(
"{:>7} {:>6} {:>6} {:>8} COMMAND",
"PID", "%CPU", "%MEM", "RSS"
);
let mut total_cpu = 0.0f64;
for row in rows {
total_cpu += row.cpu_pct;
let marker = if row.is_jan { "*" } else { " " };
println!(
"{:>7} {:>6.1} {:>6.1} {:>8} {}{}",
row.pid,
row.cpu_pct,
row.mem_pct,
format_rss(row.rss_kb),
marker,
truncate_cmd(&row.command, 72)
);
}
println!();
println!("processes: {} (*=jan binary)", rows.len());
println!("collective CPU: {total_cpu:.1}%");
}
fn format_rss(kb: u64) -> String {
if kb >= 1024 * 1024 {
format!("{:.1}G", kb as f64 / (1024.0 * 1024.0))
} else if kb >= 1024 {
format!("{:.1}M", kb as f64 / 1024.0)
} else {
format!("{kb}K")
}
}
fn truncate_cmd(cmd: &str, max: usize) -> String {
let flat: String = cmd.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= max {
flat
} else {
let mut out: String = flat.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
#[cfg_attr(target_os = "linux", allow(dead_code))]
fn is_jan_executable(path: &str) -> bool {
Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|name| name == "jan")
}
fn is_jan_comm(comm: &str) -> bool {
comm == "jan"
}
fn sort_rows(rows: &mut [ProcRow]) {
rows.sort_by(|a, b| {
b.cpu_pct
.partial_cmp(&a.cpu_pct)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.pid.cmp(&b.pid))
});
}
fn select_pids(procs: &[ProcLite], jan_only: bool) -> HashSet<u32> {
let jan_roots: HashSet<u32> = procs.iter().filter(|p| p.is_jan).map(|p| p.pid).collect();
if jan_only {
return jan_roots;
}
let by_pid: HashMap<u32, u32> = procs.iter().map(|p| (p.pid, p.ppid)).collect();
let mut selected = HashSet::new();
for p in procs {
if jan_roots.contains(&p.pid) || is_descendant(p.pid, &jan_roots, &by_pid) {
selected.insert(p.pid);
}
}
selected
}
fn is_descendant(pid: u32, roots: &HashSet<u32>, by_pid: &HashMap<u32, u32>) -> bool {
let mut seen = HashSet::new();
let mut cur = pid;
for _ in 0..64 {
if !seen.insert(cur) {
break;
}
let Some(&ppid) = by_pid.get(&cur) else {
break;
};
if ppid == 0 || ppid == cur {
break;
}
if roots.contains(&ppid) {
return true;
}
cur = ppid;
}
false
}
pub fn parse_stat_comm(stat: &str) -> Option<(String, &str)> {
let start = stat.find('(')?;
let end = stat.rfind(')')?;
if end <= start {
return None;
}
let comm = stat[start + 1..end].to_string();
let rest = stat.get(end + 1..)?.trim_start();
Some((comm, rest))
}
#[cfg(target_os = "linux")]
fn collect_linux_instant(jan_only: bool) -> Result<Vec<ProcRow>> {
let first = scan_linux_lite()?;
let selected = select_pids(&first, jan_only);
if selected.is_empty() {
return Ok(Vec::new());
}
thread::sleep(Duration::from_millis(SAMPLE_MS));
let second = scan_linux_lite()?;
let mem_total_kb = mem_total_kb().unwrap_or(1);
let sample_secs = SAMPLE_MS as f64 / 1000.0;
let hz = clock_ticks() as f64;
let first_map: HashMap<u32, ProcLite> = first.into_iter().map(|p| (p.pid, p)).collect();
let mut rows = Vec::new();
for p in second {
if !selected.contains(&p.pid) {
continue;
}
let cpu_pct = if let Some(prev) = first_map.get(&p.pid) {
let delta = (p.utime + p.stime).saturating_sub(prev.utime + prev.stime) as f64;
100.0 * (delta / hz) / sample_secs
} else {
0.0
};
let (rss_kb, command) = enrich_linux(p.pid);
let mem_pct = 100.0 * (rss_kb as f64) / (mem_total_kb as f64);
rows.push(ProcRow {
pid: p.pid,
cpu_pct,
mem_pct,
rss_kb,
command,
is_jan: p.is_jan,
});
}
sort_rows(&mut rows);
Ok(rows)
}
#[cfg(target_os = "linux")]
fn collect_linux_avg(jan_only: bool) -> Result<Vec<ProcRow>> {
let procs = scan_linux_lite()?;
let selected = select_pids(&procs, jan_only);
let mem_total_kb = mem_total_kb().unwrap_or(1);
let hz = clock_ticks() as f64;
let uptime = uptime_secs().unwrap_or(1.0);
let mut rows = Vec::new();
for p in procs {
if !selected.contains(&p.pid) {
continue;
}
let cpu_pct = lifetime_cpu_pct(p.pid, p.utime + p.stime, hz, uptime).unwrap_or(0.0);
let (rss_kb, command) = enrich_linux(p.pid);
let mem_pct = 100.0 * (rss_kb as f64) / (mem_total_kb as f64);
rows.push(ProcRow {
pid: p.pid,
cpu_pct,
mem_pct,
rss_kb,
command,
is_jan: p.is_jan,
});
}
sort_rows(&mut rows);
Ok(rows)
}
#[cfg(target_os = "linux")]
fn scan_linux_lite() -> Result<Vec<ProcLite>> {
let mut out = Vec::new();
let Ok(entries) = fs::read_dir("/proc") else {
return Ok(out);
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(pid_str) = name.to_str() else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
if let Some(sample) = read_linux_lite(pid) {
out.push(sample);
}
}
Ok(out)
}
#[cfg(target_os = "linux")]
fn read_linux_lite(pid: u32) -> Option<ProcLite> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let (comm, after) = parse_stat_comm(&stat)?;
let fields: Vec<&str> = after.split_whitespace().collect();
let ppid = fields.get(1)?.parse().ok()?;
let utime = fields.get(11)?.parse().ok()?;
let stime = fields.get(12)?.parse().ok()?;
let is_jan = is_jan_comm(&comm);
Some(ProcLite {
pid,
ppid,
utime,
stime,
is_jan,
})
}
#[cfg(target_os = "linux")]
fn enrich_linux(pid: u32) -> (u64, String) {
let rss_kb = fs::read_to_string(format!("/proc/{pid}/statm"))
.ok()
.and_then(|s| {
let pages: u64 = s.split_whitespace().nth(1)?.parse().ok()?;
Some(pages.saturating_mul(page_size_kb()))
})
.unwrap_or(0);
let cmdline = fs::read(format!("/proc/{pid}/cmdline"))
.ok()
.map(|b| {
String::from_utf8_lossy(&b)
.split('\0')
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
if !cmdline.is_empty() {
return (rss_kb, cmdline);
}
let exe = fs::read_link(format!("/proc/{pid}/exe"))
.ok()
.map(|p| p.display().to_string())
.unwrap_or_else(|| format!("[{pid}]"));
(rss_kb, exe)
}
#[cfg(target_os = "linux")]
fn lifetime_cpu_pct(pid: u32, ticks: u64, hz: f64, uptime: f64) -> Option<f64> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let (_, after) = parse_stat_comm(&stat)?;
let starttime: u64 = after.split_whitespace().nth(19)?.parse().ok()?;
let start_secs = starttime as f64 / hz;
let elapsed = (uptime - start_secs).max(0.01);
let cpu_secs = ticks as f64 / hz;
Some(100.0 * cpu_secs / elapsed)
}
#[cfg(target_os = "linux")]
fn uptime_secs() -> Option<f64> {
let text = fs::read_to_string("/proc/uptime").ok()?;
text.split_whitespace().next()?.parse().ok()
}
#[cfg(target_os = "linux")]
fn mem_total_kb() -> Option<u64> {
let text = fs::read_to_string("/proc/meminfo").ok()?;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("MemTotal:") {
return rest.split_whitespace().next()?.parse().ok();
}
}
None
}
fn clock_ticks() -> u64 {
std::process::Command::new("getconf")
.arg("CLK_TCK")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(100)
}
fn page_size_kb() -> u64 {
std::process::Command::new("getconf")
.arg("PAGE_SIZE")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse::<u64>().ok())
.map(|b| b / 1024)
.unwrap_or(4)
}
#[cfg(not(target_os = "linux"))]
fn collect_via_ps(jan_only: bool) -> Result<Vec<ProcRow>> {
use std::process::Command;
let output = Command::new("ps")
.args(["-axo", "pid=,ppid=,pcpu=,pmem=,rss=,command="])
.output()?;
if !output.status.success() {
bail!("ps failed");
}
let text = String::from_utf8_lossy(&output.stdout);
let mut lites = Vec::new();
let mut extras: HashMap<u32, (f64, f64, u64, String)> = HashMap::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let Some(pid) = parts.next().and_then(|s| s.parse().ok()) else {
continue;
};
let Some(ppid) = parts.next().and_then(|s| s.parse().ok()) else {
continue;
};
let Some(cpu) = parts.next().and_then(|s| s.parse::<f64>().ok()) else {
continue;
};
let Some(mem) = parts.next().and_then(|s| s.parse::<f64>().ok()) else {
continue;
};
let Some(rss) = parts.next().and_then(|s| s.parse::<u64>().ok()) else {
continue;
};
let command = parts.collect::<Vec<_>>().join(" ");
let first = command.split_whitespace().next().unwrap_or("");
let is_jan = is_jan_executable(first)
|| Path::new(first)
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n == "jan");
lites.push(ProcLite {
pid,
ppid,
utime: 0,
stime: 0,
is_jan,
});
extras.insert(pid, (cpu, mem, rss, command));
}
let selected = select_pids(&lites, jan_only);
let mut rows = Vec::new();
for p in lites {
if !selected.contains(&p.pid) {
continue;
}
let Some((cpu, mem, rss, command)) = extras.remove(&p.pid) else {
continue;
};
rows.push(ProcRow {
pid: p.pid,
cpu_pct: cpu,
mem_pct: mem,
rss_kb: rss,
command,
is_jan: p.is_jan,
});
}
sort_rows(&mut rows);
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_stat_comm_with_spaces() {
let stat = "1234 (jan helper) S 1 1234 1234 0 -1 4194304 0 0 0 0 10 5 0 0 20 0 1 0 999 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0";
let (comm, rest) = parse_stat_comm(stat).unwrap();
assert_eq!(comm, "jan helper");
assert!(rest.starts_with('S'));
let ppid: u32 = rest.split_whitespace().nth(1).unwrap().parse().unwrap();
assert_eq!(ppid, 1);
}
#[test]
fn detects_jan_executable_basename() {
assert!(is_jan_executable("/usr/local/bin/jan"));
assert!(is_jan_executable("jan"));
assert!(!is_jan_executable("/usr/bin/python3"));
assert!(is_jan_comm("jan"));
assert!(!is_jan_comm("java"));
}
#[test]
fn selects_descendants_of_jan_roots() {
let procs = vec![
ProcLite {
pid: 10,
ppid: 1,
utime: 0,
stime: 0,
is_jan: true,
},
ProcLite {
pid: 20,
ppid: 10,
utime: 0,
stime: 0,
is_jan: false,
},
ProcLite {
pid: 30,
ppid: 1,
utime: 0,
stime: 0,
is_jan: false,
},
];
let all = select_pids(&procs, false);
assert!(all.contains(&10));
assert!(all.contains(&20));
assert!(!all.contains(&30));
let only = select_pids(&procs, true);
assert_eq!(only, HashSet::from([10]));
}
}