Skip to content

Commit 75a5184

Browse files
committed
Add -c/--command regex filter for cmdline matching
1 parent 25298f1 commit 75a5184

9 files changed

Lines changed: 184 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
- New `-c/--command <regex>` option to filter processes by a regex against the command line (repeatable; works in batch and interactive modes)
12+
813
## [0.2.5] - 2026-08-08
914

1015
### Changed

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ futures = { version = "0.3", default-features = false }
4646
chrono = { version = "0.4", default-features = false, features = ["clock"] }
4747
serde = { version = "1", default-features = false, features = ["derive"] }
4848
serde_json = "1"
49+
regex = "1"
4950
linux-taskstats = { path = "patches/linux-taskstats", default-features = false, version = "0.7" }
5051

5152
[profile.release]

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ Monitor user's processes:
110110
sudo iotop -u www-data
111111
```
112112

113+
Filter by command-line regex:
114+
```bash
115+
sudo iotop -c bash
116+
sudo iotop -c '^sshd' -c 'nginx.*worker'
117+
```
118+
113119
Batch mode with timestamps:
114120
```bash
115121
sudo iotop -t -b -n 10 > iotop.log
@@ -140,6 +146,7 @@ sudo iotop -b --csv -t -q -n 5
140146
| `-b` | `--batch` | Batch mode (non-interactive) |
141147
| `-p` | `--pid` | Monitor specific processes/threads (can be repeated) |
142148
| `-u` | `--user` | Monitor processes by username or UID (can be repeated) |
149+
| `-c` | `--command` | Only show processes whose command line matches the regex (can be repeated) |
143150
| `-t` | `--time` | Add timestamp on each line (implies `--batch`) |
144151
| `-q` | `--quiet` | Suppress column names and headers (implies `--batch`) |
145152
| `-k` | `--kilobytes` | Use kilobytes instead of human-friendly units |

‎completions/iotop.bash‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ _iotop() {
55
COMPREPLY=()
66
cur="${COMP_WORDS[COMP_CWORD]}"
77
prev="${COMP_WORDS[COMP_CWORD-1]}"
8-
opts="-o --only -P --processes -a --accumulated -d --delay -n --iter -b --batch -p --pid -u --user -t --time -q --quiet -k --kilobytes --json --csv -h --help"
8+
opts="-o --only -P --processes -a --accumulated -d --delay -n --iter -b --batch -p --pid -u --user -c --command -t --time -q --quiet -k --kilobytes --json --csv -h --help"
99

1010
case "${prev}" in
1111
-d|--delay)
@@ -28,6 +28,11 @@ _iotop() {
2828
COMPREPLY=( $(compgen -u -- ${cur}) )
2929
return 0
3030
;;
31+
-c|--command)
32+
# Complete with running command names
33+
COMPREPLY=( $(compgen -W "$(ps -e -o comm= | sort -u | tr '\n' ' ')" -- ${cur}) )
34+
return 0
35+
;;
3136
*)
3237
;;
3338
esac

‎completions/iotop.fish‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ complete -c iotop -s n -l iter -d 'Number of iterations before ending' -x -a '5
99
complete -c iotop -s b -l batch -d 'Batch mode (non-interactive)'
1010
complete -c iotop -s p -l pid -d 'Processes/threads to monitor' -x -a '(__fish_complete_pids)'
1111
complete -c iotop -s u -l user -d 'Users to monitor' -x -a '(__fish_complete_users)'
12+
complete -c iotop -s c -l command -d 'Only show processes whose command line matches the regex' -x
1213
complete -c iotop -s t -l time -d 'Add timestamp on each line (implies --batch)'
1314
complete -c iotop -s q -l quiet -d 'Suppress column names and headers (implies --batch)'
1415
complete -c iotop -s k -l kilobytes -d 'Use kilobytes instead of human-friendly units'

‎completions/iotop.zsh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ _iotop() {
1212
'(-b --batch)'{-b,--batch}'[batch mode (non-interactive)]'
1313
'*'{-p,--pid}'[processes/threads to monitor]:pid:_pids'
1414
'*'{-u,--user}'[users to monitor]:user:_users'
15+
'*'{-c,--command}'[only show processes whose command line matches the regex]:regex:_command_names -e'
1516
'(-t --time)'{-t,--time}'[add timestamp on each line (implies --batch)]'
1617
'(-q --quiet)'{-q,--quiet}'[suppress column names and headers (implies --batch)]'
1718
'(-k --kilobytes)'{-k,--kilobytes}'[use kilobytes instead of human-friendly units]'

‎doc/iotop.8‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ Threads of the specified process are also monitored.
5959
Monitor only processes belonging to the specified user(s). The argument can be either a username
6060
or a numeric UID. This option can be repeated to monitor multiple users.
6161
.TP
62+
.BR \-c ", " \-\-command " \fIREGEX\fR"
63+
Only show processes whose command line matches the given regular expression. This option can be
64+
repeated to combine several patterns (a process is shown if it matches any of them). Works in both
65+
batch and interactive modes. Invalid patterns are rejected at startup.
66+
.TP
6267
.BR \-t ", " \-\-time
6368
Add a timestamp on each line in batch mode (implies \fB\-\-batch\fR). The timestamp format is HH:MM:SS.
6469
.TP
@@ -183,6 +188,11 @@ Monitor specific process and show only processes (not threads):
183188
Monitor all processes of a specific user:
184189
.B sudo iotop \-u www-data
185190
.TP
191+
Filter by command-line regex:
192+
.B sudo iotop \-c bash
193+
.TP
194+
.B sudo iotop \-c '^sshd' \-c 'nginx.*worker'
195+
.TP
186196
Batch mode with timestamps and quiet output:
187197
.B sudo iotop \-tq \-n 10 > iotop.log
188198
.TP

‎src/main.rs‎

Lines changed: 152 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use argh::FromArgs;
99
use crossterm::event::MouseEventKind;
1010
use crossterm::event::{KeyCode, KeyModifiers};
1111
use nix::unistd::User;
12-
use process::{ProcessList, ProcessSnapshot};
12+
use process::{ProcessInfo, ProcessList, ProcessSnapshot};
13+
use regex::Regex;
1314
use taskstats::{TaskStats, TaskStatsConnection};
1415
use tokio_util::sync::CancellationToken;
1516
use ui::{Event, SortColumn, Tui, UIState};
@@ -54,6 +55,10 @@ struct Args {
5455
#[argh(option, short = 'u')]
5556
user: Vec<String>,
5657

58+
/// regex to match against the command line (can be repeated)
59+
#[argh(option, short = 'c')]
60+
command: Vec<String>,
61+
5762
/// add timestamp on each line (implies --batch)
5863
#[argh(switch, short = 't')]
5964
time: bool,
@@ -85,6 +90,9 @@ async fn main() -> Result<()> {
8590
// Resolve usernames to UIDs
8691
let uids = resolve_users(&args.user)?;
8792

93+
// Compile command-line regex filters (invalid patterns abort early)
94+
let command_regexes = compile_command_regexes(&args.command)?;
95+
8896
// Connect to taskstats
8997
let taskstats_conn = TaskStatsConnection::new()?;
9098
warn_if_taskstats_unreadable(&taskstats_conn);
@@ -93,9 +101,9 @@ async fn main() -> Result<()> {
93101
.with_uids(uids.clone());
94102

95103
if args.batch || args.time || args.quiet || args.json || args.csv {
96-
run_batch_mode(&mut process_list, &args)?;
104+
run_batch_mode(&mut process_list, &args, &command_regexes)?;
97105
} else {
98-
run_interactive_mode(&mut process_list, &args).await?;
106+
run_interactive_mode(&mut process_list, &args, &command_regexes).await?;
99107
}
100108

101109
Ok(())
@@ -167,7 +175,32 @@ fn resolve_users(users: &[String]) -> Result<Vec<u32>> {
167175
Ok(uids)
168176
}
169177

170-
async fn run_interactive_mode(process_list: &mut ProcessList, args: &Args) -> Result<()> {
178+
/// Compile the `-c/--command` regex filters. Returns an error describing the
179+
/// offending pattern so invalid regexes fail fast at startup.
180+
fn compile_command_regexes(patterns: &[String]) -> Result<Vec<Regex>> {
181+
patterns
182+
.iter()
183+
.map(|p| {
184+
Regex::new(p).map_err(|e| anyhow::anyhow!("invalid command regex {:?}: {}", p, e))
185+
})
186+
.collect()
187+
}
188+
189+
/// Filter a process list in place so only processes whose command line matches
190+
/// at least one of the given regexes remain. When `regexes` is empty no
191+
/// filtering is applied. Used by batch and interactive modes alike.
192+
fn filter_by_command(processes: &mut Vec<&ProcessInfo>, regexes: &[Regex]) {
193+
if regexes.is_empty() {
194+
return;
195+
}
196+
processes.retain(|p| regexes.iter().any(|re| re.is_match(p.get_cmdline())));
197+
}
198+
199+
async fn run_interactive_mode(
200+
process_list: &mut ProcessList,
201+
args: &Args,
202+
command_regexes: &[Regex],
203+
) -> Result<()> {
171204
let mut tui = Tui::new()?;
172205
tui.enter()?;
173206

@@ -212,7 +245,13 @@ async fn run_interactive_mode(process_list: &mut ProcessList, args: &Args) -> Re
212245

213246
}
214247
Event::DataUpdate(snapshot) => {
215-
render_snapshot(&mut tui, &snapshot, &mut state, has_delay_acct)?;
248+
render_snapshot(
249+
&mut tui,
250+
&snapshot,
251+
&mut state,
252+
has_delay_acct,
253+
command_regexes,
254+
)?;
216255

217256
// Check iteration limit
218257
if let Some(max_iter) = args.iterations {
@@ -224,7 +263,13 @@ async fn run_interactive_mode(process_list: &mut ProcessList, args: &Args) -> Re
224263
}
225264
Event::Render => {
226265
if let Some(ref snapshot) = current_snapshot {
227-
render_snapshot(&mut tui, snapshot, &mut state, has_delay_acct)?;
266+
render_snapshot(
267+
&mut tui,
268+
snapshot,
269+
&mut state,
270+
has_delay_acct,
271+
command_regexes,
272+
)?;
228273
}
229274
}
230275
Event::Key(key) => match key.code {
@@ -418,12 +463,14 @@ fn render_snapshot(
418463
snapshot: &ProcessSnapshot,
419464
state: &mut UIState,
420465
has_delay_acct: bool,
466+
command_regexes: &[Regex],
421467
) -> Result<()> {
422468
let mut processes: Vec<&process::ProcessInfo> = snapshot.processes.values().collect();
423469

424470
if state.only_active {
425471
processes.retain(|p| p.did_some_io(state.accumulated));
426472
}
473+
filter_by_command(&mut processes, command_regexes);
427474

428475
sort_processes(&mut processes, state);
429476

@@ -515,14 +562,18 @@ fn sort_processes(processes: &mut Vec<&process::ProcessInfo>, state: &UIState) {
515562
/// Batch mode outputs process I/O statistics to stdout in a parseable format.
516563
/// This function gracefully handles broken pipe errors (e.g., when output is
517564
/// piped to `head` or similar utilities) by returning Ok(()) when write errors occur.
518-
fn run_batch_mode(process_list: &mut ProcessList, args: &Args) -> Result<()> {
565+
fn run_batch_mode(
566+
process_list: &mut ProcessList,
567+
args: &Args,
568+
command_regexes: &[Regex],
569+
) -> Result<()> {
519570
// Machine-readable output modes take precedence over the plain-text batch
520571
// output. They share the same refresh loop and iteration/delay semantics.
521572
if args.json {
522-
return run_json_mode(process_list, args);
573+
return run_json_mode(process_list, args, command_regexes);
523574
}
524575
if args.csv {
525-
return run_csv_mode(process_list, args);
576+
return run_csv_mode(process_list, args, command_regexes);
526577
}
527578

528579
use std::io::{self, Write};
@@ -607,6 +658,7 @@ fn run_batch_mode(process_list: &mut ProcessList, args: &Args) -> Result<()> {
607658
if args.only {
608659
processes.retain(|p| p.did_some_io(args.accumulated));
609660
}
661+
filter_by_command(&mut processes, command_regexes);
610662

611663
processes.sort_by(|a, b| {
612664
let stats_a = if args.accumulated {
@@ -786,7 +838,11 @@ fn collect_row(process: &process::ProcessInfo, accumulated: bool, duration: f64)
786838

787839
/// Batch mode with `--json`: emit one JSON object per iteration, containing the
788840
/// iteration timestamp, total/actual disk I/O, and the process list.
789-
fn run_json_mode(process_list: &mut ProcessList, args: &Args) -> Result<()> {
841+
fn run_json_mode(
842+
process_list: &mut ProcessList,
843+
args: &Args,
844+
command_regexes: &[Regex],
845+
) -> Result<()> {
790846
use std::io::{self, Write};
791847
use std::thread;
792848
use std::time::Duration;
@@ -806,6 +862,7 @@ fn run_json_mode(process_list: &mut ProcessList, args: &Args) -> Result<()> {
806862
if args.only {
807863
processes.retain(|p| p.did_some_io(args.accumulated));
808864
}
865+
filter_by_command(&mut processes, command_regexes);
809866
sort_processes_by_io(&mut processes, args);
810867

811868
let rows: Vec<serde_json::Value> = processes
@@ -864,7 +921,11 @@ fn csv_field(value: &str) -> String {
864921
/// Batch mode with `--csv`: emit one CSV row per process per iteration. A header
865922
/// row is printed before the first iteration (unless `--quiet`). With `--time` a
866923
/// leading time column is added. Compatible with `-t`/`-q`.
867-
fn run_csv_mode(process_list: &mut ProcessList, args: &Args) -> Result<()> {
924+
fn run_csv_mode(
925+
process_list: &mut ProcessList,
926+
args: &Args,
927+
command_regexes: &[Regex],
928+
) -> Result<()> {
868929
use std::io::{self, Write};
869930
use std::thread;
870931
use std::time::Duration;
@@ -887,6 +948,7 @@ fn run_csv_mode(process_list: &mut ProcessList, args: &Args) -> Result<()> {
887948
if args.only {
888949
processes.retain(|p| p.did_some_io(args.accumulated));
889950
}
951+
filter_by_command(&mut processes, command_regexes);
890952
sort_processes_by_io(&mut processes, args);
891953

892954
if !args.quiet && !header_printed {
@@ -952,3 +1014,82 @@ fn run_csv_mode(process_list: &mut ProcessList, args: &Args) -> Result<()> {
9521014
}
9531015
Ok(())
9541016
}
1017+
1018+
#[cfg(test)]
1019+
mod tests {
1020+
use super::*;
1021+
1022+
fn process_with_cmdline(pid: i32, cmdline: &str) -> ProcessInfo {
1023+
let mut p = ProcessInfo::new(pid);
1024+
p.cmdline = Some(cmdline.to_string());
1025+
p
1026+
}
1027+
1028+
fn refs(owned: &mut [ProcessInfo]) -> Vec<&ProcessInfo> {
1029+
owned.iter().collect()
1030+
}
1031+
1032+
#[test]
1033+
fn test_compile_command_regexes_valid() {
1034+
let regexes = compile_command_regexes(&["bash".to_string(), "ssh.*".to_string()]).unwrap();
1035+
assert_eq!(regexes.len(), 2);
1036+
}
1037+
1038+
#[test]
1039+
fn test_compile_command_regexes_invalid() {
1040+
assert!(compile_command_regexes(&["[".to_string()]).is_err());
1041+
}
1042+
1043+
#[test]
1044+
fn test_filter_by_command_empty_noop() {
1045+
let mut owned = vec![
1046+
process_with_cmdline(1, "bash"),
1047+
process_with_cmdline(2, "sshd -D"),
1048+
];
1049+
let mut processes = refs(&mut owned);
1050+
filter_by_command(&mut processes, &[]);
1051+
assert_eq!(processes.len(), 2);
1052+
}
1053+
1054+
#[test]
1055+
fn test_filter_by_command_matches_substring() {
1056+
let mut owned = vec![
1057+
process_with_cmdline(1, "/usr/bin/bash --login"),
1058+
process_with_cmdline(2, "sshd: root@pts/0"),
1059+
process_with_cmdline(3, "nginx: worker"),
1060+
];
1061+
let regexes = compile_command_regexes(&["bash".to_string()]).unwrap();
1062+
let mut processes = refs(&mut owned);
1063+
filter_by_command(&mut processes, &regexes);
1064+
assert_eq!(processes.len(), 1);
1065+
assert_eq!(processes[0].pid, 1);
1066+
}
1067+
1068+
#[test]
1069+
fn test_filter_by_command_multiple_regexes_any() {
1070+
let mut owned = vec![
1071+
process_with_cmdline(1, "bash"),
1072+
process_with_cmdline(2, "nginx"),
1073+
process_with_cmdline(3, "sleep 10"),
1074+
];
1075+
let regexes =
1076+
compile_command_regexes(&["bash".to_string(), "nginx".to_string()]).unwrap();
1077+
let mut processes = refs(&mut owned);
1078+
filter_by_command(&mut processes, &regexes);
1079+
let remaining: Vec<i32> = processes.iter().map(|p| p.pid).collect();
1080+
assert_eq!(remaining, vec![1, 2]);
1081+
}
1082+
1083+
#[test]
1084+
fn test_filter_by_command_regex_anchoring() {
1085+
let mut owned = vec![
1086+
process_with_cmdline(1, "bash"),
1087+
process_with_cmdline(2, "notbash"),
1088+
];
1089+
let regexes = compile_command_regexes(&["^bash$".to_string()]).unwrap();
1090+
let mut processes = refs(&mut owned);
1091+
filter_by_command(&mut processes, &regexes);
1092+
assert_eq!(processes.len(), 1);
1093+
assert_eq!(processes[0].pid, 1);
1094+
}
1095+
}

0 commit comments

Comments
 (0)