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 crate::traits::ProcessStore;
6use crate::tree::{ExitedProcess, ProcEvent, ProcessLink};
7use crate::types::ProcessInfo;
8
9/// Snapshot all running processes from `/proc`.
10///
11/// Populates the store. Call once at startup before processing events.
12///
13/// ```no_run
14/// use proc_tree::{DefaultStore, snapshot, ProcessStore};
15///
16/// let store = DefaultStore::new(600);
17/// snapshot(&store);
18///
19/// // PID 1 should always exist on Linux
20/// assert!(store.get_process(1).is_some());
21/// ```
22pub fn snapshot(store: &impl ProcessStore) {
23    let dir = match std::fs::read_dir("/proc") {
24        Ok(d) => d,
25        Err(e) => {
26            eprintln!("[WARNING] proc-tree: cannot read /proc: {e}");
27            return;
28        }
29    };
30    for entry in dir.flatten() {
31        let name = entry.file_name();
32        let name_str = name.to_string_lossy();
33        let pid: u32 = match name_str.parse() {
34            Ok(p) => p,
35            Err(_) => continue,
36        };
37        if let Some(info) = crate::proc::parse_proc_entry(pid) {
38            store.insert_process(pid, info);
39        }
40    }
41}
42
43/// Resolve a PID to its process info.
44///
45/// Checks the store first, then falls back to reading `/proc` directly.
46///
47/// ```no_run
48/// use proc_tree::{DefaultStore, snapshot, resolve, ProcessStore};
49///
50/// let store = DefaultStore::new(600);
51/// snapshot(&store);
52///
53/// let info = resolve(&store, 1).unwrap();
54/// assert!(!info.cmd.is_empty());
55/// ```
56pub fn resolve(store: &impl ProcessStore, pid: u32) -> Option<ProcessInfo> {
57    let info = resolve_process_info(store, pid);
58    // Populate store for future lookups if we got info from /proc
59    if let Some(ref info) = info
60        && store.get_process(pid).is_none()
61    {
62        store.insert_process(pid, info.clone());
63    }
64    info
65}
66
67/// Internal helper to resolve process info with fallback chain.
68///
69/// Tries store first, then falls back to reading `/proc` directly.
70/// Returns `None` if the process doesn't exist.
71fn resolve_process_info(store: &impl ProcessStore, pid: u32) -> Option<ProcessInfo> {
72    store
73        .get_process(pid)
74        .or_else(|| crate::proc::parse_proc_entry(pid))
75}
76
77/// Handle a batch of process lifecycle events.
78///
79/// Returns [`ExitedProcess`] handles for Exit events. The process info **stays in the store**
80/// — callers decide when to remove it via [`ExitedProcess::remove`].
81///
82/// This is critical for event-driven systems where file events (fanotify)
83/// may arrive after the proc connector exit event but still need to look
84/// up process info.
85///
86/// # Example
87///
88/// ```
89/// use proc_tree::{DefaultStore, handle_events, ProcEvent, ProcessStore};
90///
91/// let store = DefaultStore::new(0);
92///
93/// // Fork creates a process
94/// let exited = handle_events(&store, &[
95///     ProcEvent::Fork { child_pid: 200, parent_pid: 100, timestamp_ns: 0 },
96/// ]);
97/// assert!(exited.is_empty());
98///
99/// // Exit returns ExitedProcess handle — process info stays in store
100/// let exited = handle_events(&store, &[
101///     ProcEvent::Exit { pid: 200 },
102/// ]);
103/// assert_eq!(exited[0].pid, 200);
104/// assert!(store.get_process(200).is_some());
105///
106/// // Caller explicitly removes when done
107/// for ep in exited {
108///     ep.remove(&store);
109/// }
110/// assert!(store.get_process(200).is_none());
111/// ```
112#[must_use = "call .remove(&store) on each ExitedProcess after processing related events"]
113pub fn handle_events(store: &impl ProcessStore, events: &[ProcEvent]) -> Vec<ExitedProcess> {
114    let mut exited = Vec::new();
115    for event in events {
116        if let Some(ep) = handle_event(store, event) {
117            exited.push(ep);
118        }
119    }
120    exited
121}
122
123/// Handle a single process lifecycle event.
124///
125/// Returns [`ExitedProcess`] for Exit events, `None` for other events.
126/// The process info **stays in the store** — callers decide when to remove it
127/// via [`ExitedProcess::remove`].
128///
129/// # Example
130///
131/// ```
132/// use proc_tree::{DefaultStore, handle_event, ProcEvent, ProcessStore};
133///
134/// let store = DefaultStore::new(0);
135///
136/// // Create a process
137/// handle_event(&store, &ProcEvent::Fork {
138///     child_pid: 100,
139///     parent_pid: 1,
140///     timestamp_ns: 0,
141/// });
142///
143/// // Exit returns ExitedProcess handle — process info stays in store
144/// let exited = handle_event(&store, &ProcEvent::Exit { pid: 100 }).unwrap();
145/// assert_eq!(exited.pid, 100);
146/// assert!(store.get_process(100).is_some());
147///
148/// // Caller explicitly removes when done
149/// exited.remove(&store);
150/// assert!(store.get_process(100).is_none());
151/// ```
152#[must_use = "call .remove(&store) after processing related events"]
153pub fn handle_event(store: &impl ProcessStore, event: &ProcEvent) -> Option<ExitedProcess> {
154    match event {
155        ProcEvent::Fork {
156            child_pid,
157            parent_pid,
158            ..
159        } => {
160            store.insert_process(
161                *child_pid,
162                ProcessInfo {
163                    ppid: *parent_pid,
164                    cmd: String::new(),
165                    user: String::new(),
166                    tgid: 0,
167                    start_time_ns: 0,
168                },
169            );
170        }
171        ProcEvent::Exec { pid, .. } => {
172            let info = crate::proc::parse_proc_entry(*pid).unwrap_or_else(|| {
173                let cmd = "unknown".to_string();
174                ProcessInfo {
175                    ppid: 0,
176                    cmd,
177                    user: "unknown".to_string(),
178                    tgid: 0,
179                    start_time_ns: 0,
180                }
181            });
182            // Keep start_time_ns from /proc, don't overwrite with event timestamp
183            store.insert_process(*pid, info);
184        }
185        ProcEvent::Exit { pid } => {
186            // Orphan children to init (PID 1)
187            let children = store.children_of(*pid);
188            for child_pid in children {
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    build_chain_links(store, pid)
296        .iter()
297        .map(|l| l.to_string())
298        .collect::<Vec<_>>()
299        .join(";")
300}
301
302/// Get direct children of a PID.
303///
304/// ```
305/// use proc_tree::{DefaultStore, children, ProcessStore, ProcessInfo};
306///
307/// let store = DefaultStore::new(0);
308/// store.insert_process(1, ProcessInfo { ppid: 0, cmd: "init".into(), user: "root".into(), tgid: 1, start_time_ns: 0 });
309/// store.insert_process(100, ProcessInfo { ppid: 1, cmd: "a".into(), user: "root".into(), tgid: 100, start_time_ns: 0 });
310/// store.insert_process(200, ProcessInfo { ppid: 1, cmd: "b".into(), user: "root".into(), tgid: 200, start_time_ns: 0 });
311/// store.insert_process(300, ProcessInfo { ppid: 100, cmd: "c".into(), user: "root".into(), tgid: 300, start_time_ns: 0 });
312///
313/// let mut kids = children(&store, 1);
314/// kids.sort();
315/// assert_eq!(kids, vec![100, 200]);
316/// assert_eq!(children(&store, 100), vec![300]);
317/// assert!(children(&store, 999).is_empty());
318/// ```
319pub fn children(store: &impl ProcessStore, pid: u32) -> Vec<u32> {
320    store.children_of(pid)
321}
322
323/// Get all descendants of a PID (BFS traversal).
324///
325/// ```
326/// use proc_tree::{DefaultStore, descendants, ProcessStore, ProcessInfo};
327///
328/// let store = DefaultStore::new(0);
329/// store.insert_process(1, ProcessInfo { ppid: 0, cmd: "init".into(), user: "root".into(), tgid: 1, start_time_ns: 0 });
330/// store.insert_process(100, ProcessInfo { ppid: 1, cmd: "a".into(), user: "root".into(), tgid: 100, start_time_ns: 0 });
331/// store.insert_process(200, ProcessInfo { ppid: 100, cmd: "b".into(), user: "root".into(), tgid: 200, start_time_ns: 0 });
332/// store.insert_process(300, ProcessInfo { ppid: 200, cmd: "c".into(), user: "root".into(), tgid: 300, start_time_ns: 0 });
333///
334/// let mut desc = descendants(&store, 1);
335/// desc.sort();
336/// assert_eq!(desc, vec![100, 200, 300]);
337/// assert_eq!(descendants(&store, 300), Vec::<u32>::new());
338/// ```
339pub fn descendants(store: &impl ProcessStore, pid: u32) -> Vec<u32> {
340    let mut result = Vec::new();
341    let mut queue = std::collections::VecDeque::new();
342    queue.push_back(pid);
343    while let Some(current) = queue.pop_front() {
344        let kids = children(store, current);
345        for kid in kids {
346            result.push(kid);
347            queue.push_back(kid);
348        }
349    }
350    result
351}
352
353/// Get siblings of a PID (processes with the same parent).
354///
355/// Excludes the given pid itself.
356///
357/// ```
358/// use proc_tree::{DefaultStore, siblings, ProcessStore, ProcessInfo};
359///
360/// let store = DefaultStore::new(0);
361/// store.insert_process(1, ProcessInfo { ppid: 0, cmd: "init".into(), user: "root".into(), tgid: 1, start_time_ns: 0 });
362/// store.insert_process(100, ProcessInfo { ppid: 1, cmd: "a".into(), user: "root".into(), tgid: 100, start_time_ns: 0 });
363/// store.insert_process(200, ProcessInfo { ppid: 1, cmd: "b".into(), user: "root".into(), tgid: 200, start_time_ns: 0 });
364/// store.insert_process(300, ProcessInfo { ppid: 1, cmd: "c".into(), user: "root".into(), tgid: 300, start_time_ns: 0 });
365///
366/// let mut sibs = siblings(&store, 100);
367/// sibs.sort();
368/// assert_eq!(sibs, vec![200, 300]);
369/// assert!(siblings(&store, 1).is_empty()); // init has no siblings
370/// ```
371pub fn siblings(store: &impl ProcessStore, pid: u32) -> Vec<u32> {
372    let ppid = match store.get_process(pid) {
373        Some(info) => info.ppid,
374        None => return Vec::new(),
375    };
376    children(store, ppid)
377        .into_iter()
378        .filter(|&c| c != pid)
379        .collect()
380}
381
382/// Find all PIDs whose cmd matches the given string.
383///
384/// ```
385/// use proc_tree::{DefaultStore, find_by_cmd, ProcessStore, ProcessInfo};
386///
387/// let store = DefaultStore::new(0);
388/// store.insert_process(1, ProcessInfo { ppid: 0, cmd: "init".into(), user: "root".into(), tgid: 1, start_time_ns: 0 });
389/// store.insert_process(100, ProcessInfo { ppid: 1, cmd: "sshd".into(), user: "root".into(), tgid: 100, start_time_ns: 0 });
390/// store.insert_process(200, ProcessInfo { ppid: 1, cmd: "sshd".into(), user: "root".into(), tgid: 200, start_time_ns: 0 });
391/// store.insert_process(300, ProcessInfo { ppid: 1, cmd: "bash".into(), user: "root".into(), tgid: 300, start_time_ns: 0 });
392///
393/// let mut sshds = find_by_cmd(&store, "sshd");
394/// sshds.sort();
395/// assert_eq!(sshds, vec![100, 200]);
396/// assert_eq!(find_by_cmd(&store, "nginx"), Vec::<u32>::new());
397/// ```
398pub fn find_by_cmd(store: &impl ProcessStore, target_cmd: &str) -> Vec<u32> {
399    find_by(
400        store,
401        |pid| {
402            store
403                .get_process(pid)
404                .map(|info| info.cmd)
405                .filter(|c| !c.is_empty())
406                .or_else(|| crate::proc::read_proc_comm(pid))
407        },
408        target_cmd,
409    )
410}
411
412/// Find all PIDs whose user matches the given string.
413///
414/// ```
415/// use proc_tree::{DefaultStore, find_by_user, ProcessStore, ProcessInfo};
416///
417/// let store = DefaultStore::new(0);
418///
419/// store.insert_process(1, ProcessInfo { ppid: 0, cmd: "init".into(), user: "root".into(), tgid: 1, start_time_ns: 0 });
420/// store.insert_process(100, ProcessInfo { ppid: 1, cmd: "bash".into(), user: "alice".into(), tgid: 100, start_time_ns: 0 });
421///
422/// assert_eq!(find_by_user(&store, "root"), vec![1]);
423/// assert_eq!(find_by_user(&store, "alice"), vec![100]);
424/// assert_eq!(find_by_user(&store, "nobody"), Vec::<u32>::new());
425/// ```
426pub fn find_by_user(store: &impl ProcessStore, target_user: &str) -> Vec<u32> {
427    find_by(
428        store,
429        |pid| {
430            store
431                .get_process(pid)
432                .map(|info| info.user)
433                .or_else(|| crate::proc::parse_proc_entry(pid).map(|info| info.user))
434        },
435        target_user,
436    )
437}
438
439/// Generic find function that filters PIDs by a value extractor.
440///
441/// This is a helper to reduce code duplication between `find_by_cmd` and `find_by_user`.
442fn find_by<F>(store: &impl ProcessStore, extract: F, target: &str) -> Vec<u32>
443where
444    F: Fn(u32) -> Option<String>,
445{
446    store
447        .all_pids()
448        .into_iter()
449        .filter(|&pid| extract(pid).as_deref() == Some(target))
450        .collect()
451}
452
453/// Render a pstree-style display starting from the given root PID.
454///
455/// ```
456/// use proc_tree::{DefaultStore, display, ProcessStore, ProcessInfo};
457///
458/// let store = DefaultStore::new(0);
459/// store.insert_process(1, ProcessInfo { ppid: 0, cmd: "init".into(), user: "root".into(), tgid: 1, start_time_ns: 0 });
460/// store.insert_process(100, ProcessInfo { ppid: 1, cmd: "sshd".into(), user: "root".into(), tgid: 100, start_time_ns: 0 });
461/// store.insert_process(200, ProcessInfo { ppid: 1, cmd: "cron".into(), user: "root".into(), tgid: 200, start_time_ns: 0 });
462///
463/// let output = display(&store, 1);
464/// assert!(output.starts_with("init"));
465/// assert!(output.contains("sshd"));
466/// assert!(output.contains("cron"));
467/// ```
468pub fn display(store: &impl ProcessStore, root_pid: u32) -> String {
469    render_tree(store, root_pid, true)
470}
471
472/// Recursive tree renderer.
473///
474/// `is_root` controls the rendering style:
475/// - Root node: first child attaches with "─"
476/// - Non-root nodes: all children use tree prefixes
477fn render_tree(store: &impl ProcessStore, pid: u32, is_root: bool) -> String {
478    let cmd = get_cmd(store, pid);
479    let kids = children(store, pid);
480    if kids.is_empty() {
481        return cmd;
482    }
483    let mut output = cmd;
484    for (i, &kid) in kids.iter().enumerate() {
485        let is_last = i == kids.len() - 1;
486        let prefix = if is_last { "└─" } else { "├─" };
487        let continuation = if is_last { "  " } else { "│ " };
488        let sub = render_tree(store, kid, false);
489        let lines: Vec<&str> = sub.lines().collect();
490        if is_root && i == 0 {
491            output.push_str(&format!("─{}", lines[0]));
492        } else {
493            output.push('\n');
494            output.push_str(prefix);
495            output.push_str(lines[0]);
496        }
497        for line in &lines[1..] {
498            output.push('\n');
499            output.push_str(continuation);
500            output.push_str(line);
501        }
502    }
503    output
504}
505
506/// Get command name for a PID, with fallback chain: store -> /proc -> "unknown"
507fn get_cmd(store: &impl ProcessStore, pid: u32) -> String {
508    resolve_process_info(store, pid)
509        .map(|info| info.cmd)
510        .filter(|c| !c.is_empty())
511        .unwrap_or_else(|| "unknown".to_string())
512}
513
514/// Get the number of entries in the store.
515///
516/// ```
517/// use proc_tree::{DefaultStore, tree_len, ProcessStore, ProcessInfo};
518///
519/// let store = DefaultStore::new(0);
520/// assert_eq!(tree_len(&store), 0);
521///
522/// store.insert_process(1, ProcessInfo { ppid: 0, cmd: "init".into(), user: "root".into(), tgid: 1, start_time_ns: 0 });
523/// store.insert_process(2, ProcessInfo { ppid: 1, cmd: "bash".into(), user: "root".into(), tgid: 2, start_time_ns: 0 });
524/// assert_eq!(tree_len(&store), 2);
525/// ```
526pub fn tree_len(store: &impl ProcessStore) -> usize {
527    store.all_pids().len()
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::default_store::DefaultStore;
534
535    #[test]
536    fn display_single_node() {
537        let store = DefaultStore::new(0);
538        store.insert_process(
539            1,
540            ProcessInfo {
541                ppid: 0,
542                cmd: "init".into(),
543                user: "root".into(),
544                tgid: 1,
545                start_time_ns: 0,
546            },
547        );
548        assert_eq!(display(&store, 1), "init");
549    }
550
551    #[test]
552    fn display_root_with_children() {
553        let store = DefaultStore::new(0);
554        store.insert_process(
555            1,
556            ProcessInfo {
557                ppid: 0,
558                cmd: "init".into(),
559                user: "root".into(),
560                tgid: 1,
561                start_time_ns: 0,
562            },
563        );
564        store.insert_process(
565            100,
566            ProcessInfo {
567                ppid: 1,
568                cmd: "a".into(),
569                user: "root".into(),
570                tgid: 100,
571                start_time_ns: 0,
572            },
573        );
574        store.insert_process(
575            200,
576            ProcessInfo {
577                ppid: 1,
578                cmd: "b".into(),
579                user: "root".into(),
580                tgid: 200,
581                start_time_ns: 0,
582            },
583        );
584        let d = display(&store, 1);
585        assert!(d.starts_with("init"));
586        assert!(d.contains("a"));
587        assert!(d.contains("b"));
588    }
589}