use std::collections::HashSet;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::node::IicpNode;
const REQUIRED_HEALTH_FIELDS: &[&str] = &["status", "node_id", "region", "load", "models"];
const NON_ROUTABLE: &[&str] = &["localhost", "127.0.0.1", "::1", "example.com", "0.0.0.0"];
const DISCOVER_INTENT: &str = "urn:iicp:intent:llm:chat:v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeResult {
pub test_id: String,
pub passed: bool,
pub message: String,
pub latency_ms: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConformanceReport {
pub pass_count: usize,
pub fail_count: usize,
pub last_run_at: String,
pub tests: Vec<ProbeResult>,
}
async fn check_registered(node_id: &str, node_token: Option<&str>) -> ProbeResult {
if !node_id.is_empty() && node_token.is_some_and(|t| !t.is_empty()) {
let short = if node_id.len() > 8 {
format!("{}…", &node_id[..8])
} else {
node_id.to_string()
};
return ProbeResult {
test_id: "CONF-REG-01".into(),
passed: true,
message: format!("Registered ({short})"),
latency_ms: None,
};
}
if !node_id.is_empty() {
return ProbeResult {
test_id: "CONF-REG-01".into(),
passed: true,
message: format!(
"node_id set ({}…); token not tracked by SDK",
&node_id[..node_id.len().min(8)]
),
latency_ms: None,
};
}
ProbeResult {
test_id: "CONF-REG-01".into(),
passed: false,
message: "node_id empty — register() not yet called".into(),
latency_ms: None,
}
}
async fn check_health_schema(local_port: u16) -> ProbeResult {
let url = format!("http://127.0.0.1:{local_port}/iicp/health");
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build()
{
Ok(c) => c,
Err(e) => {
return ProbeResult {
test_id: "CONF-HEALTH-01".into(),
passed: false,
message: format!("Error: {e}"),
latency_ms: None,
};
}
};
let t0 = std::time::Instant::now();
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
return ProbeResult {
test_id: "CONF-HEALTH-01".into(),
passed: false,
message: format!("Error: {e}"),
latency_ms: None,
};
}
};
let latency_ms = t0.elapsed().as_secs_f64() * 1000.0;
if !resp.status().is_success() {
return ProbeResult {
test_id: "CONF-HEALTH-01".into(),
passed: false,
message: format!("HTTP {}", resp.status().as_u16()),
latency_ms: Some(latency_ms),
};
}
let body: serde_json::Value = match resp.json().await {
Ok(b) => b,
Err(e) => {
return ProbeResult {
test_id: "CONF-HEALTH-01".into(),
passed: false,
message: format!("Body parse error: {e}"),
latency_ms: Some(latency_ms),
};
}
};
let present: HashSet<&str> = body
.as_object()
.map(|m| m.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
let mut missing: Vec<&str> = REQUIRED_HEALTH_FIELDS
.iter()
.copied()
.filter(|f| !present.contains(f))
.collect();
if !missing.is_empty() {
missing.sort();
return ProbeResult {
test_id: "CONF-HEALTH-01".into(),
passed: false,
message: format!("Missing fields: {missing:?}"),
latency_ms: Some(latency_ms),
};
}
ProbeResult {
test_id: "CONF-HEALTH-01".into(),
passed: true,
message: format!("OK ({latency_ms:.0}ms)"),
latency_ms: Some(latency_ms),
}
}
fn parse_host_port(endpoint: &str) -> (String, u16) {
let mut s = endpoint;
for scheme in ["https://", "http://"] {
if let Some(rest) = endpoint.strip_prefix(scheme) {
s = rest;
break;
}
}
let authority = s.split('/').next().unwrap_or(s);
if let Some((host, port_str)) = authority.rsplit_once(':') {
let port = port_str.parse::<u16>().unwrap_or(443);
(host.to_string(), port)
} else {
let port = if endpoint.starts_with("https://") {
443
} else {
80
};
(authority.to_string(), port)
}
}
fn directory_base(directory_url: &str) -> String {
let trimmed = directory_url.trim_end_matches('/');
if trimmed.ends_with("/api") {
trimmed.to_string()
} else {
format!("{trimmed}/api")
}
}
async fn check_reachability(endpoint: &str, directory_url: &str) -> ProbeResult {
let ep = endpoint.trim_end_matches('/');
if ep.is_empty() || NON_ROUTABLE.iter().any(|p| ep.contains(p)) {
return ProbeResult {
test_id: "CONF-REACH-01".into(),
passed: false,
message: "endpoint is non-routable — external check skipped; \
see https://iicp.network/docs/port-forwarding"
.into(),
latency_ms: None,
};
}
let (host, port) = parse_host_port(ep);
let url = format!(
"{}/v1/probe?host={}&port={}",
directory_base(directory_url),
urlencoding(&host),
port
);
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(e) => {
return ProbeResult {
test_id: "CONF-REACH-01".into(),
passed: false,
message: format!("Client build: {e}"),
latency_ms: None,
};
}
};
let t0 = std::time::Instant::now();
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
return ProbeResult {
test_id: "CONF-REACH-01".into(),
passed: false,
message: format!("Probe unavailable: {e}"),
latency_ms: None,
};
}
};
let latency_ms = t0.elapsed().as_secs_f64() * 1000.0;
if !resp.status().is_success() {
return ProbeResult {
test_id: "CONF-REACH-01".into(),
passed: false,
message: format!("HTTP {}", resp.status().as_u16()),
latency_ms: Some(latency_ms),
};
}
let body: serde_json::Value = match resp.json().await {
Ok(b) => b,
Err(e) => {
return ProbeResult {
test_id: "CONF-REACH-01".into(),
passed: false,
message: format!("Body parse error: {e}"),
latency_ms: Some(latency_ms),
};
}
};
if body.get("reachable").and_then(|v| v.as_bool()) == Some(true) {
return ProbeResult {
test_id: "CONF-REACH-01".into(),
passed: true,
message: format!("Reachable ({latency_ms:.0}ms)"),
latency_ms: Some(latency_ms),
};
}
let err = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("not reachable");
ProbeResult {
test_id: "CONF-REACH-01".into(),
passed: false,
message: err.to_string(),
latency_ms: Some(latency_ms),
}
}
async fn check_discover_self(node_id: &str, directory_url: &str) -> ProbeResult {
if node_id.is_empty() {
return ProbeResult {
test_id: "CONF-DISC-01".into(),
passed: false,
message: "No node_id — register() not yet called".into(),
latency_ms: None,
};
}
let url = format!(
"{}/v1/discover?intent={}",
directory_base(directory_url),
urlencoding(DISCOVER_INTENT)
);
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(e) => {
return ProbeResult {
test_id: "CONF-DISC-01".into(),
passed: false,
message: format!("Client build: {e}"),
latency_ms: None,
};
}
};
let t0 = std::time::Instant::now();
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
return ProbeResult {
test_id: "CONF-DISC-01".into(),
passed: false,
message: format!("Discover error: {e}"),
latency_ms: None,
};
}
};
let latency_ms = t0.elapsed().as_secs_f64() * 1000.0;
if !resp.status().is_success() {
return ProbeResult {
test_id: "CONF-DISC-01".into(),
passed: false,
message: format!("HTTP {}", resp.status().as_u16()),
latency_ms: Some(latency_ms),
};
}
let body: serde_json::Value = match resp.json().await {
Ok(b) => b,
Err(e) => {
return ProbeResult {
test_id: "CONF-DISC-01".into(),
passed: false,
message: format!("Body parse error: {e}"),
latency_ms: Some(latency_ms),
};
}
};
let nodes = body
.get("nodes")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let found = nodes
.iter()
.any(|n| n.get("node_id").and_then(|v| v.as_str()) == Some(node_id));
if found {
ProbeResult {
test_id: "CONF-DISC-01".into(),
passed: true,
message: format!("Found in NODELIST ({} nodes)", nodes.len()),
latency_ms: Some(latency_ms),
}
} else {
ProbeResult {
test_id: "CONF-DISC-01".into(),
passed: false,
message: format!("node_id absent from NODELIST (got {} nodes)", nodes.len()),
latency_ms: Some(latency_ms),
}
}
}
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pub async fn run_conformance_checks(
node: &IicpNode,
local_port: u16,
node_token: Option<&str>,
) -> ConformanceReport {
let cfg = node.cfg();
let (a, b, c, d) = tokio::join!(
check_registered(&cfg.node_id, node_token),
check_health_schema(local_port),
check_reachability(&cfg.endpoint, &cfg.directory_url),
check_discover_self(&cfg.node_id, &cfg.directory_url),
);
let results = vec![a, b, c, d];
let pass_count = results.iter().filter(|r| r.passed).count();
let fail_count = results.iter().filter(|r| !r.passed).count();
ConformanceReport {
pass_count,
fail_count,
last_run_at: rfc3339_now(),
tests: results,
}
}
fn rfc3339_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days_since_epoch = secs / 86400;
let secs_of_day = secs % 86400;
let hour = secs_of_day / 3600;
let minute = (secs_of_day % 3600) / 60;
let second = secs_of_day % 60;
let (year, month, day) = days_to_ymd(days_since_epoch as i64);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn days_to_ymd(z: i64) -> (i64, u8, u8) {
let z = z + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u8;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}