Skip to main content

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