Skip to main content

prettyping_rs/
lib.rs

1use std::net::IpAddr;
2use std::time::Duration;
3
4use clap::error::ErrorKind;
5
6pub mod app;
7pub mod cli;
8pub mod config;
9pub mod engine;
10pub mod net;
11pub mod render;
12pub mod ring_buffer;
13pub mod runtime;
14pub mod stats;
15
16#[cfg(target_os = "windows")]
17pub mod windows_console;
18
19const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
20const DEFAULT_TIMEOUT: Duration = Duration::from_secs(1);
21const DEFAULT_PAYLOAD_SIZE: usize = 56;
22
23pub fn run() -> Result<(), clap::Error> {
24    let config = cli::parse_config_from_env()?;
25    let target = resolve_target(&config)?;
26    let app_config = map_runtime_config(&config, target)?;
27
28    #[cfg(any(target_os = "linux", target_os = "macos"))]
29    {
30        run_with_unix_backend(&config, app_config)
31    }
32
33    #[cfg(target_os = "windows")]
34    {
35        run_with_windows_backend(&config, app_config)
36    }
37
38    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
39    {
40        let _ = app_config;
41        Err(runtime_error(
42            "runtime backend is not supported on this platform",
43        ))
44    }
45}
46
47#[cfg(test)]
48#[must_use]
49fn selected_runtime_backend() -> &'static str {
50    #[cfg(any(target_os = "linux", target_os = "macos"))]
51    {
52        return "unix_surge";
53    }
54
55    #[cfg(target_os = "windows")]
56    {
57        return "windows_ping_async";
58    }
59
60    #[allow(unreachable_code)]
61    "unsupported"
62}
63
64fn resolve_target(config: &config::Config) -> Result<IpAddr, clap::Error> {
65    let resolved = net::dns::resolve_once(&config.host, config.family).map_err(|err| {
66        runtime_error(format!("failed to resolve target '{}': {err}", config.host))
67    })?;
68
69    resolved
70        .addresses
71        .first()
72        .copied()
73        .ok_or_else(|| runtime_error(format!("no resolved addresses found for '{}'", config.host)))
74}
75
76fn map_runtime_config(
77    config: &config::Config,
78    target: IpAddr,
79) -> Result<app::AppConfig, clap::Error> {
80    let interval =
81        option_secs_to_duration(config.interval_secs, DEFAULT_INTERVAL, "-i/--interval")?;
82    let timeout = option_secs_to_duration(config.timeout_secs, DEFAULT_TIMEOUT, "-W/--timeout")?;
83
84    let payload_size = match config.packet_size {
85        Some(packet_size) => usize::try_from(packet_size)
86            .map_err(|_| runtime_error("-s/--size is too large for this platform pointer width"))?,
87        None => DEFAULT_PAYLOAD_SIZE,
88    };
89
90    let ttl = match config.ttl {
91        Some(ttl) => Some(
92            u8::try_from(ttl).map_err(|_| runtime_error("-t/--ttl must be between 1 and 255"))?,
93        ),
94        None => None,
95    };
96
97    Ok(app::AppConfig {
98        target,
99        interval,
100        timeout,
101        count: config.count.map(u64::from),
102        payload_size,
103        ttl,
104    })
105}
106
107fn option_secs_to_duration(
108    configured: Option<f64>,
109    fallback: Duration,
110    flag_name: &str,
111) -> Result<Duration, clap::Error> {
112    let Some(seconds) = configured else {
113        return Ok(fallback);
114    };
115
116    if !seconds.is_finite() || seconds <= 0.0 {
117        return Err(runtime_error(format!(
118            "{flag_name} must be a finite value greater than 0"
119        )));
120    }
121
122    Duration::try_from_secs_f64(seconds)
123        .map_err(|_| runtime_error(format!("{flag_name} value is out of range")))
124}
125
126fn runtime_error(message: impl Into<String>) -> clap::Error {
127    clap::Error::raw(ErrorKind::Io, message.into())
128}
129
130#[cfg(any(target_os = "linux", target_os = "macos"))]
131fn run_with_unix_backend(
132    config: &config::Config,
133    app_config: app::AppConfig,
134) -> Result<(), clap::Error> {
135    let mut engine =
136        engine::unix_surge::UnixSurgeEngine::new(engine::unix_surge::UnixSurgeEngineOptions {
137            target: app_config.target,
138            timeout: app_config.timeout,
139            ttl: app_config.ttl,
140        })
141        .map_err(|err| runtime_error(err.to_string()))?;
142
143    runtime::run_with_runtime(&mut engine, &app_config, config)
144        .map(|_| ())
145        .map_err(|err| runtime_error(format!("ping runtime failed: {err}")))
146}
147
148#[cfg(target_os = "windows")]
149fn run_with_windows_backend(
150    config: &config::Config,
151    app_config: app::AppConfig,
152) -> Result<(), clap::Error> {
153    let mut engine = engine::windows_ping_async::WindowsPingAsyncEngine::new(
154        engine::windows_ping_async::WindowsPingAsyncEngineOptions {
155            target: app_config.target,
156            timeout: app_config.timeout,
157            ttl: app_config.ttl,
158        },
159    )
160    .map_err(|err| runtime_error(err.to_string()))?;
161
162    runtime::run_with_runtime(&mut engine, &app_config, config)
163        .map(|_| ())
164        .map_err(|err| runtime_error(format!("ping runtime failed: {err}")))
165}
166
167#[cfg(test)]
168mod tests {
169    use std::net::{IpAddr, Ipv4Addr};
170    use std::time::Duration;
171
172    use crate::cli::parse_config_from_args;
173
174    use super::map_runtime_config;
175
176    #[test]
177    fn maps_native_ping_flags_into_runtime_contract() {
178        let config = parse_config_from_args([
179            "prettyping",
180            "-c",
181            "7",
182            "-i",
183            "0.25",
184            "-W",
185            "2.5",
186            "-s",
187            "80",
188            "-t",
189            "64",
190            "example.com",
191        ])
192        .expect("cli parsing should pass");
193
194        let runtime = map_runtime_config(&config, IpAddr::V4(Ipv4Addr::LOCALHOST))
195            .expect("runtime mapping should pass");
196
197        assert_eq!(runtime.count, Some(7));
198        assert_eq!(runtime.interval, Duration::from_millis(250));
199        assert_eq!(runtime.timeout, Duration::from_millis(2_500));
200        assert_eq!(runtime.payload_size, 80);
201        assert_eq!(runtime.ttl, Some(64));
202    }
203
204    #[test]
205    fn uses_runtime_defaults_when_native_ping_flags_are_missing() {
206        let config =
207            parse_config_from_args(["prettyping", "example.com"]).expect("cli parsing should pass");
208
209        let runtime = map_runtime_config(&config, IpAddr::V4(Ipv4Addr::LOCALHOST))
210            .expect("runtime mapping should pass");
211
212        assert_eq!(runtime.count, None);
213        assert_eq!(runtime.interval, Duration::from_secs(1));
214        assert_eq!(runtime.timeout, Duration::from_secs(1));
215        assert_eq!(runtime.payload_size, 56);
216        assert_eq!(runtime.ttl, None);
217    }
218
219    #[test]
220    fn selects_runtime_backend_for_current_platform() {
221        #[cfg(any(target_os = "linux", target_os = "macos"))]
222        assert_eq!(super::selected_runtime_backend(), "unix_surge");
223
224        #[cfg(target_os = "windows")]
225        assert_eq!(super::selected_runtime_backend(), "windows_ping_async");
226
227        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
228        assert_eq!(super::selected_runtime_backend(), "unsupported");
229    }
230}