Skip to main content

proc_cli/core/
target.rs

1//! Target resolution - Convert user input to processes
2//!
3//! Targets can be:
4//! - `:port` - Process listening on this port
5//! - `pid` - Process with this PID (numeric)
6//! - `name` - Processes matching this name
7
8use crate::core::port::{parse_port, PortInfo};
9use crate::core::{NameMatch, Process};
10use crate::error::{ProcError, Result};
11
12/// Resolved target type
13#[derive(Debug, Clone)]
14pub enum TargetType {
15    /// Target a process by the port it listens on (e.g., `:3000`)
16    Port(u16),
17    /// Target a process by its process ID (e.g., `1234`)
18    Pid(u32),
19    /// Target processes by name (e.g., `node`): the whole name, or with `--cmd`
20    /// any part of the name or command line
21    Name(String),
22}
23
24/// clap value parser for target arguments: a target that starts with ':' must be
25/// a port from 1 to 65535. Without this, ":70000" fell through to a name search
26/// and reported "not found" instead of a typo.
27pub fn validate_target_arg(s: &str) -> std::result::Result<String, String> {
28    for target in parse_targets(s) {
29        if let Some(port) = target.strip_prefix(':') {
30            if !matches!(port.parse::<u16>(), Ok(p) if p > 0) {
31                return Err(format!(
32                    "'{target}' is not a port; ports go from :1 to :65535"
33                ));
34            }
35        }
36    }
37    Ok(s.to_string())
38}
39
40/// Parse a target string and determine its type
41pub fn parse_target(target: &str) -> TargetType {
42    let target = target.trim();
43
44    // Explicit port prefix
45    if target.starts_with(':') {
46        if let Ok(port) = parse_port(target) {
47            return TargetType::Port(port);
48        }
49    }
50
51    // Pure number - treat as PID
52    if let Ok(pid) = target.parse::<u32>() {
53        return TargetType::Pid(pid);
54    }
55
56    // Otherwise it's a name
57    TargetType::Name(target.to_string())
58}
59
60/// Resolve a target to processes; `mode` says how a name is compared
61pub fn resolve_target(target: &str, mode: NameMatch) -> Result<Vec<Process>> {
62    match parse_target(target) {
63        TargetType::Port(port) => resolve_port(port),
64        TargetType::Pid(pid) => resolve_pid(pid),
65        TargetType::Name(name) => Process::find_by_name(&name, mode),
66    }
67}
68
69/// Resolve a port to every process listening on it
70fn resolve_port(port: u16) -> Result<Vec<Process>> {
71    processes_on_port(port, PortInfo::holders_of(port)?, Process::find_by_pid)
72}
73
74/// The processes behind `listeners`, what was found listening on `port`, in
75/// that order; `find` looks a process up by PID. A holder can end between the
76/// two lookups and is left out, unless that leaves nobody.
77fn processes_on_port(
78    port: u16,
79    listeners: Vec<PortInfo>,
80    mut find: impl FnMut(u32) -> Result<Option<Process>>,
81) -> Result<Vec<Process>> {
82    let Some(first) = listeners.first().map(|l| l.pid) else {
83        return Err(ProcError::PortNotFound(port));
84    };
85    let mut found = Vec::new();
86    for listener in &listeners {
87        if let Some(proc) = find(listener.pid)? {
88            found.push(proc);
89        }
90    }
91    if found.is_empty() {
92        Err(ProcError::ProcessGone(first))
93    } else {
94        Ok(found)
95    }
96}
97
98/// Resolve PID to process
99fn resolve_pid(pid: u32) -> Result<Vec<Process>> {
100    match Process::find_by_pid(pid)? {
101        Some(proc) => Ok(vec![proc]),
102        None => Err(ProcError::ProcessNotFound(pid.to_string())),
103    }
104}
105
106/// Find all ports a process is listening on
107pub fn find_ports_for_pid(pid: u32) -> Result<Vec<PortInfo>> {
108    let all_ports = PortInfo::get_all_listening()?;
109    Ok(all_ports.into_iter().filter(|p| p.pid == pid).collect())
110}
111
112/// Split comma-separated targets into individual target strings
113///
114/// Examples:
115///   ":3000,:8080" -> [":3000", ":8080"]
116///   "node,python" -> ["node", "python"]
117///   ":3000, 1234, node" -> [":3000", "1234", "node"]
118pub fn parse_targets(targets_str: &str) -> Vec<String> {
119    targets_str
120        .split(',')
121        .map(|s| s.trim().to_string())
122        .filter(|s| !s.is_empty())
123        .collect()
124}
125
126/// Split every target argument the user typed into individual targets
127///
128/// A command takes its targets as separate arguments, and a value may still be
129/// a comma-separated list, so each value goes through [`parse_targets`].
130///
131/// Examples:
132///   ["123", ":3000,:8080", "node"] -> ["123", ":3000", ":8080", "node"]
133pub fn flatten_targets(values: &[String]) -> Vec<String> {
134    values.iter().flat_map(|v| parse_targets(v)).collect()
135}
136
137/// The commands whose positional arguments are targets that [`flatten_targets`]
138/// splits on commas, with the clap id of that argument. `by`, `tree`, `in` and
139/// `for` take one value that commas never split, so they are not here.
140const COMMA_SPLIT_ARGS: &[(&str, &str)] = &[
141    ("on", "target"),
142    ("info", "targets"),
143    ("list", "name"),
144    ("kill", "target"),
145    ("stop", "target"),
146    ("watch", "target"),
147    ("unstick", "target"),
148    ("freeze", "target"),
149    ("thaw", "target"),
150    ("why", "target"),
151    ("free", "target"),
152    ("wait", "target"),
153];
154
155/// A word as a shell would need it typed: quoted when it holds anything but
156/// plain characters, so the notice's example can be pasted as it stands.
157fn shell_word(word: &str) -> String {
158    let plain = |c: char| c.is_ascii_alphanumeric() || ":._-/@%+=".contains(c);
159    if !word.is_empty() && word.chars().all(plain) {
160        word.to_string()
161    } else {
162        format!("'{}'", word.replace('\'', "'\\''"))
163    }
164}
165
166/// The deprecation notice for comma-separated targets, if the command line
167/// used any, or `None`.
168///
169/// Only the target arguments count (see `COMMA_SPLIT_ARGS`): a comma in a
170/// `--by` or `--in` value, or in the one value of `by`, `in` or `for`, is just
171/// text. Nothing is returned under `--json`, whose output must not change.
172/// The notice is one string however many commas there are, and its example is
173/// the user's own targets, split and joined by spaces.
174pub fn comma_notice(matches: &clap::ArgMatches) -> Option<String> {
175    let (command, sub) = matches.subcommand()?;
176    let (_, id) = COMMA_SPLIT_ARGS.iter().find(|(name, _)| *name == command)?;
177    if sub.get_flag("json") {
178        return None;
179    }
180    let typed: Vec<String> = sub.get_many::<String>(id)?.cloned().collect();
181    if !typed.iter().any(|value| value.contains(',')) {
182        return None;
183    }
184    let targets: Vec<String> = flatten_targets(&typed)
185        .iter()
186        .map(|target| shell_word(target))
187        .collect();
188    Some(format!(
189        "comma-separated targets are deprecated and will be removed in a future major \
190         release; pass them as separate arguments: proc {command} {}",
191        targets.join(" ")
192    ))
193}
194
195/// What resolving several targets came to
196#[derive(Debug, Default)]
197pub struct Resolved {
198    /// The processes found, each once however many targets name it
199    pub processes: Vec<Process>,
200    /// The targets that matched nothing
201    pub not_found: Vec<String>,
202    /// For a name that matched nothing, but is in some command lines: what
203    /// `--cmd` would find
204    pub hints: Vec<String>,
205}
206
207impl Resolved {
208    /// The warning for the targets that matched nothing, if there were any
209    pub fn warning(&self) -> Option<String> {
210        if self.not_found.is_empty() {
211            return None;
212        }
213        let mut warning = format!("Not found: {}", self.not_found.join(", "));
214        for hint in &self.hints {
215            warning.push_str("\n  ");
216            warning.push_str(hint);
217        }
218        Some(warning)
219    }
220}
221
222/// Resolve multiple targets, deduplicating by PID
223pub fn resolve_targets(targets: &[String], mode: NameMatch) -> Resolved {
224    resolve_targets_impl(targets, false, mode)
225}
226
227/// Resolve multiple targets, excluding the current process (self)
228///
229/// Use this for destructive commands (kill, stop) to avoid proc killing itself
230/// when the target pattern matches proc's own command line arguments.
231pub fn resolve_targets_excluding_self(targets: &[String], mode: NameMatch) -> Resolved {
232    resolve_targets_impl(targets, true, mode)
233}
234
235fn resolve_targets_impl(targets: &[String], exclude_self: bool, mode: NameMatch) -> Resolved {
236    use std::collections::HashSet;
237
238    let mut resolved = Resolved::default();
239    let mut seen_pids = HashSet::new();
240    let self_pid = std::process::id();
241
242    for target in targets {
243        match resolve_target(target, mode) {
244            Ok(processes) => {
245                for proc in processes {
246                    // Skip self if requested (for destructive commands)
247                    if exclude_self && proc.pid == self_pid {
248                        continue;
249                    }
250                    if seen_pids.insert(proc.pid) {
251                        resolved.processes.push(proc);
252                    }
253                }
254            }
255            Err(error) => {
256                resolved.not_found.push(target.clone());
257                resolved.hints.extend(error.command_line_hint());
258            }
259        }
260    }
261
262    resolved
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_parse_targets_single() {
271        assert_eq!(parse_targets(":3000"), vec![":3000"]);
272        assert_eq!(parse_targets("node"), vec!["node"]);
273        assert_eq!(parse_targets("1234"), vec!["1234"]);
274    }
275
276    #[test]
277    fn test_parse_targets_multiple() {
278        assert_eq!(parse_targets(":3000,:8080"), vec![":3000", ":8080"]);
279        assert_eq!(parse_targets("node,python"), vec!["node", "python"]);
280        assert_eq!(
281            parse_targets(":3000,1234,node"),
282            vec![":3000", "1234", "node"]
283        );
284    }
285
286    #[test]
287    fn test_parse_targets_with_whitespace() {
288        assert_eq!(
289            parse_targets(":3000, :8080, :9000"),
290            vec![":3000", ":8080", ":9000"]
291        );
292        assert_eq!(parse_targets(" node , python "), vec!["node", "python"]);
293    }
294
295    #[test]
296    fn separate_arguments_and_comma_lists_flatten_to_one_list_in_order() {
297        let typed = |values: &[&str]| -> Vec<String> {
298            flatten_targets(&values.iter().map(|v| v.to_string()).collect::<Vec<_>>())
299        };
300        assert_eq!(typed(&["123", "456"]), vec!["123", "456"]);
301        assert_eq!(
302            typed(&["123", ":3000,:8080", "node"]),
303            vec!["123", ":3000", ":8080", "node"]
304        );
305        assert_eq!(typed(&["a,b", ",", " c "]), vec!["a", "b", "c"]);
306        // a repeated target stays repeated: the PID dedupe happens when resolving
307        assert_eq!(typed(&["1", "1,1"]), vec!["1", "1", "1"]);
308        assert!(typed(&[]).is_empty());
309    }
310
311    #[test]
312    fn the_commands_that_split_on_commas_are_the_ones_with_a_target_list() {
313        use clap::CommandFactory;
314        let cli = crate::cli::Cli::command();
315        for sub in cli.get_subcommands() {
316            let name = sub.get_name();
317            let listed = COMMA_SPLIT_ARGS.iter().find(|(n, _)| *n == name);
318            // a list of targets: a positional that takes any number of values
319            let lists: Vec<&str> = sub
320                .get_positionals()
321                .filter(|a| a.get_num_args().is_some_and(|n| n.max_values() > 1))
322                .map(|a| a.get_id().as_str())
323                .collect();
324            match listed {
325                Some((_, id)) => assert_eq!(lists, vec![*id], "{name}"),
326                None => assert!(lists.is_empty(), "{name} takes a list but isn't listed"),
327            }
328        }
329    }
330
331    #[test]
332    fn shell_words_are_quoted_only_when_they_need_to_be() {
333        assert_eq!(shell_word(":3000"), ":3000");
334        assert_eq!(shell_word("node"), "node");
335        assert_eq!(shell_word("my app"), "'my app'");
336        assert_eq!(shell_word("it's"), r"'it'\''s'");
337        assert_eq!(shell_word(""), "''");
338    }
339
340    #[test]
341    fn test_parse_targets_empty_entries() {
342        assert_eq!(parse_targets(":3000,,,:8080"), vec![":3000", ":8080"]);
343        assert_eq!(parse_targets(",,node,,"), vec!["node"]);
344    }
345
346    #[test]
347    fn test_parse_target_port() {
348        assert!(matches!(parse_target(":3000"), TargetType::Port(3000)));
349        assert!(matches!(parse_target(":8080"), TargetType::Port(8080)));
350    }
351
352    #[test]
353    fn test_parse_target_pid() {
354        assert!(matches!(parse_target("1234"), TargetType::Pid(1234)));
355        assert!(matches!(parse_target("99999"), TargetType::Pid(99999)));
356    }
357
358    #[test]
359    fn a_colon_target_that_is_not_a_port_is_a_name() {
360        for target in [":abc", ":70000", ":"] {
361            assert!(
362                matches!(parse_target(target), TargetType::Name(n) if n == target),
363                "{target}"
364            );
365        }
366    }
367
368    #[test]
369    fn test_parse_target_name() {
370        assert!(matches!(parse_target("node"), TargetType::Name(_)));
371        assert!(matches!(parse_target("my-process"), TargetType::Name(_)));
372    }
373
374    #[test]
375    fn only_a_colon_target_has_to_be_a_port() {
376        // names and PIDs are left alone, alone or in a list with ports
377        assert_eq!(validate_target_arg("node").unwrap(), "node");
378        assert_eq!(validate_target_arg("1234").unwrap(), "1234");
379        assert_eq!(validate_target_arg(":3000, node").unwrap(), ":3000, node");
380        for bad in [":70000", ":0", ":abc", "node,:70000"] {
381            let err = validate_target_arg(bad).unwrap_err();
382            assert!(err.contains("is not a port"), "{bad}: {err}");
383        }
384    }
385
386    fn listener(pid: u32) -> PortInfo {
387        PortInfo {
388            port: 4000,
389            protocol: crate::core::Protocol::Tcp,
390            pid,
391            process_name: "x".to_string(),
392            address: None,
393        }
394    }
395
396    fn own_process() -> Process {
397        Process::find_by_pid(std::process::id()).unwrap().unwrap()
398    }
399
400    #[test]
401    fn a_port_resolves_to_the_process_listening_on_it() {
402        let found = processes_on_port(4000, vec![listener(7)], |pid| {
403            assert_eq!(pid, 7);
404            Ok(Some(own_process()))
405        })
406        .unwrap();
407        assert_eq!(found.len(), 1);
408
409        let none = processes_on_port(4000, Vec::new(), |_| panic!("no listener, no lookup"));
410        assert!(
411            matches!(none, Err(ProcError::PortNotFound(4000))),
412            "{none:?}"
413        );
414
415        // the process ended after the port was listed
416        let gone = processes_on_port(4000, vec![listener(7)], |_| Ok(None));
417        assert!(matches!(gone, Err(ProcError::ProcessGone(7))), "{gone:?}");
418
419        let failed = processes_on_port(4000, vec![listener(7)], |_| {
420            Err(ProcError::SystemError("no".to_string()))
421        });
422        assert!(
423            matches!(failed, Err(ProcError::SystemError(_))),
424            "{failed:?}"
425        );
426    }
427
428    #[test]
429    fn a_port_resolves_to_every_process_listening_on_it() {
430        let mut asked = Vec::new();
431        let found = processes_on_port(4000, vec![listener(7), listener(8), listener(9)], |pid| {
432            asked.push(pid);
433            // 8 ended after the port was listed; the others are there
434            Ok((pid != 8).then(own_process))
435        })
436        .unwrap();
437        assert_eq!(asked, vec![7, 8, 9]);
438        assert_eq!(found.len(), 2, "the holder that ended is left out");
439
440        // every holder gone: the first one is named
441        let gone = processes_on_port(4000, vec![listener(7), listener(8)], |_| Ok(None));
442        assert!(matches!(gone, Err(ProcError::ProcessGone(7))), "{gone:?}");
443    }
444
445    #[test]
446    fn targets_resolve_once_each_and_never_to_ourselves_when_asked() {
447        let me = std::process::id().to_string();
448        let targets = vec![me.clone(), me.clone(), "99999999".to_string()];
449
450        // listed twice, found once; the made-up PID is not found
451        let resolved = resolve_targets(&targets, NameMatch::Exact);
452        assert_eq!(resolved.processes.len(), 1);
453        assert_eq!(resolved.processes[0].pid, std::process::id());
454        assert_eq!(resolved.not_found, vec!["99999999".to_string()]);
455        assert_eq!(
456            resolved.warning().unwrap(),
457            "Not found: 99999999",
458            "a PID has no --cmd hint"
459        );
460
461        let resolved = resolve_targets_excluding_self(&targets, NameMatch::Exact);
462        assert!(resolved.processes.is_empty());
463        assert_eq!(resolved.not_found, vec!["99999999".to_string()]);
464    }
465
466    #[test]
467    fn nothing_missing_means_no_warning() {
468        let me = std::process::id().to_string();
469        let resolved = resolve_targets(&[me], NameMatch::Exact);
470        assert!(resolved.not_found.is_empty());
471        assert_eq!(resolved.warning(), None);
472    }
473}