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 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
40pub fn parse_target(target: &str) -> TargetType {
42 let target = target.trim();
43
44 if target.starts_with(':') {
46 if let Ok(port) = parse_port(target) {
47 return TargetType::Port(port);
48 }
49 }
50
51 if let Ok(pid) = target.parse::<u32>() {
53 return TargetType::Pid(pid);
54 }
55
56 TargetType::Name(target.to_string())
58}
59
60pub 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
69fn resolve_port(port: u16) -> Result<Vec<Process>> {
71 processes_on_port(port, PortInfo::holders_of(port)?, Process::find_by_pid)
72}
73
74fn 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
98fn 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
106pub 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
112pub 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
126pub fn flatten_targets(values: &[String]) -> Vec<String> {
134 values.iter().flat_map(|v| parse_targets(v)).collect()
135}
136
137const 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
155fn 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
166pub 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#[derive(Debug, Default)]
197pub struct Resolved {
198 pub processes: Vec<Process>,
200 pub not_found: Vec<String>,
202 pub hints: Vec<String>,
205}
206
207impl Resolved {
208 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
222pub fn resolve_targets(targets: &[String], mode: NameMatch) -> Resolved {
224 resolve_targets_impl(targets, false, mode)
225}
226
227pub 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 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 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 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 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 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 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 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 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}