proc_tree/ops.rs
1//! Process tree operations: snapshot, resolve, queries, display.
2//!
3//! All functions are generic over [`ProcessStore`] so they work with any storage backend.
4
5use std::fmt::Write;
6
7use crate::traits::ProcessStore;
8use crate::tree::{ExitedProcess, ProcEvent, ProcessLink};
9use crate::types::ProcessInfo;
10
11const UNKNOWN: &str = "unknown";
12
13/// Snapshot all running processes from `/proc`.
14///
15/// Populates the store. Call once at startup before processing events.
16///
17/// # Errors
18///
19/// Returns an error if `/proc` cannot be read.
20///
21/// # Example
22///
23/// ```no_run
24/// use proc_tree::{DefaultStore, snapshot, ProcessStore};
25///
26/// let store = DefaultStore::new(600);
27/// snapshot(&store).expect("failed to read /proc");
28///
29/// // PID 1 should always exist on Linux
30/// assert!(store.get_process(1).is_some());
31/// ```
32pub fn snapshot(store: &impl ProcessStore) -> Result<(), std::io::Error> {
33 let dir = std::fs::read_dir("/proc")?;
34 for entry in dir.flatten() {
35 let name = entry.file_name();
36 let name_str = name.to_string_lossy();
37 let pid: u32 = match name_str.parse() {
38 Ok(p) => p,
39 Err(_) => continue,
40 };
41 if let Some(info) = crate::proc::parse_proc_entry(pid) {
42 store.insert_process(pid, info);
43 }
44 }
45 Ok(())
46}
47
48/// Resolve a PID to its process info.
49///
50/// Checks the store first, then falls back to reading `/proc` directly.
51///
52/// ```no_run
53/// use proc_tree::{DefaultStore, snapshot, resolve, ProcessStore};
54///
55/// let store = DefaultStore::new(600);
56/// snapshot(&store).expect("failed to read /proc");
57///
58/// let info = resolve(&store, 1).unwrap();
59/// assert!(!info.cmd().is_empty());
60/// ```
61pub fn resolve(store: &impl ProcessStore, pid: u32) -> Option<ProcessInfo> {
62 let info = resolve_process_info(store, pid);
63 // Populate store for future lookups if we got info from /proc
64 if let Some(ref info) = info
65 && store.get_process(pid).is_none()
66 {
67 store.insert_process(pid, info.clone());
68 }
69 info
70}
71
72/// Internal helper to resolve process info with fallback chain.
73///
74/// Tries store first, then falls back to reading `/proc` directly.
75/// Returns `None` if the process doesn't exist.
76fn resolve_process_info(store: &impl ProcessStore, pid: u32) -> Option<ProcessInfo> {
77 store
78 .get_process(pid)
79 .or_else(|| crate::proc::parse_proc_entry(pid))
80}
81
82/// Handle a batch of process lifecycle events.
83///
84/// Returns [`ExitedProcess`] handles for Exit events. The process info **stays in the store**
85/// — callers decide when to remove it via [`ExitedProcess::remove`].
86///
87/// This is critical for event-driven systems where file events (fanotify)
88/// may arrive after the proc connector exit event but still need to look
89/// up process info.
90///
91/// # Example
92///
93/// ```
94/// use proc_tree::{DefaultStore, handle_events, ProcEvent, ProcessStore};
95///
96/// let store = DefaultStore::new(0);
97///
98/// // Fork creates a process
99/// let exited = handle_events(&store, &[
100/// ProcEvent::Fork { child_pid: 200, parent_pid: 100, timestamp_ns: 0 },
101/// ]);
102/// assert!(exited.is_empty());
103///
104/// // Exit returns ExitedProcess handle — process info stays in store
105/// let exited = handle_events(&store, &[
106/// ProcEvent::Exit { pid: 200 },
107/// ]);
108/// assert_eq!(exited[0].pid, 200);
109/// assert!(store.get_process(200).is_some());
110///
111/// // Caller explicitly removes when done
112/// for ep in exited {
113/// ep.remove(&store);
114/// }
115/// assert!(store.get_process(200).is_none());
116/// ```
117#[must_use = "call .remove(&store) on each ExitedProcess after processing related events"]
118pub fn handle_events(store: &impl ProcessStore, events: &[ProcEvent]) -> Vec<ExitedProcess> {
119 let mut exited = Vec::new();
120 for event in events {
121 if let Some(ep) = handle_event(store, event) {
122 exited.push(ep);
123 }
124 }
125 exited
126}
127
128/// Handle a single process lifecycle event.
129///
130/// Returns [`ExitedProcess`] for Exit events, `None` for other events.
131/// The process info **stays in the store** — callers decide when to remove it
132/// via [`ExitedProcess::remove`].
133///
134/// # Example
135///
136/// ```
137/// use proc_tree::{DefaultStore, handle_event, ProcEvent, ProcessStore};
138///
139/// let store = DefaultStore::new(0);
140///
141/// // Create a process
142/// handle_event(&store, &ProcEvent::Fork {
143/// child_pid: 100,
144/// parent_pid: 1,
145/// timestamp_ns: 0,
146/// });
147///
148/// // Exit returns ExitedProcess handle — process info stays in store
149/// let exited = handle_event(&store, &ProcEvent::Exit { pid: 100 }).unwrap();
150/// assert_eq!(exited.pid, 100);
151/// assert!(store.get_process(100).is_some());
152///
153/// // Caller explicitly removes when done
154/// exited.remove(&store);
155/// assert!(store.get_process(100).is_none());
156/// ```
157#[must_use = "call .remove(&store) after processing related events"]
158pub fn handle_event(store: &impl ProcessStore, event: &ProcEvent) -> Option<ExitedProcess> {
159 match event {
160 ProcEvent::Fork {
161 child_pid,
162 parent_pid,
163 ..
164 } => {
165 store.insert_process(
166 *child_pid,
167 ProcessInfo::new(String::new(), String::new(), *parent_pid, 0, 0),
168 );
169 }
170 ProcEvent::Exec { pid, .. } => {
171 let info = crate::proc::parse_proc_entry(*pid).unwrap_or_else(|| {
172 ProcessInfo::new(UNKNOWN.to_string(), UNKNOWN.to_string(), 0, 0, 0)
173 });
174 // Keep start_time_ns from /proc, don't overwrite with event timestamp
175 store.insert_process(*pid, info);
176 }
177 ProcEvent::Exit { pid } => {
178 // Orphan children to init (PID 1)
179 store.for_each_child(*pid, &mut |child_pid| {
180 if let Some(mut info) = store.get_process(child_pid) {
181 info = ProcessInfo::new(
182 info.cmd().to_string(),
183 info.user().to_string(),
184 1,
185 info.tgid(),
186 info.start_time_ns(),
187 );
188 store.insert_process(child_pid, info);
189 }
190 });
191 return Some(ExitedProcess { pid: *pid });
192 }
193 }
194 None
195}
196
197/// Check if `pid` is a descendant of any process whose cmd == `target_cmd`.
198///
199/// ```
200/// use proc_tree::{DefaultStore, is_descendant, ProcessStore, ProcessInfo};
201///
202/// let store = DefaultStore::new(0);
203/// store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
204/// store.insert_process(100, ProcessInfo::new("sshd".into(), "root".into(), 1, 100, 0));
205/// store.insert_process(200, ProcessInfo::new("bash".into(), "root".into(), 100, 200, 0));
206///
207/// assert!(is_descendant(&store, 200, "sshd"));
208/// assert!(is_descendant(&store, 200, "init"));
209/// assert!(!is_descendant(&store, 200, "nginx"));
210/// assert!(!is_descendant(&store, 1, "sshd")); // init is not a descendant of sshd
211/// ```
212pub fn is_descendant(store: &impl ProcessStore, pid: u32, target_cmd: &str) -> bool {
213 walk_ancestors(store, pid, |info| info.cmd() == target_cmd)
214}
215
216/// Walk up the process tree from `pid`, calling `predicate` on each ancestor.
217///
218/// Returns `true` if the predicate matches any ancestor, `false` otherwise.
219/// Handles cycles by tracking visited PIDs.
220fn walk_ancestors<P>(store: &impl ProcessStore, pid: u32, predicate: P) -> bool
221where
222 P: Fn(&ProcessInfo) -> bool,
223{
224 let mut current = pid;
225 let mut visited = std::collections::HashSet::new();
226 while let Some(info) = store.get_process(current) {
227 if !visited.insert(current) {
228 break;
229 }
230 if predicate(&info) {
231 return true;
232 }
233 if info.ppid() == 0 || current == info.ppid() {
234 break;
235 }
236 current = info.ppid();
237 }
238 false
239}
240
241/// Build a chain of ProcessLink from the process tree.
242pub fn build_chain_links(store: &impl ProcessStore, pid: u32) -> Vec<ProcessLink> {
243 let mut parts = Vec::new();
244 let mut current = pid;
245 let mut visited = std::collections::HashSet::new();
246 loop {
247 if !visited.insert(current) {
248 break;
249 }
250 match resolve_process_info(store, current) {
251 Some(info) => {
252 parts.push(ProcessLink::new(
253 current,
254 info.cmd().to_string(),
255 info.user().to_string(),
256 ));
257 if info.ppid() == 0 || current == info.ppid() {
258 break;
259 }
260 current = info.ppid();
261 }
262 None => {
263 parts.push(ProcessLink::new(
264 current,
265 UNKNOWN.to_string(),
266 UNKNOWN.to_string(),
267 ));
268 break;
269 }
270 }
271 }
272 parts
273}
274
275/// Build a chain string from the process tree.
276///
277/// Format: `"102|touch|root;101|sh|root;100|openclaw|root;1|systemd|root"`
278///
279/// ```
280/// use proc_tree::{DefaultStore, build_chain_string, ProcessStore, ProcessInfo};
281///
282/// let store = DefaultStore::new(0);
283///
284/// store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
285/// store.insert_process(100, ProcessInfo::new("sshd".into(), "root".into(), 1, 100, 0));
286/// store.insert_process(200, ProcessInfo::new("bash".into(), "root".into(), 100, 200, 0));
287///
288/// let chain = build_chain_string(&store, 200);
289/// assert_eq!(chain, "200|bash|root;100|sshd|root;1|init|root");
290/// ```
291pub fn build_chain_string(store: &impl ProcessStore, pid: u32) -> String {
292 let links = build_chain_links(store, pid);
293 if links.is_empty() {
294 return String::new();
295 }
296 let mut result = String::new();
297 for (i, link) in links.iter().enumerate() {
298 if i > 0 {
299 result.push(';');
300 }
301 // SAFETY: writing to String never fails
302 let _ = write!(result, "{link}");
303 }
304 result
305}
306
307/// Get direct children of a PID.
308///
309/// ```
310/// use proc_tree::{DefaultStore, children, ProcessStore, ProcessInfo};
311///
312/// let store = DefaultStore::new(0);
313/// store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
314/// store.insert_process(100, ProcessInfo::new("a".into(), "root".into(), 1, 100, 0));
315/// store.insert_process(200, ProcessInfo::new("b".into(), "root".into(), 1, 200, 0));
316/// store.insert_process(300, ProcessInfo::new("c".into(), "root".into(), 100, 300, 0));
317///
318/// let mut kids = children(&store, 1);
319/// kids.sort();
320/// assert_eq!(kids, vec![100, 200]);
321/// assert_eq!(children(&store, 100), vec![300]);
322/// assert!(children(&store, 999).is_empty());
323/// ```
324pub fn children(store: &impl ProcessStore, pid: u32) -> Vec<u32> {
325 store.children_of(pid)
326}
327
328/// Get all descendants of a PID (BFS traversal).
329///
330/// ```
331/// use proc_tree::{DefaultStore, descendants, ProcessStore, ProcessInfo};
332///
333/// let store = DefaultStore::new(0);
334/// store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
335/// store.insert_process(100, ProcessInfo::new("a".into(), "root".into(), 1, 100, 0));
336/// store.insert_process(200, ProcessInfo::new("b".into(), "root".into(), 100, 200, 0));
337/// store.insert_process(300, ProcessInfo::new("c".into(), "root".into(), 200, 300, 0));
338///
339/// let mut desc = descendants(&store, 1);
340/// desc.sort();
341/// assert_eq!(desc, vec![100, 200, 300]);
342/// assert_eq!(descendants(&store, 300), Vec::<u32>::new());
343/// ```
344pub fn descendants(store: &impl ProcessStore, pid: u32) -> Vec<u32> {
345 let mut result = Vec::new();
346 let mut queue = std::collections::VecDeque::new();
347 queue.push_back(pid);
348 while let Some(current) = queue.pop_front() {
349 let kids = children(store, current);
350 for kid in kids {
351 result.push(kid);
352 queue.push_back(kid);
353 }
354 }
355 result
356}
357
358/// Get siblings of a PID (processes with the same parent).
359///
360/// Excludes the given pid itself.
361///
362/// ```
363/// use proc_tree::{DefaultStore, siblings, ProcessStore, ProcessInfo};
364///
365/// let store = DefaultStore::new(0);
366/// store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
367/// store.insert_process(100, ProcessInfo::new("a".into(), "root".into(), 1, 100, 0));
368/// store.insert_process(200, ProcessInfo::new("b".into(), "root".into(), 1, 200, 0));
369/// store.insert_process(300, ProcessInfo::new("c".into(), "root".into(), 1, 300, 0));
370///
371/// let mut sibs = siblings(&store, 100);
372/// sibs.sort();
373/// assert_eq!(sibs, vec![200, 300]);
374/// assert!(siblings(&store, 1).is_empty()); // init has no siblings
375/// ```
376pub fn siblings(store: &impl ProcessStore, pid: u32) -> Vec<u32> {
377 let ppid = match store.get_process(pid) {
378 Some(info) => info.ppid(),
379 None => return Vec::new(),
380 };
381 children(store, ppid)
382 .into_iter()
383 .filter(|&c| c != pid)
384 .collect()
385}
386
387/// Find all PIDs whose cmd matches the given string.
388///
389/// ```
390/// use proc_tree::{DefaultStore, find_by_cmd, ProcessStore, ProcessInfo};
391///
392/// let store = DefaultStore::new(0);
393/// store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
394/// store.insert_process(100, ProcessInfo::new("sshd".into(), "root".into(), 1, 100, 0));
395/// store.insert_process(200, ProcessInfo::new("sshd".into(), "root".into(), 1, 200, 0));
396/// store.insert_process(300, ProcessInfo::new("bash".into(), "root".into(), 1, 300, 0));
397///
398/// let mut sshds = find_by_cmd(&store, "sshd");
399/// sshds.sort();
400/// assert_eq!(sshds, vec![100, 200]);
401/// assert_eq!(find_by_cmd(&store, "nginx"), Vec::<u32>::new());
402/// ```
403pub fn find_by_cmd(store: &impl ProcessStore, target_cmd: &str) -> Vec<u32> {
404 find_by(
405 store,
406 |pid| {
407 store
408 .get_process(pid)
409 .map(|info| info.cmd().to_string())
410 .filter(|c| !c.is_empty())
411 .or_else(|| crate::proc::read_proc_comm(pid))
412 },
413 target_cmd,
414 )
415}
416
417/// Find all PIDs whose user matches the given string.
418///
419/// ```
420/// use proc_tree::{DefaultStore, find_by_user, ProcessStore, ProcessInfo};
421///
422/// let store = DefaultStore::new(0);
423///
424/// store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
425/// store.insert_process(100, ProcessInfo::new("bash".into(), "alice".into(), 1, 100, 0));
426///
427/// assert_eq!(find_by_user(&store, "root"), vec![1]);
428/// assert_eq!(find_by_user(&store, "alice"), vec![100]);
429/// assert_eq!(find_by_user(&store, "nobody"), Vec::<u32>::new());
430/// ```
431pub fn find_by_user(store: &impl ProcessStore, target_user: &str) -> Vec<u32> {
432 find_by(
433 store,
434 |pid| {
435 store
436 .get_process(pid)
437 .map(|info| info.user().to_string())
438 .or_else(|| crate::proc::parse_proc_entry(pid).map(|info| info.user().to_string()))
439 },
440 target_user,
441 )
442}
443
444/// Generic find function that filters PIDs by a value extractor.
445///
446/// This is a helper to reduce code duplication between `find_by_cmd` and `find_by_user`.
447fn find_by<F>(store: &impl ProcessStore, extract: F, target: &str) -> Vec<u32>
448where
449 F: Fn(u32) -> Option<String>,
450{
451 store
452 .all_pids()
453 .into_iter()
454 .filter(|&pid| extract(pid).as_deref() == Some(target))
455 .collect()
456}
457
458/// Render a pstree-style display starting from the given root PID.
459///
460/// ```
461/// use proc_tree::{DefaultStore, display, ProcessStore, ProcessInfo};
462///
463/// let store = DefaultStore::new(0);
464/// store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
465/// store.insert_process(100, ProcessInfo::new("sshd".into(), "root".into(), 1, 100, 0));
466/// store.insert_process(200, ProcessInfo::new("cron".into(), "root".into(), 1, 200, 0));
467///
468/// let output = display(&store, 1);
469/// assert!(output.starts_with("init"));
470/// assert!(output.contains("sshd"));
471/// assert!(output.contains("cron"));
472/// ```
473pub fn display(store: &impl ProcessStore, root_pid: u32) -> String {
474 render_tree(store, root_pid, true)
475}
476
477/// Recursive tree renderer.
478///
479/// `is_root` controls the rendering style:
480/// - Root node: first child attaches with "─"
481/// - Non-root nodes: all children use tree prefixes
482fn render_tree(store: &impl ProcessStore, pid: u32, is_root: bool) -> String {
483 let cmd = get_cmd(store, pid);
484 let kids = children(store, pid);
485 if kids.is_empty() {
486 return cmd;
487 }
488 let mut output = cmd;
489 for (i, &kid) in kids.iter().enumerate() {
490 let is_last = i == kids.len() - 1;
491 let prefix = if is_last { "└─" } else { "├─" };
492 let continuation = if is_last { " " } else { "│ " };
493 let sub = render_tree(store, kid, false);
494 let mut lines = sub.lines();
495 if let Some(first) = lines.next() {
496 if is_root && i == 0 {
497 output.push('─');
498 output.push_str(first);
499 } else {
500 output.push('\n');
501 output.push_str(prefix);
502 output.push_str(first);
503 }
504 for line in lines {
505 output.push('\n');
506 output.push_str(continuation);
507 output.push_str(line);
508 }
509 }
510 }
511 output
512}
513
514/// Get command name for a PID, with fallback chain: store -> /proc -> "unknown"
515fn get_cmd(store: &impl ProcessStore, pid: u32) -> String {
516 resolve_process_info(store, pid)
517 .map(|info| info.cmd().to_string())
518 .filter(|c| !c.is_empty())
519 .unwrap_or_else(|| UNKNOWN.to_string())
520}
521
522/// Get the number of entries in the store.
523///
524/// ```
525/// use proc_tree::{DefaultStore, tree_len, ProcessStore, ProcessInfo};
526///
527/// let store = DefaultStore::new(0);
528/// assert_eq!(tree_len(&store), 0);
529///
530/// store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
531/// store.insert_process(2, ProcessInfo::new("bash".into(), "root".into(), 1, 2, 0));
532/// assert_eq!(tree_len(&store), 2);
533/// ```
534pub fn tree_len(store: &impl ProcessStore) -> usize {
535 store.all_pids().len()
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use crate::default_store::DefaultStore;
542
543 #[test]
544 fn display_single_node() {
545 let store = DefaultStore::new(0);
546 store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
547 assert_eq!(display(&store, 1), "init");
548 }
549
550 #[test]
551 fn display_root_with_children() {
552 let store = DefaultStore::new(0);
553 store.insert_process(1, ProcessInfo::new("init".into(), "root".into(), 0, 1, 0));
554 store.insert_process(100, ProcessInfo::new("a".into(), "root".into(), 1, 100, 0));
555 store.insert_process(200, ProcessInfo::new("b".into(), "root".into(), 1, 200, 0));
556 let d = display(&store, 1);
557 assert!(d.starts_with("init"));
558 assert!(d.contains("a"));
559 assert!(d.contains("b"));
560 }
561}