use serde::Serialize;
use super::util;
const TARGET: &str = "1.1.1.1";
const MAX_HOPS: u32 = 15;
#[derive(Debug, Clone, Serialize)]
pub struct Hop {
pub number: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub ip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_ms: Option<f64>,
pub timed_out: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct LatencyJump {
pub from_hop: u32,
pub to_hop: u32,
pub delta_ms: f64,
pub segment: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RoutePath {
pub target: String,
pub hops: Vec<Hop>,
pub reached: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_hop_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub isp_boundary_hop: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub largest_jump: Option<LatencyJump>,
pub assessment: String,
pub level: String,
}
pub async fn collect() -> Option<RoutePath> {
let transcript = run_traceroute().await?;
let hops = parse_transcript(&transcript);
if hops.is_empty() {
return None;
}
Some(analyze_hops(hops))
}
async fn run_traceroute() -> Option<String> {
#[cfg(windows)]
{
let mut cmd = tokio::process::Command::new("tracert");
cmd.args(["-d", "-h", &MAX_HOPS.to_string(), "-w", "800", TARGET]);
let output = util::run_with_timeout(cmd, util::TRACE).await?;
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(unix)]
{
let mut cmd = tokio::process::Command::new("traceroute");
cmd.args([
"-n",
"-m",
&MAX_HOPS.to_string(),
"-q",
"2",
"-w",
"1",
TARGET,
]);
if let Some(output) = util::run_with_timeout(cmd, util::TRACE).await {
if output.status.success() || !output.stdout.is_empty() {
return Some(String::from_utf8_lossy(&output.stdout).into_owned());
}
}
let mut cmd = tokio::process::Command::new("tracepath");
cmd.args(["-n", "-m", &MAX_HOPS.to_string(), TARGET]);
let output = util::run_with_timeout(cmd, util::TRACE).await?;
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
}
fn parse_transcript(text: &str) -> Vec<Hop> {
let mut hops: Vec<Hop> = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let mut parts = trimmed.split_whitespace();
let Some(first) = parts.next() else { continue };
let Ok(number) = first.parse::<u32>() else {
continue;
};
if number == 0 || number > MAX_HOPS {
continue;
}
if hops.iter().any(|h: &Hop| h.number == number) {
continue;
}
let rest: Vec<&str> = trimmed.split_whitespace().skip(1).collect();
let mut times: Vec<f64> = Vec::new();
let mut ip: Option<String> = None;
let mut idx = 0;
while idx < rest.len() {
let token = rest[idx].trim_end_matches(':').trim_end_matches(',');
if let Some(stripped) = token.strip_prefix('<') {
if let Ok(v) = stripped.parse::<f64>() {
times.push(v);
}
} else if let Ok(v) = token.parse::<f64>() {
if rest.get(idx + 1).is_some_and(|n| n.starts_with("ms")) {
times.push(v);
idx += 1; }
} else if let Some(stripped) = token.strip_suffix("ms") {
if let Ok(v) = stripped.parse::<f64>() {
times.push(v);
}
} else if ip.is_none() && looks_like_ip(token) {
ip = Some(token.to_string());
}
idx += 1;
}
let timed_out = times.is_empty() && ip.is_none();
let avg_ms = if times.is_empty() {
None
} else {
Some(times.iter().sum::<f64>() / times.len() as f64)
};
hops.push(Hop {
number,
ip,
avg_ms,
timed_out,
});
}
hops
}
fn looks_like_ip(token: &str) -> bool {
token.parse::<std::net::IpAddr>().is_ok()
}
fn is_private_ip(ip: &str) -> bool {
match ip.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(v4)) => v4.is_private() || v4.is_link_local() || is_cgnat_v4(&v4),
Ok(std::net::IpAddr::V6(v6)) => {
let seg = v6.segments()[0];
(seg & 0xfe00) == 0xfc00 || (seg & 0xffc0) == 0xfe80
}
Err(_) => false,
}
}
fn is_cgnat_v4(v4: &std::net::Ipv4Addr) -> bool {
let o = v4.octets();
o[0] == 100 && (64..128).contains(&o[1])
}
fn analyze_hops(hops: Vec<Hop>) -> RoutePath {
let reached = hops
.iter()
.any(|h| h.ip.as_deref() == Some(TARGET) && !h.timed_out);
let first_hop_ms = hops.iter().find(|h| h.number == 1).and_then(|h| h.avg_ms);
let isp_boundary_hop = hops
.iter()
.filter(|h| h.ip.is_some())
.find(|h| !is_private_ip(h.ip.as_deref().unwrap_or("")))
.map(|h| h.number);
let responding: Vec<(&Hop, f64)> = hops
.iter()
.filter_map(|h| h.avg_ms.map(|ms| (h, ms)))
.collect();
let largest_jump = responding
.windows(2)
.filter_map(|w| {
let (from, from_ms) = w[0];
let (to, to_ms) = w[1];
let delta = to_ms - from_ms;
if delta <= 0.0 {
return None;
}
let segment = match isp_boundary_hop {
Some(boundary) if to.number < boundary => "LAN",
Some(boundary) if to.number <= boundary + 3 => "ISP",
Some(_) => "backbone",
None => "LAN",
};
Some(LatencyJump {
from_hop: from.number,
to_hop: to.number,
delta_ms: delta,
segment: segment.to_string(),
})
})
.max_by(|a, b| {
a.delta_ms
.partial_cmp(&b.delta_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});
let (assessment, level) = if !reached {
(
format!("Target {} not reached within {} hops", TARGET, MAX_HOPS),
"fail",
)
} else if first_hop_ms.is_some_and(|ms| ms > 10.0) {
(
format!(
"First hop (your router) is slow at {:.0}ms — check Wi-Fi signal or router load",
first_hop_ms.unwrap_or(0.0)
),
"warn",
)
} else if largest_jump
.as_ref()
.is_some_and(|j| j.delta_ms > 100.0 && j.segment == "ISP")
{
(
"Large latency jump inside the ISP segment — possible congestion".to_string(),
"warn",
)
} else {
("Path looks healthy".to_string(), "ok")
};
RoutePath {
target: TARGET.to_string(),
hops,
reached,
first_hop_ms,
isp_boundary_hop,
largest_jump,
assessment,
level: level.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const TRACERT_TRANSCRIPT: &str = "\
Tracing route to 1.1.1.1 over a maximum of 15 hops
1 <1 ms <1 ms <1 ms 192.168.1.1
2 8 ms 9 ms 8 ms 100.64.0.1
3 12 ms 11 ms 13 ms 68.86.92.25
4 * * * Request timed out.
5 13 ms 14 ms 12 ms 1.1.1.1
Trace complete.";
const TRACEROUTE_TRANSCRIPT: &str = "\
traceroute to 1.1.1.1 (1.1.1.1), 15 hops max, 60 byte packets
1 192.168.1.1 0.512 ms 0.498 ms
2 100.64.0.1 8.123 ms 8.077 ms
3 68.86.92.25 12.401 ms 12.388 ms
4 * *
5 1.1.1.1 13.102 ms 13.097 ms";
#[test]
fn parses_tracert_transcript() {
let hops = parse_transcript(TRACERT_TRANSCRIPT);
assert_eq!(hops.len(), 5);
assert_eq!(hops[0].ip.as_deref(), Some("192.168.1.1"));
assert_eq!(hops[0].avg_ms, Some(1.0)); assert!(hops[3].timed_out);
assert_eq!(hops[4].ip.as_deref(), Some("1.1.1.1"));
}
#[test]
fn parses_traceroute_transcript() {
let hops = parse_transcript(TRACEROUTE_TRANSCRIPT);
assert_eq!(hops.len(), 5);
assert_eq!(hops[1].ip.as_deref(), Some("100.64.0.1"));
assert!(hops[1].avg_ms.is_some_and(|ms| (ms - 8.1).abs() < 0.1));
assert!(hops[3].timed_out);
}
#[test]
fn analysis_finds_boundary_and_reachability() {
let path = analyze_hops(parse_transcript(TRACERT_TRANSCRIPT));
assert!(path.reached);
assert_eq!(path.isp_boundary_hop, Some(3));
assert_eq!(path.level, "ok");
assert!(path.first_hop_ms.is_some());
}
#[test]
fn unreached_target_is_fail_level() {
let hops = vec![
Hop {
number: 1,
ip: Some("192.168.1.1".to_string()),
avg_ms: Some(1.0),
timed_out: false,
},
Hop {
number: 2,
ip: None,
avg_ms: None,
timed_out: true,
},
];
let path = analyze_hops(hops);
assert!(!path.reached);
assert_eq!(path.level, "fail");
}
#[test]
fn slow_first_hop_warns() {
let hops = vec![
Hop {
number: 1,
ip: Some("192.168.1.1".to_string()),
avg_ms: Some(35.0),
timed_out: false,
},
Hop {
number: 2,
ip: Some("1.1.1.1".to_string()),
avg_ms: Some(40.0),
timed_out: false,
},
];
let path = analyze_hops(hops);
assert_eq!(path.level, "warn");
assert!(path.assessment.contains("router"));
}
#[test]
fn cgnat_addresses_count_as_private() {
assert!(is_private_ip("100.64.0.1"));
assert!(is_private_ip("192.168.1.1"));
assert!(!is_private_ip("68.86.92.25"));
assert!(!is_private_ip("1.1.1.1"));
}
}