1use crate::container::Container;
2use crate::killable::{Killable, KillableType};
3#[cfg(target_os = "linux")]
4use crate::linux::find_target_processes;
5#[cfg(target_os = "macos")]
6use crate::macos::find_target_processes;
7#[cfg(target_os = "windows")]
8use crate::windows::find_target_processes;
9use crate::{cli::Mode, signal::KillportSignal};
10use std::io::Error;
11use tokio::runtime::{Builder, Runtime};
12
13pub trait ProcessFinder {
15 fn find_target_processes(&self, port: u16) -> Result<Vec<Box<dyn Killable>>, Error>;
16}
17
18pub trait ContainerOps {
20 fn is_available(&self) -> Result<bool, Error>;
21 fn find_target_containers(&self, port: u16) -> Result<Vec<Container>, Error>;
22}
23
24pub struct NativeProcessFinder;
26
27impl ProcessFinder for NativeProcessFinder {
28 fn find_target_processes(&self, port: u16) -> Result<Vec<Box<dyn Killable>>, Error> {
29 let processes = find_target_processes(port)?;
30 Ok(processes
31 .into_iter()
32 .map(|p| Box::new(p) as Box<dyn Killable>)
33 .collect())
34 }
35}
36
37pub struct RealContainerOps {
39 rt: Runtime,
40}
41
42impl RealContainerOps {
43 pub fn new() -> Result<Self, Error> {
44 Ok(Self {
45 rt: Builder::new_current_thread().enable_all().build()?,
46 })
47 }
48}
49
50impl ContainerOps for RealContainerOps {
51 fn is_available(&self) -> Result<bool, Error> {
52 Container::is_available(&self.rt)
53 }
54
55 fn find_target_containers(&self, port: u16) -> Result<Vec<Container>, Error> {
56 Container::find_target_containers(&self.rt, port)
57 }
58}
59
60pub struct Killport<P: ProcessFinder, D: ContainerOps> {
62 process_finder: P,
63 container_ops: D,
64}
65
66impl Killport<NativeProcessFinder, RealContainerOps> {
67 pub fn with_real_deps() -> Result<Self, Error> {
69 Ok(Self::new(NativeProcessFinder, RealContainerOps::new()?))
70 }
71}
72
73impl<P: ProcessFinder, D: ContainerOps> Killport<P, D> {
74 pub fn new(process_finder: P, container_ops: D) -> Self {
75 Self {
76 process_finder,
77 container_ops,
78 }
79 }
80
81 pub fn find_target_killables(
82 &self,
83 port: u16,
84 mode: Mode,
85 ) -> Result<Vec<Box<dyn Killable>>, Error> {
86 let mut target_killables: Vec<Box<dyn Killable>> = vec![];
87 let containers_available = mode != Mode::Process && self.container_ops.is_available()?;
88
89 let has_containers = if containers_available && mode != Mode::Process {
92 let target_containers = self.container_ops.find_target_containers(port)?;
93 let found = !target_containers.is_empty();
94 for container in target_containers {
95 target_killables.push(Box::new(container));
96 }
97 found
98 } else {
99 false
100 };
101
102 if mode != Mode::Container {
103 if !has_containers {
106 let target_processes = self.process_finder.find_target_processes(port)?;
107 for process in target_processes {
108 target_killables.push(process);
109 }
110 }
111 }
112
113 Ok(target_killables)
114 }
115
116 pub fn kill_service_by_port(
117 &self,
118 port: u16,
119 signal: KillportSignal,
120 mode: Mode,
121 dry_run: bool,
122 ) -> Result<Vec<(KillableType, String)>, Error> {
123 let mut results = Vec::new();
124 let target_killables = self.find_target_killables(port, mode)?;
125
126 for killable in target_killables {
127 let killed = dry_run || killable.kill(signal.clone())?;
128 if killed {
129 results.push((killable.get_type(), killable.get_name()));
130 }
131 }
132
133 Ok(results)
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 #[cfg(unix)]
141 use nix::sys::signal::Signal;
142 use std::cell::RefCell;
143
144 struct MockKillable {
147 kill_result: Result<bool, Error>,
148 killable_type: KillableType,
149 name: String,
150 kill_called: RefCell<bool>,
151 }
152
153 impl MockKillable {
154 fn process(name: &str) -> Self {
155 Self {
156 kill_result: Ok(true),
157 killable_type: KillableType::Process,
158 name: name.to_string(),
159 kill_called: RefCell::new(false),
160 }
161 }
162
163 fn with_kill_result(mut self, result: Result<bool, Error>) -> Self {
164 self.kill_result = result;
165 self
166 }
167 }
168
169 impl Killable for MockKillable {
170 fn kill(&self, _signal: KillportSignal) -> Result<bool, Error> {
171 *self.kill_called.borrow_mut() = true;
172 match &self.kill_result {
173 Ok(v) => Ok(*v),
174 Err(e) => Err(Error::new(e.kind(), e.to_string())),
175 }
176 }
177
178 fn get_type(&self) -> KillableType {
179 self.killable_type.clone()
180 }
181
182 fn get_name(&self) -> String {
183 self.name.clone()
184 }
185 }
186
187 struct FnProcessFinder<F: Fn(u16) -> Result<Vec<Box<dyn Killable>>, Error>> {
188 finder: F,
189 }
190
191 impl<F: Fn(u16) -> Result<Vec<Box<dyn Killable>>, Error>> ProcessFinder for FnProcessFinder<F> {
192 fn find_target_processes(&self, port: u16) -> Result<Vec<Box<dyn Killable>>, Error> {
193 (self.finder)(port)
194 }
195 }
196
197 struct FnContainerOps<
198 P: Fn() -> Result<bool, Error>,
199 C: Fn(u16) -> Result<Vec<Container>, Error>,
200 > {
201 is_present: P,
202 find_containers: C,
203 }
204
205 impl<P: Fn() -> Result<bool, Error>, C: Fn(u16) -> Result<Vec<Container>, Error>> ContainerOps
206 for FnContainerOps<P, C>
207 {
208 fn is_available(&self) -> Result<bool, Error> {
209 (self.is_present)()
210 }
211
212 fn find_target_containers(&self, port: u16) -> Result<Vec<Container>, Error> {
213 (self.find_containers)(port)
214 }
215 }
216
217 #[allow(clippy::type_complexity)]
218 fn no_containers() -> FnContainerOps<
219 impl Fn() -> Result<bool, Error>,
220 impl Fn(u16) -> Result<Vec<Container>, Error>,
221 > {
222 FnContainerOps {
223 is_present: || Ok(false),
224 find_containers: |_| Ok(vec![]),
225 }
226 }
227
228 #[allow(clippy::type_complexity)]
229 fn with_containers(
230 containers: Vec<String>,
231 ) -> FnContainerOps<
232 impl Fn() -> Result<bool, Error>,
233 impl Fn(u16) -> Result<Vec<Container>, Error>,
234 > {
235 FnContainerOps {
236 is_present: || Ok(true),
237 find_containers: move |_| {
238 Ok(containers
239 .iter()
240 .map(|n| Container { name: n.clone() })
241 .collect())
242 },
243 }
244 }
245
246 fn signal() -> KillportSignal {
247 #[cfg(unix)]
248 {
249 KillportSignal(Signal::SIGKILL)
250 }
251 #[cfg(not(unix))]
252 {
253 KillportSignal("SIGKILL".to_string())
254 }
255 }
256
257 #[test]
260 fn test_find_killables_mode_auto_no_containers() {
261 let finder = FnProcessFinder {
262 finder: |_| {
263 let p: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
264 Ok(vec![p])
265 },
266 };
267 let kp = Killport::new(finder, no_containers());
268 let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
269 assert_eq!(results.len(), 1);
270 assert_eq!(results[0].get_type(), KillableType::Process);
271 assert_eq!(results[0].get_name(), "my_app");
272 }
273
274 #[test]
275 fn test_find_killables_containers_found_skips_all_processes() {
276 let finder = FnProcessFinder {
279 finder: |_| {
280 let p: Box<dyn Killable> = Box::new(MockKillable::process("OrbStack Helper"));
281 Ok(vec![p])
282 },
283 };
284 let ct = with_containers(vec!["nginx".to_string()]);
285 let kp = Killport::new(finder, ct);
286 let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
287 assert_eq!(results.len(), 1);
289 assert_eq!(results[0].get_type(), KillableType::Container);
290 assert_eq!(results[0].get_name(), "nginx");
291 }
292
293 #[test]
294 fn test_find_killables_no_containers_keeps_all_processes() {
295 let finder = FnProcessFinder {
298 finder: |_| {
299 let p1: Box<dyn Killable> = Box::new(MockKillable::process("docker-proxy"));
300 let p2: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
301 Ok(vec![p1, p2])
302 },
303 };
304 let ct = with_containers(vec![]); let kp = Killport::new(finder, ct);
306 let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
307 assert_eq!(results.len(), 2);
309 assert_eq!(results[0].get_name(), "docker-proxy");
310 assert_eq!(results[1].get_name(), "my_app");
311 }
312
313 #[test]
314 fn test_find_killables_runtime_absent_returns_all_processes() {
315 let finder = FnProcessFinder {
317 finder: |_| {
318 let p1: Box<dyn Killable> = Box::new(MockKillable::process("docker-proxy"));
319 let p2: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
320 Ok(vec![p1, p2])
321 },
322 };
323 let kp = Killport::new(finder, no_containers());
324 let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
325 assert_eq!(results.len(), 2);
326 assert_eq!(results[0].get_name(), "docker-proxy");
327 assert_eq!(results[1].get_name(), "my_app");
328 }
329
330 #[test]
331 fn test_find_killables_process_mode_skips_containers() {
332 let finder = FnProcessFinder {
334 finder: |_| {
335 let p1: Box<dyn Killable> = Box::new(MockKillable::process("docker-proxy"));
336 let p2: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
337 Ok(vec![p1, p2])
338 },
339 };
340 let ct = FnContainerOps {
341 is_present: || panic!("Container runtime should not be checked in Process mode"),
342 find_containers: |_| panic!("Container runtime should not be checked in Process mode"),
343 };
344 let kp = Killport::new(finder, ct);
345 let results = kp.find_target_killables(8080, Mode::Process).unwrap();
346 assert_eq!(results.len(), 2);
347 assert_eq!(results[0].get_name(), "docker-proxy");
348 assert_eq!(results[1].get_name(), "my_app");
349 }
350
351 #[test]
352 fn test_find_killables_multiple_containers_skips_processes() {
353 let finder = FnProcessFinder {
355 finder: |_| {
356 let p1: Box<dyn Killable> = Box::new(MockKillable::process("docker-proxy"));
357 let p2: Box<dyn Killable> = Box::new(MockKillable::process("dockerd"));
358 Ok(vec![p1, p2])
359 },
360 };
361 let ct = with_containers(vec!["nginx".to_string()]);
362 let kp = Killport::new(finder, ct);
363 let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
364 assert_eq!(results.len(), 1);
365 assert_eq!(results[0].get_type(), KillableType::Container);
366 assert_eq!(results[0].get_name(), "nginx");
367 }
368
369 #[test]
370 fn test_find_killables_mode_process_only() {
371 let finder = FnProcessFinder {
372 finder: |_| {
373 let p: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
374 Ok(vec![p])
375 },
376 };
377 let ct = FnContainerOps {
379 is_present: || panic!("Container runtime should not be checked in Process mode"),
380 find_containers: |_| panic!("Container runtime should not be checked in Process mode"),
381 };
382 let kp = Killport::new(finder, ct);
383 let results = kp.find_target_killables(8080, Mode::Process).unwrap();
384 assert_eq!(results.len(), 1);
385 assert_eq!(results[0].get_type(), KillableType::Process);
386 }
387
388 #[test]
389 fn test_find_killables_mode_container_only() {
390 let finder = FnProcessFinder {
391 finder: |_| panic!("Process finder should not be called in Container mode"),
392 };
393 let ct = with_containers(vec!["redis".to_string()]);
394 let kp = Killport::new(finder, ct);
395 let results = kp.find_target_killables(8080, Mode::Container).unwrap();
396 assert_eq!(results.len(), 1);
397 assert_eq!(results[0].get_type(), KillableType::Container);
398 assert_eq!(results[0].get_name(), "redis");
399 }
400
401 #[test]
402 fn test_find_killables_empty_results() {
403 let finder = FnProcessFinder {
404 finder: |_| Ok(vec![]),
405 };
406 let kp = Killport::new(finder, no_containers());
407 let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
408 assert!(results.is_empty());
409 }
410
411 #[test]
412 fn test_find_killables_process_finder_error() {
413 let finder = FnProcessFinder {
414 finder: |_| {
415 Err(Error::new(
416 std::io::ErrorKind::PermissionDenied,
417 "access denied",
418 ))
419 },
420 };
421 let kp = Killport::new(finder, no_containers());
422 let result = kp.find_target_killables(8080, Mode::Auto);
423 assert!(result.is_err());
424 let err = result.err().unwrap();
425 assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
426 }
427
428 #[test]
429 fn test_find_killables_container_check_error() {
430 let finder = FnProcessFinder {
431 finder: |_| Ok(vec![]),
432 };
433 let ct = FnContainerOps {
434 is_present: || Err(Error::other("container runtime error")),
435 find_containers: |_| Ok(vec![]),
436 };
437 let kp = Killport::new(finder, ct);
438 let result = kp.find_target_killables(8080, Mode::Auto);
439 assert!(result.is_err());
440 }
441
442 #[test]
443 fn test_find_killables_container_find_error() {
444 let finder = FnProcessFinder {
445 finder: |_| Ok(vec![]),
446 };
447 let ct = FnContainerOps {
448 is_present: || Ok(true),
449 find_containers: |_| Err(Error::other("container error")),
450 };
451 let kp = Killport::new(finder, ct);
452 let result = kp.find_target_killables(8080, Mode::Auto);
453 assert!(result.is_err());
454 }
455
456 #[test]
459 fn test_kill_service_actual_kill_succeeds() {
460 let finder = FnProcessFinder {
461 finder: |_| {
462 let p: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
463 Ok(vec![p])
464 },
465 };
466 let kp = Killport::new(finder, no_containers());
467 let results = kp
468 .kill_service_by_port(8080, signal(), Mode::Auto, false)
469 .unwrap();
470 assert_eq!(results.len(), 1);
471 assert_eq!(results[0].0, KillableType::Process);
472 assert_eq!(results[0].1, "my_app");
473 }
474
475 #[test]
476 fn test_kill_service_kill_returns_false() {
477 let finder = FnProcessFinder {
478 finder: |_| {
479 let p: Box<dyn Killable> =
480 Box::new(MockKillable::process("my_app").with_kill_result(Ok(false)));
481 Ok(vec![p])
482 },
483 };
484 let kp = Killport::new(finder, no_containers());
485 let results = kp
486 .kill_service_by_port(8080, signal(), Mode::Auto, false)
487 .unwrap();
488 assert!(
489 results.is_empty(),
490 "Process that returned false should not be in results"
491 );
492 }
493
494 #[test]
495 fn test_kill_service_kill_error_propagates() {
496 let finder =
497 FnProcessFinder {
498 finder: |_| {
499 let p: Box<dyn Killable> =
500 Box::new(MockKillable::process("my_app").with_kill_result(Err(
501 Error::new(std::io::ErrorKind::PermissionDenied, "EPERM"),
502 )));
503 Ok(vec![p])
504 },
505 };
506 let kp = Killport::new(finder, no_containers());
507 let result = kp.kill_service_by_port(8080, signal(), Mode::Auto, false);
508 assert!(result.is_err());
509 }
510
511 #[test]
512 fn test_kill_service_dry_run_collects_without_killing() {
513 let finder = FnProcessFinder {
516 finder: |_| {
517 let p: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
518 Ok(vec![p])
519 },
520 };
521 let kp = Killport::new(finder, no_containers());
522 let results = kp
523 .kill_service_by_port(8080, signal(), Mode::Auto, true)
524 .unwrap();
525 assert_eq!(results.len(), 1);
526 assert_eq!(results[0].0, KillableType::Process);
527 assert_eq!(results[0].1, "my_app");
528 }
529
530 #[test]
531 fn test_kill_service_dry_run_empty() {
532 let finder = FnProcessFinder {
533 finder: |_| Ok(vec![]),
534 };
535 let kp = Killport::new(finder, no_containers());
536 let results = kp
537 .kill_service_by_port(8080, signal(), Mode::Auto, true)
538 .unwrap();
539 assert!(results.is_empty());
540 }
541
542 #[test]
543 fn test_kill_service_multiple_targets_no_containers() {
544 let finder = FnProcessFinder {
546 finder: |_| {
547 let p1: Box<dyn Killable> = Box::new(MockKillable::process("app1"));
548 let p2: Box<dyn Killable> = Box::new(MockKillable::process("app2"));
549 Ok(vec![p1, p2])
550 },
551 };
552 let kp = Killport::new(finder, no_containers());
553 let results = kp
554 .kill_service_by_port(8080, signal(), Mode::Auto, true)
555 .unwrap();
556 assert_eq!(results.len(), 2);
557 assert_eq!(results[0].1, "app1");
558 assert_eq!(results[1].1, "app2");
559 }
560
561 #[test]
562 fn test_kill_service_container_found_skips_processes() {
563 let finder = FnProcessFinder {
565 finder: |_| {
566 let p: Box<dyn Killable> = Box::new(MockKillable::process("OrbStack Helper"));
567 Ok(vec![p])
568 },
569 };
570 let ct = with_containers(vec!["nginx".to_string()]);
571 let kp = Killport::new(finder, ct);
572 let results = kp
573 .kill_service_by_port(8080, signal(), Mode::Auto, true)
574 .unwrap();
575 assert_eq!(results.len(), 1);
576 assert_eq!(results[0].0, KillableType::Container);
577 assert_eq!(results[0].1, "nginx");
578 }
579}