forked from Crockan/MercuryToolbox
chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "proctree"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Inspect Windows process trees with compact AI-friendly output."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", default-features = false }
|
||||
lexopt.workspace = true
|
||||
regex-lite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
windowsupport = { path = "../windowsupport" }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
predicates.workspace = true
|
||||
@@ -0,0 +1,900 @@
|
||||
//! The `proctree` command inspects process trees.
|
||||
#![allow(clippy::multiple_crate_versions)]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::Write as _;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{
|
||||
CliError, CommonArgs, ExitCode, RenderMode, map_result_count, parse_color_choice,
|
||||
parse_format_choice, print_json, print_quick_help_error, print_structured,
|
||||
};
|
||||
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
||||
use regex_lite::{Regex, RegexBuilder};
|
||||
use serde::Serialize;
|
||||
use windowsupport::{ProcessDescriptor, sleep_for, snapshot_processes};
|
||||
|
||||
const HELP: &str = "\
|
||||
Inspect Windows process trees with compact AI-friendly output.
|
||||
|
||||
Windows only.
|
||||
|
||||
Usage:
|
||||
proctree [OPTIONS] system
|
||||
proctree [OPTIONS] root <PID>
|
||||
proctree [OPTIONS] run -- <COMMAND...>
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
--match <REGEX> Filter by image name or command line
|
||||
--max-depth <COUNT> Optional maximum depth to emit
|
||||
--include-cmdline Include command-line text in compact output
|
||||
--orphans Highlight descendants still alive after the root exits
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
proctree system
|
||||
proctree root 1234 --json | ConvertFrom-Json
|
||||
proctree run -- pwsh -NoProfile -Command \"npm test\"
|
||||
proctree system --match 'pwsh|Mercury' --include-cmdline
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cli {
|
||||
common: CommonArgs,
|
||||
mode: Mode,
|
||||
match_pattern: Option<Regex>,
|
||||
max_depth: Option<usize>,
|
||||
include_cmdline: bool,
|
||||
orphans: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Mode {
|
||||
System,
|
||||
Root(u32),
|
||||
Run(Vec<OsString>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct ProcessTreeReport {
|
||||
root_pid: Option<u32>,
|
||||
message: Option<String>,
|
||||
nodes: Vec<ProcessNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct ProcessNode {
|
||||
pid: u32,
|
||||
parent_pid: Option<u32>,
|
||||
depth: usize,
|
||||
image_name: String,
|
||||
exe: Option<String>,
|
||||
command_line: Vec<String>,
|
||||
command_line_preview: String,
|
||||
start_time_unix: u64,
|
||||
run_time_seconds: u64,
|
||||
run_time_display: String,
|
||||
orphan: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RunObservation {
|
||||
root_pid: u32,
|
||||
observed_processes: HashMap<u32, ProcessDescriptor>,
|
||||
observed_descendants: HashSet<u32>,
|
||||
}
|
||||
|
||||
/// Parses CLI arguments and returns a process exit code.
|
||||
#[must_use]
|
||||
pub fn main_entry() -> i32 {
|
||||
match parse_cli_from(std::env::args_os()) {
|
||||
Ok((ParseOutcome::Help, _)) => {
|
||||
print!("{HELP}");
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Version, _)) => {
|
||||
println!("proctree {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
||||
Ok(code) => code.as_i32(),
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let mut parser = lexopt::Parser::from_iter(args);
|
||||
let mut common = CommonArgs::default();
|
||||
let mut mode = None::<Mode>;
|
||||
let mut match_pattern = None::<Regex>;
|
||||
let mut max_depth = None::<usize>;
|
||||
let mut include_cmdline = false;
|
||||
let mut orphans = false;
|
||||
let mut collecting_run = false;
|
||||
let mut run_args = Vec::<OsString>::new();
|
||||
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
if collecting_run {
|
||||
if let ArgValue(value) = argument {
|
||||
run_args.push(value);
|
||||
continue;
|
||||
}
|
||||
return Err(CliError::usage(
|
||||
"run only accepts command arguments after --",
|
||||
));
|
||||
}
|
||||
|
||||
match argument {
|
||||
Long("help") | Short('h') => return Ok((ParseOutcome::Help, empty_cli(common))),
|
||||
Long("version") | Short('V') => {
|
||||
return Ok((ParseOutcome::Version, empty_cli(common)));
|
||||
}
|
||||
Long("json") => common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(&mut parser, "--format")?;
|
||||
common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("color") => {
|
||||
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
||||
}
|
||||
Long("quiet") => common.quiet = true,
|
||||
Long("match") => {
|
||||
match_pattern = Some(parse_regex(&parser_value_string(&mut parser, "--match")?)?);
|
||||
}
|
||||
Long("max-depth") => {
|
||||
max_depth = Some(parse_usize_flag(
|
||||
"--max-depth",
|
||||
&parser_value_string(&mut parser, "--max-depth")?,
|
||||
)?);
|
||||
}
|
||||
Long("include-cmdline") => include_cmdline = true,
|
||||
Long("orphans") => orphans = true,
|
||||
ArgValue(value) => {
|
||||
if matches!(mode, Some(Mode::Run(_))) {
|
||||
collecting_run = true;
|
||||
run_args.push(value);
|
||||
continue;
|
||||
}
|
||||
let text = arg_to_string(value, "subcommand")?;
|
||||
if mode.is_none() {
|
||||
mode = Some(parse_mode_token(&text, &mut parser)?);
|
||||
} else {
|
||||
return Err(CliError::usage(
|
||||
"unexpected positional argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mode = match mode {
|
||||
Some(Mode::Run(_)) => {
|
||||
if run_args.is_empty() {
|
||||
return Err(CliError::usage("run requires a command after --"));
|
||||
}
|
||||
Mode::Run(run_args)
|
||||
}
|
||||
Some(mode) => mode,
|
||||
None => {
|
||||
return Err(CliError::usage(
|
||||
"provide a subcommand: system, root, or run",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok((
|
||||
ParseOutcome::Run,
|
||||
Cli {
|
||||
common,
|
||||
mode,
|
||||
match_pattern,
|
||||
max_depth,
|
||||
include_cmdline,
|
||||
orphans,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
const fn empty_cli(common: CommonArgs) -> Cli {
|
||||
Cli {
|
||||
common,
|
||||
mode: Mode::System,
|
||||
match_pattern: None,
|
||||
max_depth: None,
|
||||
include_cmdline: false,
|
||||
orphans: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn arg_to_string(value: OsString, context: &str) -> Result<String, CliError> {
|
||||
value.into_string().map_err(|invalid| {
|
||||
CliError::usage(format!(
|
||||
"{context} expects UTF-8 text, got '{}'",
|
||||
invalid.to_string_lossy()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_mode_token(text: &str, parser: &mut lexopt::Parser) -> Result<Mode, CliError> {
|
||||
match text {
|
||||
"system" => Ok(Mode::System),
|
||||
"root" => {
|
||||
let pid = parser_value_string(parser, "root pid")?
|
||||
.parse::<u32>()
|
||||
.map_err(|error| CliError::usage(format!("invalid root pid: {error}")))?;
|
||||
Ok(Mode::Root(pid))
|
||||
}
|
||||
"run" => Ok(Mode::Run(Vec::new())),
|
||||
_ => Err(CliError::usage(
|
||||
"unknown subcommand; expected system, root, or run",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
value.into_string().map_err(|invalid| {
|
||||
CliError::usage(format!(
|
||||
"{flag} expects UTF-8 text, got '{}'",
|
||||
invalid.to_string_lossy()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_regex(value: &str) -> Result<Regex, CliError> {
|
||||
RegexBuilder::new(value)
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
.map_err(|error| CliError::usage(format!("invalid --match regex '{value}': {error}")))
|
||||
}
|
||||
|
||||
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
||||
}
|
||||
|
||||
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
||||
let report = match &cli.mode {
|
||||
Mode::System => build_system_report(cli),
|
||||
Mode::Root(pid) => build_root_report(cli, *pid, false),
|
||||
Mode::Run(command) => build_run_report(cli, command)?,
|
||||
};
|
||||
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Json => print_json(&report)?,
|
||||
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
|
||||
RenderMode::Text => print!("{}", render_report(&report, cli.include_cmdline)),
|
||||
}
|
||||
|
||||
Ok(map_result_count(report.nodes.len()))
|
||||
}
|
||||
|
||||
fn build_system_report(cli: &Cli) -> ProcessTreeReport {
|
||||
let processes = filter_processes(snapshot_processes(), cli.match_pattern.as_ref());
|
||||
let nodes = flatten_system_nodes(&processes, cli.max_depth);
|
||||
ProcessTreeReport {
|
||||
root_pid: None,
|
||||
message: None,
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_root_report(cli: &Cli, root_pid: u32, orphan_mode: bool) -> ProcessTreeReport {
|
||||
let processes = filter_processes(snapshot_processes(), cli.match_pattern.as_ref());
|
||||
let nodes = flatten_subtree_nodes(&processes, root_pid, cli.max_depth, orphan_mode);
|
||||
ProcessTreeReport {
|
||||
root_pid: Some(root_pid),
|
||||
message: nodes
|
||||
.is_empty()
|
||||
.then(|| format!("root process {root_pid} not found at snapshot time")),
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_run_report(cli: &Cli, command: &[OsString]) -> Result<ProcessTreeReport, CliError> {
|
||||
let Some(program) = command.first() else {
|
||||
return Err(CliError::usage("run requires a command"));
|
||||
};
|
||||
let mut child = Command::new(program)
|
||||
.args(&command[1..])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|error| CliError::runtime(format!("failed to launch command: {error}")))?;
|
||||
let root_pid = child.id();
|
||||
|
||||
let mut observation = RunObservation::new(root_pid);
|
||||
loop {
|
||||
let snapshot = snapshot_processes();
|
||||
observation.record(&snapshot);
|
||||
if child
|
||||
.try_wait()
|
||||
.map_err(|error| CliError::runtime(format!("failed to poll command: {error}")))?
|
||||
.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
sleep_for(Duration::from_millis(75));
|
||||
}
|
||||
|
||||
let mut final_snapshot = snapshot_processes();
|
||||
observation.record(&final_snapshot);
|
||||
if cli.orphans {
|
||||
sleep_for(Duration::from_millis(150));
|
||||
final_snapshot = snapshot_processes();
|
||||
observation.record(&final_snapshot);
|
||||
}
|
||||
|
||||
Ok(observation.build_report(cli, &final_snapshot))
|
||||
}
|
||||
|
||||
impl RunObservation {
|
||||
fn new(root_pid: u32) -> Self {
|
||||
Self {
|
||||
root_pid,
|
||||
observed_processes: HashMap::new(),
|
||||
observed_descendants: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&mut self, processes: &[ProcessDescriptor]) {
|
||||
let process_map = processes
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|process| (process.pid, process))
|
||||
.collect::<HashMap<_, _>>();
|
||||
if let Some(root) = process_map.get(&self.root_pid) {
|
||||
self.observed_processes.insert(self.root_pid, root.clone());
|
||||
}
|
||||
let children = build_children_map(processes);
|
||||
let descendants = collect_descendant_ids(&children, self.root_pid);
|
||||
for pid in descendants.iter().copied() {
|
||||
if let Some(process) = process_map.get(&pid) {
|
||||
self.observed_processes.insert(pid, process.clone());
|
||||
}
|
||||
}
|
||||
self.observed_descendants.extend(descendants);
|
||||
}
|
||||
|
||||
fn build_report(&self, cli: &Cli, live_processes: &[ProcessDescriptor]) -> ProcessTreeReport {
|
||||
let historical = self
|
||||
.observed_processes
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut nodes = flatten_subtree_nodes(&historical, self.root_pid, cli.max_depth, false);
|
||||
if let Some(pattern) = cli.match_pattern.as_ref() {
|
||||
nodes.retain(|node| {
|
||||
pattern.is_match(&node.image_name)
|
||||
|| node.command_line.iter().any(|arg| pattern.is_match(arg))
|
||||
});
|
||||
}
|
||||
if cli.orphans {
|
||||
let live_pids = live_processes
|
||||
.iter()
|
||||
.map(|process| process.pid)
|
||||
.collect::<HashSet<_>>();
|
||||
for node in &mut nodes {
|
||||
node.orphan = node.pid != self.root_pid
|
||||
&& self.observed_descendants.contains(&node.pid)
|
||||
&& live_pids.contains(&node.pid);
|
||||
}
|
||||
}
|
||||
ProcessTreeReport {
|
||||
root_pid: Some(self.root_pid),
|
||||
message: nodes
|
||||
.is_empty()
|
||||
.then(|| format!("root process {} exited before snapshot", self.root_pid)),
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_processes(
|
||||
processes: Vec<ProcessDescriptor>,
|
||||
pattern: Option<&Regex>,
|
||||
) -> Vec<ProcessDescriptor> {
|
||||
if let Some(pattern) = pattern {
|
||||
processes
|
||||
.into_iter()
|
||||
.filter(|process| {
|
||||
pattern.is_match(&process.image_name)
|
||||
|| process.command_line.iter().any(|arg| pattern.is_match(arg))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
processes
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_system_nodes(
|
||||
processes: &[ProcessDescriptor],
|
||||
max_depth: Option<usize>,
|
||||
) -> Vec<ProcessNode> {
|
||||
let children = build_children_map(processes);
|
||||
let process_map = processes
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|process| (process.pid, process))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut roots = processes
|
||||
.iter()
|
||||
.filter(|process| {
|
||||
process
|
||||
.parent_pid
|
||||
.is_none_or(|parent| !process_map.contains_key(&parent))
|
||||
})
|
||||
.map(|process| process.pid)
|
||||
.collect::<Vec<_>>();
|
||||
roots.sort_unstable();
|
||||
|
||||
let mut nodes = Vec::new();
|
||||
for root in roots {
|
||||
collect_tree_nodes(
|
||||
root,
|
||||
0,
|
||||
&children,
|
||||
&process_map,
|
||||
max_depth,
|
||||
false,
|
||||
&mut nodes,
|
||||
);
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
fn flatten_subtree_nodes(
|
||||
processes: &[ProcessDescriptor],
|
||||
root_pid: u32,
|
||||
max_depth: Option<usize>,
|
||||
orphan_mode: bool,
|
||||
) -> Vec<ProcessNode> {
|
||||
let children = build_children_map(processes);
|
||||
let process_map = processes
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|process| (process.pid, process))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut nodes = Vec::new();
|
||||
if process_map.contains_key(&root_pid) {
|
||||
collect_tree_nodes(
|
||||
root_pid,
|
||||
0,
|
||||
&children,
|
||||
&process_map,
|
||||
max_depth,
|
||||
orphan_mode,
|
||||
&mut nodes,
|
||||
);
|
||||
} else if orphan_mode {
|
||||
let descendants = collect_descendant_ids(&children, root_pid);
|
||||
let mut orphan_pids = descendants.into_iter().collect::<Vec<_>>();
|
||||
orphan_pids.sort_unstable();
|
||||
for pid in orphan_pids {
|
||||
if let Some(process) = process_map.get(&pid) {
|
||||
nodes.push(ProcessNode {
|
||||
pid: process.pid,
|
||||
parent_pid: process.parent_pid,
|
||||
depth: 0,
|
||||
image_name: process.image_name.clone(),
|
||||
exe: process.exe.clone(),
|
||||
command_line: process.command_line.clone(),
|
||||
command_line_preview: compact_command_line(&process.command_line),
|
||||
start_time_unix: process.start_time_unix,
|
||||
run_time_seconds: process.run_time_seconds,
|
||||
run_time_display: format_run_time(process.run_time_seconds),
|
||||
orphan: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
fn build_children_map(processes: &[ProcessDescriptor]) -> HashMap<u32, Vec<u32>> {
|
||||
let mut map = HashMap::<u32, Vec<u32>>::new();
|
||||
for process in processes {
|
||||
if let Some(parent) = process.parent_pid {
|
||||
map.entry(parent).or_default().push(process.pid);
|
||||
}
|
||||
}
|
||||
for children in map.values_mut() {
|
||||
children.sort_unstable();
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn collect_tree_nodes(
|
||||
pid: u32,
|
||||
depth: usize,
|
||||
children: &HashMap<u32, Vec<u32>>,
|
||||
process_map: &HashMap<u32, ProcessDescriptor>,
|
||||
max_depth: Option<usize>,
|
||||
orphan: bool,
|
||||
nodes: &mut Vec<ProcessNode>,
|
||||
) {
|
||||
if max_depth.is_some_and(|max_depth| depth > max_depth) {
|
||||
return;
|
||||
}
|
||||
let Some(process) = process_map.get(&pid) else {
|
||||
return;
|
||||
};
|
||||
nodes.push(ProcessNode {
|
||||
pid: process.pid,
|
||||
parent_pid: process.parent_pid,
|
||||
depth,
|
||||
image_name: process.image_name.clone(),
|
||||
exe: process.exe.clone(),
|
||||
command_line: process.command_line.clone(),
|
||||
command_line_preview: compact_command_line(&process.command_line),
|
||||
start_time_unix: process.start_time_unix,
|
||||
run_time_seconds: process.run_time_seconds,
|
||||
run_time_display: format_run_time(process.run_time_seconds),
|
||||
orphan,
|
||||
});
|
||||
if let Some(child_pids) = children.get(&pid) {
|
||||
for child in child_pids {
|
||||
collect_tree_nodes(
|
||||
*child,
|
||||
depth + 1,
|
||||
children,
|
||||
process_map,
|
||||
max_depth,
|
||||
orphan,
|
||||
nodes,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_descendant_ids(children: &HashMap<u32, Vec<u32>>, root_pid: u32) -> HashSet<u32> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut stack = Vec::new();
|
||||
if let Some(root_children) = children.get(&root_pid) {
|
||||
stack.extend(root_children.iter().copied());
|
||||
}
|
||||
while let Some(pid) = stack.pop() {
|
||||
if seen.insert(pid)
|
||||
&& let Some(next) = children.get(&pid)
|
||||
{
|
||||
stack.extend(next.iter().copied());
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
fn render_report(report: &ProcessTreeReport, include_cmdline: bool) -> String {
|
||||
let mut rendered = String::new();
|
||||
if let Some(root_pid) = report.root_pid {
|
||||
writeln!(rendered, "root_pid={root_pid} nodes={}", report.nodes.len())
|
||||
.expect("writing to a String cannot fail");
|
||||
} else {
|
||||
writeln!(rendered, "system nodes={}", report.nodes.len())
|
||||
.expect("writing to a String cannot fail");
|
||||
}
|
||||
if let Some(message) = &report.message {
|
||||
writeln!(rendered, "message={message}").expect("writing to a String cannot fail");
|
||||
}
|
||||
for node in &report.nodes {
|
||||
let indent = " ".repeat(node.depth);
|
||||
let orphan = if node.orphan { " orphan" } else { "" };
|
||||
if include_cmdline && !node.command_line.is_empty() {
|
||||
writeln!(
|
||||
rendered,
|
||||
"{indent}pid={} image={} runtime={}{orphan} cmd={}",
|
||||
node.pid,
|
||||
node.image_name,
|
||||
node.run_time_display,
|
||||
compact_command_line(&node.command_line)
|
||||
)
|
||||
.expect("writing to a String cannot fail");
|
||||
} else {
|
||||
writeln!(
|
||||
rendered,
|
||||
"{indent}pid={} image={} runtime={}{orphan}",
|
||||
node.pid, node.image_name, node.run_time_display
|
||||
)
|
||||
.expect("writing to a String cannot fail");
|
||||
}
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn compact_command_line(arguments: &[String]) -> String {
|
||||
const MAX_COMMAND_LINE_CHARS: usize = 120;
|
||||
|
||||
let rendered = join_command_line(&sanitize_command_line(arguments));
|
||||
let character_count = rendered.chars().count();
|
||||
if character_count <= MAX_COMMAND_LINE_CHARS {
|
||||
return rendered;
|
||||
}
|
||||
|
||||
let visible = MAX_COMMAND_LINE_CHARS.saturating_sub(3);
|
||||
let prefix = rendered.chars().take(visible).collect::<String>();
|
||||
format!("{prefix}...")
|
||||
}
|
||||
|
||||
fn join_command_line(arguments: &[String]) -> String {
|
||||
let mut rendered = String::new();
|
||||
for argument in arguments {
|
||||
if !rendered.is_empty() {
|
||||
rendered.push(' ');
|
||||
}
|
||||
rendered.push_str(argument);
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn sanitize_command_line(arguments: &[String]) -> Vec<String> {
|
||||
let mut sanitized = Vec::with_capacity(arguments.len());
|
||||
let mut index = 0_usize;
|
||||
while index < arguments.len() {
|
||||
let value = &arguments[index];
|
||||
if value.eq_ignore_ascii_case("-encodedcommand")
|
||||
|| value.eq_ignore_ascii_case("-ec")
|
||||
|| value.eq_ignore_ascii_case("/encodedcommand")
|
||||
{
|
||||
sanitized.push(value.clone());
|
||||
if let Some(payload) = arguments.get(index + 1) {
|
||||
sanitized.push(format!("<encoded:{} chars>", payload.chars().count()));
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
sanitized.push(value.clone());
|
||||
index += 1;
|
||||
}
|
||||
sanitized
|
||||
}
|
||||
|
||||
fn format_run_time(run_time_seconds: u64) -> String {
|
||||
if run_time_seconds == 0 {
|
||||
"<1s".to_string()
|
||||
} else {
|
||||
format!("{run_time_seconds}s")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_processes() -> Vec<ProcessDescriptor> {
|
||||
vec![
|
||||
ProcessDescriptor {
|
||||
pid: 1,
|
||||
parent_pid: None,
|
||||
image_name: "root.exe".to_string(),
|
||||
exe: Some("C:\\root.exe".to_string()),
|
||||
command_line: vec!["root".to_string()],
|
||||
start_time_unix: 0,
|
||||
run_time_seconds: 10,
|
||||
},
|
||||
ProcessDescriptor {
|
||||
pid: 10,
|
||||
parent_pid: Some(1),
|
||||
image_name: "child.exe".to_string(),
|
||||
exe: None,
|
||||
command_line: vec!["child".to_string(), "--watch".to_string()],
|
||||
start_time_unix: 1,
|
||||
run_time_seconds: 5,
|
||||
},
|
||||
ProcessDescriptor {
|
||||
pid: 11,
|
||||
parent_pid: Some(10),
|
||||
image_name: "grand.exe".to_string(),
|
||||
exe: None,
|
||||
command_line: vec!["grand".to_string()],
|
||||
start_time_unix: 2,
|
||||
run_time_seconds: 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_run_mode_with_common_flags_after_subcommand() {
|
||||
let (_, cli) = parse_cli_from([
|
||||
"proctree",
|
||||
"run",
|
||||
"--json",
|
||||
"--max-depth",
|
||||
"2",
|
||||
"--",
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
])
|
||||
.expect("cli");
|
||||
|
||||
assert!(cli.common.json);
|
||||
assert_eq!(cli.max_depth, Some(2));
|
||||
assert!(matches!(cli.mode, Mode::Run(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_subtree_marks_orphans() {
|
||||
let processes = sample_processes().into_iter().skip(1).collect::<Vec<_>>();
|
||||
|
||||
let nodes = flatten_subtree_nodes(&processes, 1, None, true);
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert!(nodes.iter().all(|node| node.orphan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_and_root_modes_parse_cleanly() {
|
||||
let (outcome, _) = parse_cli_from(["proctree", "--help"]).expect("help");
|
||||
assert_eq!(outcome, ParseOutcome::Help);
|
||||
|
||||
let (_, cli) = parse_cli_from([
|
||||
"proctree",
|
||||
"--match",
|
||||
"child",
|
||||
"--include-cmdline",
|
||||
"root",
|
||||
"10",
|
||||
])
|
||||
.expect("root");
|
||||
assert!(cli.include_cmdline);
|
||||
assert!(cli.match_pattern.is_some());
|
||||
assert!(matches!(cli.mode, Mode::Root(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_parsers_and_filters_cover_error_paths() {
|
||||
assert!(matches!(
|
||||
parse_regex("["),
|
||||
Err(CliError::Usage(message)) if message.contains("invalid --match regex")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_usize_flag("--max-depth", "bad"),
|
||||
Err(CliError::Usage(message)) if message.contains("invalid --max-depth")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_mode_token("wat", &mut lexopt::Parser::from_iter(["proctree"])),
|
||||
Err(CliError::Usage(message)) if message.contains("unknown subcommand")
|
||||
));
|
||||
|
||||
let regex = parse_regex("watch").expect("regex");
|
||||
let filtered = filter_processes(sample_processes(), Some(®ex));
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].pid, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_and_root_rendering_stay_compact() {
|
||||
let processes = sample_processes();
|
||||
let system_nodes = flatten_system_nodes(&processes, Some(1));
|
||||
let root_nodes = flatten_subtree_nodes(&processes, 1, Some(2), false);
|
||||
let report = ProcessTreeReport {
|
||||
root_pid: Some(1),
|
||||
message: None,
|
||||
nodes: root_nodes,
|
||||
};
|
||||
let text = render_report(&report, true);
|
||||
|
||||
assert_eq!(system_nodes.len(), 2);
|
||||
assert!(text.contains("root_pid=1"));
|
||||
assert!(text.contains("cmd=child --watch"));
|
||||
assert!(text.contains("runtime=5s"));
|
||||
assert_eq!(format_run_time(0), "<1s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_command_line_truncates_long_output() {
|
||||
let command = compact_command_line(&[
|
||||
"pwsh".to_string(),
|
||||
"-EncodedCommand".to_string(),
|
||||
"A".repeat(200),
|
||||
]);
|
||||
|
||||
assert_eq!(command, "pwsh -EncodedCommand <encoded:200 chars>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn children_maps_and_descendants_are_stable() {
|
||||
let processes = sample_processes();
|
||||
let children = build_children_map(&processes);
|
||||
let descendants = collect_descendant_ids(&children, 1);
|
||||
|
||||
assert_eq!(children.get(&1), Some(&vec![10]));
|
||||
assert!(descendants.contains(&10));
|
||||
assert!(descendants.contains(&11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_observation_preserves_seen_descendants_after_root_exits() {
|
||||
let root = ProcessDescriptor {
|
||||
pid: 1,
|
||||
parent_pid: None,
|
||||
image_name: "root.exe".to_string(),
|
||||
exe: None,
|
||||
command_line: vec!["root".to_string()],
|
||||
start_time_unix: 0,
|
||||
run_time_seconds: 1,
|
||||
};
|
||||
let child = ProcessDescriptor {
|
||||
pid: 10,
|
||||
parent_pid: Some(1),
|
||||
image_name: "child.exe".to_string(),
|
||||
exe: None,
|
||||
command_line: vec!["child".to_string()],
|
||||
start_time_unix: 0,
|
||||
run_time_seconds: 1,
|
||||
};
|
||||
let detached_child = ProcessDescriptor {
|
||||
parent_pid: None,
|
||||
..child.clone()
|
||||
};
|
||||
|
||||
let mut observation = RunObservation::new(1);
|
||||
observation.record(&[root, child]);
|
||||
observation.record(std::slice::from_ref(&detached_child));
|
||||
|
||||
let cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
mode: Mode::System,
|
||||
match_pattern: None,
|
||||
max_depth: None,
|
||||
include_cmdline: false,
|
||||
orphans: true,
|
||||
};
|
||||
let report = observation.build_report(&cli, &[detached_child]);
|
||||
assert_eq!(report.root_pid, Some(1));
|
||||
assert_eq!(report.message, None);
|
||||
assert_eq!(report.nodes.len(), 2);
|
||||
assert!(report.nodes.iter().any(|node| node.pid == 1));
|
||||
assert!(
|
||||
report
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.pid == 10)
|
||||
.is_some_and(|node| node.orphan)
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.pid == 10)
|
||||
.is_some_and(|node| node.command_line_preview == "child")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Binary entry point for `proctree`.
|
||||
#![allow(clippy::multiple_crate_versions)]
|
||||
|
||||
fn main() {
|
||||
std::process::exit(proctree::main_entry());
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Integration tests for the `proctree` command.
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
||||
fn cargo_command() -> Command {
|
||||
Command::cargo_bin("proctree").expect("binary")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_rooted_run_tree_as_json() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("run")
|
||||
.arg("--json")
|
||||
.arg("--")
|
||||
.arg("pwsh")
|
||||
.arg("-NoProfile")
|
||||
.arg("-Command")
|
||||
.arg("Start-Process pwsh -ArgumentList '-NoProfile','-Command','Start-Sleep -Milliseconds 800' -PassThru | Out-Null; Start-Sleep -Milliseconds 200")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"root_pid\""))
|
||||
.stdout(predicate::str::contains("\"nodes\""))
|
||||
.stdout(predicate::str::contains("\"image_name\":\"pwsh.exe\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_includes_proctree_examples() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("system"))
|
||||
.stdout(predicate::str::contains("root <PID>"))
|
||||
.stdout(predicate::str::contains("ConvertFrom-Json"));
|
||||
}
|
||||
Reference in New Issue
Block a user