use anyhow::Result;
use owo_colors::OwoColorize;
use regex::Regex;
use std::collections::{HashMap, HashSet};
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
use crate::config::Config;
use crate::session::SessionStore;
use crate::util::use_color;
#[derive(Debug)]
pub struct ProcessNode {
pub pid: u32,
pub ppid: u32,
pub name: String,
#[allow(dead_code)]
pub cmdline: String,
pub memory_mb: f64,
#[allow(dead_code)]
pub cpu_percent: f32,
pub is_target: bool, pub is_whitelisted: bool, pub is_orphan: bool, pub session_id: Option<String>,
}
pub fn build_process_tree(config: &Config) -> Result<HashMap<u32, ProcessNode>> {
let mut sys = System::new_with_specifics(
RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
);
sys.refresh_processes(sysinfo::ProcessesToUpdate::All);
let session_store = SessionStore::load().unwrap_or_default();
let mut pid_to_session: HashMap<u32, String> = HashMap::new();
for session in session_store.sessions.values() {
for tp in &session.pids {
pid_to_session.insert(tp.pid, session.id.clone());
}
}
let target_patterns: Vec<Regex> = config
.targets
.iter()
.filter_map(|p| match Regex::new(p) {
Ok(re) => Some(re),
Err(e) => {
eprintln!("Warning: Invalid target pattern '{p}': {e}");
None
}
})
.collect();
let whitelist_patterns: Vec<Regex> = config
.whitelist
.iter()
.filter_map(|p| match Regex::new(p) {
Ok(re) => Some(re),
Err(e) => {
eprintln!("Warning: Invalid whitelist pattern '{p}': {e}");
None
}
})
.collect();
let mut nodes = HashMap::new();
for (pid, process) in sys.processes() {
let pid_u32 = pid.as_u32();
let ppid = process.parent().map(|p| p.as_u32()).unwrap_or(0);
let name = process.name().to_string_lossy().to_string();
let cmdline = process
.cmd()
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(" ");
let is_target = matches_patterns(&cmdline, &target_patterns);
let is_whitelisted = matches_patterns(&cmdline, &whitelist_patterns);
let is_orphan = ppid == 1;
nodes.insert(
pid_u32,
ProcessNode {
pid: pid_u32,
ppid,
name,
cmdline: if cmdline.chars().count() > 80 {
format!("{}...", cmdline.chars().take(77).collect::<String>())
} else {
cmdline
},
memory_mb: process.memory() as f64 / 1024.0 / 1024.0,
cpu_percent: process.cpu_usage(),
is_target,
is_whitelisted,
is_orphan,
session_id: pid_to_session.get(&pid_u32).cloned(),
},
);
}
Ok(nodes)
}
fn matches_patterns(text: &str, patterns: &[Regex]) -> bool {
patterns.iter().any(|re| re.is_match(text))
}
fn collect_orphan_tree(pid: u32, children: &HashMap<u32, Vec<u32>>, result: &mut HashSet<u32>) {
if let Some(child_pids) = children.get(&pid) {
for &child in child_pids {
if result.insert(child) {
collect_orphan_tree(child, children, result);
}
}
}
}
pub fn print_tree(filter_targets: bool) -> Result<()> {
let config = Config::load()?;
let nodes = build_process_tree(&config)?;
let color = use_color();
let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
for node in nodes.values() {
children.entry(node.ppid).or_default().push(node.pid);
}
for pids in children.values_mut() {
pids.sort();
}
let total = nodes.len();
let targets: Vec<_> = nodes
.values()
.filter(|n| n.is_target && !n.is_whitelisted)
.collect();
let mut orphan_tree_pids = HashSet::new();
for node in targets.iter() {
if node.is_orphan {
orphan_tree_pids.insert(node.pid);
collect_orphan_tree(node.pid, &children, &mut orphan_tree_pids);
}
}
let orphan_targets: Vec<_> = targets
.iter()
.filter(|n| orphan_tree_pids.contains(&n.pid))
.collect();
let total_reclaimable: f64 = orphan_targets.iter().map(|n| n.memory_mb).sum();
println!();
if color {
println!(" {} {}", "proc-janitor".bold(), "Process Tree".dimmed());
} else {
println!(" proc-janitor Process Tree");
}
println!(" {}", "─".repeat(50));
if color {
print!(" {} {}", format!("{total}").bold(), "processes".dimmed());
if !targets.is_empty() {
print!(
" {} {} {}",
"│".dimmed(),
format!("{}", targets.len()).yellow().bold(),
"targets".dimmed()
);
}
if !orphan_targets.is_empty() {
print!(
" {} {} {}",
"│".dimmed(),
format!("{}", orphan_targets.len()).red().bold(),
"cleanable".dimmed()
);
print!(
" {} {} {}",
"│".dimmed(),
format!("{total_reclaimable:.0}MB").red(),
"reclaimable".dimmed()
);
}
println!();
} else {
print!(" {total} processes");
if !targets.is_empty() {
print!(" | {} targets", targets.len());
}
if !orphan_targets.is_empty() {
print!(
" | {} cleanable | {:.0}MB reclaimable",
orphan_targets.len(),
total_reclaimable
);
}
println!();
}
println!();
if color {
println!(
" {} {} {} {}",
"🎯 target".dimmed(),
"â›” whitelisted".dimmed(),
"👻 orphan".dimmed(),
"📎 session".dimmed()
);
} else {
println!(" 🎯 target ⛔ whitelisted 👻 orphan 📎 session");
}
println!();
if filter_targets {
if targets.is_empty() {
if color {
println!(" {}", "No target processes found.".dimmed());
println!(
" {}",
"Configure targets: proc-janitor config init".dimmed()
);
} else {
println!(" No target processes found.");
println!(" Configure targets: proc-janitor config init");
}
} else {
if color {
println!(" {}", "Showing target processes only".dimmed());
} else {
println!(" Showing target processes only");
}
println!();
for node in &targets {
print_node(node, " ", color);
}
}
} else {
if let Some(init_children) = children.get(&1) {
if color {
println!(" {}", "init (PID 1)".dimmed());
} else {
println!(" init (PID 1)");
}
let interesting: Vec<_> = init_children
.iter()
.filter(|&&pid| {
nodes
.get(&pid)
.map(|n| n.is_target && !n.is_whitelisted)
.unwrap_or(false)
|| has_target_descendant(pid, &children, &nodes, &mut HashSet::new())
})
.collect();
if interesting.is_empty() {
if color {
println!(" {}", " No target processes in tree.".dimmed());
println!(
" {}",
" Configure targets: proc-janitor config init".dimmed()
);
} else {
println!(" No target processes in tree.");
println!(" Configure targets: proc-janitor config init");
}
} else {
let len = interesting.len();
for (i, &&pid) in interesting.iter().enumerate() {
if let Some(node) = nodes.get(&pid) {
let is_last = i == len - 1;
print_subtree(
node,
" ",
is_last,
&children,
&nodes,
&mut HashSet::new(),
color,
);
}
}
}
}
}
if !orphan_targets.is_empty() {
println!();
if color {
println!(" {} {}", "Cleanable".red().bold(), "─".repeat(41).dimmed());
} else {
println!(" Cleanable ─────────────────────────────────");
}
for node in &orphan_targets {
if color {
println!(
" {} {:>6.1} MB {}",
format!("PID {:>6}", node.pid).dimmed(),
node.memory_mb,
node.name.red()
);
} else {
println!(
" PID {:>6} {:>6.1} MB {}",
node.pid, node.memory_mb, node.name
);
}
}
println!();
if color {
println!(
" {} {}",
"→".green(),
"Run `proc-janitor clean` to terminate".dimmed()
);
} else {
println!(" → Run `proc-janitor clean` to terminate");
}
}
println!();
Ok(())
}
fn has_target_descendant(
pid: u32,
children: &HashMap<u32, Vec<u32>>,
nodes: &HashMap<u32, ProcessNode>,
visited: &mut HashSet<u32>,
) -> bool {
if !visited.insert(pid) {
return false; }
if let Some(node) = nodes.get(&pid) {
if node.is_target && !node.is_whitelisted {
return true;
}
}
if let Some(child_pids) = children.get(&pid) {
for &child_pid in child_pids {
if has_target_descendant(child_pid, children, nodes, visited) {
return true;
}
}
}
false
}
fn print_subtree(
node: &ProcessNode,
prefix: &str,
is_last: bool,
children: &HashMap<u32, Vec<u32>>,
nodes: &HashMap<u32, ProcessNode>,
visited: &mut HashSet<u32>,
color: bool,
) {
if !visited.insert(node.pid) {
return; }
let (connector, ext) = if is_last {
("└─ ", " ")
} else {
("├─ ", "│ ")
};
if color {
print!("{}", format!("{prefix}{connector}").dimmed());
} else {
print!("{prefix}{connector}");
}
print_node(node, "", color);
let new_prefix = format!("{prefix}{ext}");
if let Some(child_pids) = children.get(&node.pid) {
let interesting_children: Vec<_> = child_pids
.iter()
.filter(|&&pid| {
nodes
.get(&pid)
.map(|n| n.is_target && !n.is_whitelisted)
.unwrap_or(false)
|| has_target_descendant(pid, children, nodes, &mut HashSet::new())
})
.collect();
let len = interesting_children.len();
for (i, &&pid) in interesting_children.iter().enumerate() {
if let Some(child_node) = nodes.get(&pid) {
let is_last = i == len - 1;
print_subtree(
child_node,
&new_prefix,
is_last,
children,
nodes,
visited,
color,
);
}
}
}
}
fn print_node(node: &ProcessNode, prefix: &str, color: bool) {
let mut markers = String::new();
if node.is_target && !node.is_whitelisted {
markers.push_str(" 🎯");
}
if node.is_whitelisted {
markers.push_str(" â›”");
}
if node.is_orphan {
markers.push_str(" 👻");
}
if node.session_id.is_some() {
markers.push_str(" 📎");
}
let mem_str = if color {
if node.memory_mb > 100.0 {
format!("{:>6.1}MB", node.memory_mb).red().to_string()
} else if node.memory_mb > 50.0 {
format!("{:>6.1}MB", node.memory_mb).yellow().to_string()
} else {
format!("{:>6.1}MB", node.memory_mb).dimmed().to_string()
}
} else {
format!("{:>6.1}MB", node.memory_mb)
};
let name_str = if color {
if node.is_target && !node.is_whitelisted && node.is_orphan {
node.name.red().bold().to_string()
} else if node.is_target && !node.is_whitelisted {
node.name.yellow().to_string()
} else {
node.name.dimmed().to_string()
}
} else {
node.name.clone()
};
let pid_str = if color {
format!("{}", node.pid).dimmed().to_string()
} else {
format!("{}", node.pid)
};
println!("{prefix}{name_str} {pid_str} {mem_str}{markers}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matches_patterns_basic() {
let patterns = vec![
Regex::new("node.*claude").unwrap(),
Regex::new("python").unwrap(),
];
assert!(matches_patterns("node --experimental claude", &patterns));
assert!(matches_patterns("python script.py", &patterns));
assert!(!matches_patterns("cargo build", &patterns));
}
#[test]
fn test_matches_patterns_empty() {
let patterns: Vec<Regex> = vec![];
assert!(!matches_patterns("anything", &patterns));
}
}