1use crate::core::port::{parse_port, PortInfo};
9use crate::core::{NameMatch, Process};
10use crate::error::{ProcError, Result};
11
12#[derive(Debug, Clone)]
14pub enum TargetType {
15 Port(u16),
17 Pid(u32),
19 Name(String),
22}
23
24pub fn validate_target_arg(s: &str) -> std::result::Result<String, String> {
28 let targets = parse_targets(s);
29 if targets.is_empty() {
31 return Err("a target can't be empty: give a PID, :port or name".to_string());
32 }
33 for target in targets {
34 if let Some(port) = target.strip_prefix(':') {
35 if !matches!(port.parse::<u16>(), Ok(p) if p > 0) {
36 return Err(format!(
37 "'{target}' is not a port; ports go from :1 to :65535"
38 ));
39 }
40 }
41 }
42 Ok(s.to_string())
43}
44
45pub fn parse_target(target: &str) -> TargetType {
47 let target = target.trim();
48
49 if target.starts_with(':') {
51 if let Ok(port) = parse_port(target) {
52 return TargetType::Port(port);
53 }
54 }
55
56 if let Ok(pid) = target.parse::<u32>() {
58 return TargetType::Pid(pid);
59 }
60
61 TargetType::Name(target.to_string())
63}
64
65pub fn resolve_target(target: &str, mode: NameMatch) -> Result<Vec<Process>> {
67 match parse_target(target) {
68 TargetType::Port(port) => resolve_port(port),
69 TargetType::Pid(pid) => resolve_pid(pid),
70 TargetType::Name(name) => Process::find_by_name(&name, mode),
71 }
72}
73
74fn resolve_port(port: u16) -> Result<Vec<Process>> {
76 processes_on_port(port, PortInfo::holders_of(port)?, Process::find_by_pid)
77}
78
79fn processes_on_port(
83 port: u16,
84 listeners: Vec<PortInfo>,
85 mut find: impl FnMut(u32) -> Result<Option<Process>>,
86) -> Result<Vec<Process>> {
87 let Some(first) = listeners.first().map(|l| l.pid) else {
88 return Err(ProcError::PortNotFound(port));
89 };
90 let mut found = Vec::new();
91 for listener in &listeners {
92 if let Some(proc) = find(listener.pid)? {
93 found.push(proc);
94 }
95 }
96 if found.is_empty() {
97 Err(ProcError::ProcessGone(first))
98 } else {
99 Ok(found)
100 }
101}
102
103fn resolve_pid(pid: u32) -> Result<Vec<Process>> {
105 match Process::find_by_pid(pid)? {
106 Some(proc) => Ok(vec![proc]),
107 None => Err(ProcError::ProcessNotFound(pid.to_string())),
108 }
109}
110
111pub fn find_ports_for_pid(pid: u32) -> Result<Vec<PortInfo>> {
113 let all_ports = PortInfo::get_all_listening()?;
114 Ok(all_ports.into_iter().filter(|p| p.pid == pid).collect())
115}
116
117pub fn with_ports(processes: Vec<Process>) -> Result<Vec<(Process, Vec<PortInfo>)>> {
122 let mut listening = None;
123 let mut results = Vec::with_capacity(processes.len());
124 for proc in processes {
125 let all = match &mut listening {
126 Some(all) => all,
127 unread => unread.insert(PortInfo::get_all_listening()?),
128 };
129 let ports = all.iter().filter(|p| p.pid == proc.pid).cloned().collect();
130 results.push((proc, ports));
131 }
132 Ok(results)
133}
134
135pub fn parse_targets(targets_str: &str) -> Vec<String> {
142 targets_str
143 .split(',')
144 .map(|s| s.trim().to_string())
145 .filter(|s| !s.is_empty())
146 .collect()
147}
148
149pub fn flatten_targets(values: &[String]) -> Vec<String> {
157 values.iter().flat_map(|v| parse_targets(v)).collect()
158}
159
160const COMMA_SPLIT_ARGS: &[(&str, &str)] = &[
164 ("on", "target"),
165 ("info", "targets"),
166 ("list", "name"),
167 ("kill", "target"),
168 ("stop", "target"),
169 ("watch", "target"),
170 ("unstick", "target"),
171 ("freeze", "target"),
172 ("thaw", "target"),
173 ("why", "target"),
174 ("free", "target"),
175 ("wait", "target"),
176];
177
178fn shell_word(word: &str) -> String {
181 let plain = |c: char| c.is_ascii_alphanumeric() || ":._-/@%+=".contains(c);
182 if !word.is_empty() && word.chars().all(plain) {
183 word.to_string()
184 } else {
185 format!("'{}'", word.replace('\'', "'\\''"))
186 }
187}
188
189pub fn comma_notice(matches: &clap::ArgMatches) -> Option<String> {
198 let (command, sub) = matches.subcommand()?;
199 let (_, id) = COMMA_SPLIT_ARGS.iter().find(|(name, _)| *name == command)?;
200 if sub.get_flag("json") {
201 return None;
202 }
203 let typed: Vec<String> = sub.get_many::<String>(id)?.cloned().collect();
204 if !typed.iter().any(|value| value.contains(',')) {
205 return None;
206 }
207 let targets: Vec<String> = flatten_targets(&typed)
208 .iter()
209 .map(|target| shell_word(target))
210 .collect();
211 Some(format!(
212 "comma-separated targets are deprecated and will be removed in a future major \
213 release; pass them as separate arguments: proc {command} {}",
214 targets.join(" ")
215 ))
216}
217
218#[derive(Debug, Default)]
220pub struct Resolved {
221 pub processes: Vec<Process>,
223 pub not_found: Vec<String>,
225 pub hints: Vec<String>,
228}
229
230impl Resolved {
231 pub fn warning(&self) -> Option<String> {
233 if self.not_found.is_empty() {
234 return None;
235 }
236 let mut warning = format!("Not found: {}", self.not_found.join(", "));
237 for hint in &self.hints {
238 warning.push_str("\n ");
239 warning.push_str(hint);
240 }
241 Some(warning)
242 }
243}
244
245pub fn resolve_targets(targets: &[String], mode: NameMatch) -> Resolved {
247 resolve_targets_impl(targets, false, mode)
248}
249
250pub fn resolve_targets_excluding_self(targets: &[String], mode: NameMatch) -> Resolved {
255 resolve_targets_impl(targets, true, mode)
256}
257
258fn resolve_targets_impl(targets: &[String], exclude_self: bool, mode: NameMatch) -> Resolved {
259 use std::collections::HashSet;
260
261 let mut resolved = Resolved::default();
262 let mut seen_pids = HashSet::new();
263 let self_pid = std::process::id();
264
265 for target in targets {
266 match resolve_target(target, mode) {
267 Ok(processes) => {
268 for proc in processes {
269 if exclude_self && proc.pid == self_pid {
271 continue;
272 }
273 if seen_pids.insert(proc.pid) {
274 resolved.processes.push(proc);
275 }
276 }
277 }
278 Err(error) => {
279 resolved.not_found.push(target.clone());
280 resolved.hints.extend(error.command_line_hint());
281 }
282 }
283 }
284
285 resolved
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn with_ports_gives_each_process_its_own_ports() {
294 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
295 let port = listener.local_addr().unwrap().port();
296 let me = Process::find_by_pid(std::process::id()).unwrap().unwrap();
297 let mut child = crate::test_support::idle_child();
298 let other = crate::test_support::record_of(&child);
299 let results = with_ports(vec![me, other]).unwrap();
301 crate::test_support::end(&mut child);
302 assert!(results[0].1.iter().any(|p| p.port == port), "{results:?}");
303 assert!(results[1].1.is_empty(), "{results:?}");
304 }
305
306 #[test]
307 fn test_parse_targets_single() {
308 assert_eq!(parse_targets(":3000"), vec![":3000"]);
309 assert_eq!(parse_targets("node"), vec!["node"]);
310 assert_eq!(parse_targets("1234"), vec!["1234"]);
311 }
312
313 #[test]
314 fn test_parse_targets_multiple() {
315 assert_eq!(parse_targets(":3000,:8080"), vec![":3000", ":8080"]);
316 assert_eq!(parse_targets("node,python"), vec!["node", "python"]);
317 assert_eq!(
318 parse_targets(":3000,1234,node"),
319 vec![":3000", "1234", "node"]
320 );
321 }
322
323 #[test]
324 fn test_parse_targets_with_whitespace() {
325 assert_eq!(
326 parse_targets(":3000, :8080, :9000"),
327 vec![":3000", ":8080", ":9000"]
328 );
329 assert_eq!(parse_targets(" node , python "), vec!["node", "python"]);
330 }
331
332 #[test]
333 fn separate_arguments_and_comma_lists_flatten_to_one_list_in_order() {
334 let typed = |values: &[&str]| -> Vec<String> {
335 flatten_targets(&values.iter().map(|v| v.to_string()).collect::<Vec<_>>())
336 };
337 assert_eq!(typed(&["123", "456"]), vec!["123", "456"]);
338 assert_eq!(
339 typed(&["123", ":3000,:8080", "node"]),
340 vec!["123", ":3000", ":8080", "node"]
341 );
342 assert_eq!(typed(&["a,b", ",", " c "]), vec!["a", "b", "c"]);
343 assert_eq!(typed(&["1", "1,1"]), vec!["1", "1", "1"]);
345 assert!(typed(&[]).is_empty());
346 }
347
348 #[test]
349 fn the_commands_that_split_on_commas_are_the_ones_with_a_target_list() {
350 use clap::CommandFactory;
351 let cli = crate::cli::Cli::command();
352 for sub in cli.get_subcommands() {
353 let name = sub.get_name();
354 let listed = COMMA_SPLIT_ARGS.iter().find(|(n, _)| *n == name);
355 let lists: Vec<&str> = sub
357 .get_positionals()
358 .filter(|a| a.get_num_args().is_some_and(|n| n.max_values() > 1))
359 .map(|a| a.get_id().as_str())
360 .collect();
361 match listed {
362 Some((_, id)) => assert_eq!(lists, vec![*id], "{name}"),
363 None => assert!(lists.is_empty(), "{name} takes a list but isn't listed"),
364 }
365 }
366 }
367
368 #[test]
369 fn shell_words_are_quoted_only_when_they_need_to_be() {
370 assert_eq!(shell_word(":3000"), ":3000");
371 assert_eq!(shell_word("node"), "node");
372 assert_eq!(shell_word("my app"), "'my app'");
373 assert_eq!(shell_word("it's"), r"'it'\''s'");
374 assert_eq!(shell_word(""), "''");
375 }
376
377 #[test]
378 fn test_parse_targets_empty_entries() {
379 assert_eq!(parse_targets(":3000,,,:8080"), vec![":3000", ":8080"]);
380 assert_eq!(parse_targets(",,node,,"), vec!["node"]);
381 }
382
383 #[test]
384 fn test_parse_target_port() {
385 assert!(matches!(parse_target(":3000"), TargetType::Port(3000)));
386 assert!(matches!(parse_target(":8080"), TargetType::Port(8080)));
387 }
388
389 #[test]
390 fn test_parse_target_pid() {
391 assert!(matches!(parse_target("1234"), TargetType::Pid(1234)));
392 assert!(matches!(parse_target("99999"), TargetType::Pid(99999)));
393 }
394
395 #[test]
396 fn a_colon_target_that_is_not_a_port_is_a_name() {
397 for target in [":abc", ":70000", ":"] {
398 assert!(
399 matches!(parse_target(target), TargetType::Name(n) if n == target),
400 "{target}"
401 );
402 }
403 }
404
405 #[test]
406 fn test_parse_target_name() {
407 assert!(matches!(parse_target("node"), TargetType::Name(_)));
408 assert!(matches!(parse_target("my-process"), TargetType::Name(_)));
409 }
410
411 #[test]
412 fn only_a_colon_target_has_to_be_a_port() {
413 assert_eq!(validate_target_arg("node").unwrap(), "node");
415 assert_eq!(validate_target_arg("1234").unwrap(), "1234");
416 assert_eq!(validate_target_arg(":3000, node").unwrap(), ":3000, node");
417 for bad in [":70000", ":0", ":abc", "node,:70000"] {
418 let err = validate_target_arg(bad).unwrap_err();
419 assert!(err.contains("is not a port"), "{bad}: {err}");
420 }
421 }
422
423 fn listener(pid: u32) -> PortInfo {
424 PortInfo {
425 port: 4000,
426 protocol: crate::core::Protocol::Tcp,
427 pid,
428 process_name: "x".to_string(),
429 address: None,
430 }
431 }
432
433 fn own_process() -> Process {
434 Process::find_by_pid(std::process::id()).unwrap().unwrap()
435 }
436
437 #[test]
438 fn a_port_resolves_to_the_process_listening_on_it() {
439 let found = processes_on_port(4000, vec![listener(7)], |pid| {
440 assert_eq!(pid, 7);
441 Ok(Some(own_process()))
442 })
443 .unwrap();
444 assert_eq!(found.len(), 1);
445
446 let none = processes_on_port(4000, Vec::new(), |_| panic!("no listener, no lookup"));
447 assert!(
448 matches!(none, Err(ProcError::PortNotFound(4000))),
449 "{none:?}"
450 );
451
452 let gone = processes_on_port(4000, vec![listener(7)], |_| Ok(None));
454 assert!(matches!(gone, Err(ProcError::ProcessGone(7))), "{gone:?}");
455
456 let failed = processes_on_port(4000, vec![listener(7)], |_| {
457 Err(ProcError::SystemError("no".to_string()))
458 });
459 assert!(
460 matches!(failed, Err(ProcError::SystemError(_))),
461 "{failed:?}"
462 );
463 }
464
465 #[test]
466 fn a_port_resolves_to_every_process_listening_on_it() {
467 let mut asked = Vec::new();
468 let found = processes_on_port(4000, vec![listener(7), listener(8), listener(9)], |pid| {
469 asked.push(pid);
470 Ok((pid != 8).then(own_process))
472 })
473 .unwrap();
474 assert_eq!(asked, vec![7, 8, 9]);
475 assert_eq!(found.len(), 2, "the holder that ended is left out");
476
477 let gone = processes_on_port(4000, vec![listener(7), listener(8)], |_| Ok(None));
479 assert!(matches!(gone, Err(ProcError::ProcessGone(7))), "{gone:?}");
480 }
481
482 #[test]
483 fn targets_resolve_once_each_and_never_to_ourselves_when_asked() {
484 let me = std::process::id().to_string();
485 let targets = vec![me.clone(), me.clone(), "99999999".to_string()];
486
487 let resolved = resolve_targets(&targets, NameMatch::Exact);
489 assert_eq!(resolved.processes.len(), 1);
490 assert_eq!(resolved.processes[0].pid, std::process::id());
491 assert_eq!(resolved.not_found, vec!["99999999".to_string()]);
492 assert_eq!(
493 resolved.warning().unwrap(),
494 "Not found: 99999999",
495 "a PID has no --cmd hint"
496 );
497
498 let resolved = resolve_targets_excluding_self(&targets, NameMatch::Exact);
499 assert!(resolved.processes.is_empty());
500 assert_eq!(resolved.not_found, vec!["99999999".to_string()]);
501 }
502
503 #[test]
504 fn nothing_missing_means_no_warning() {
505 let me = std::process::id().to_string();
506 let resolved = resolve_targets(&[me], NameMatch::Exact);
507 assert!(resolved.not_found.is_empty());
508 assert_eq!(resolved.warning(), None);
509 }
510}