anp 0.9.0

Rust SDK for Agent Network Protocol (ANP)
Documentation
#![allow(dead_code)]

use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use std::sync::OnceLock;

use serde_json::Value;
use tiny_http::{Header, Response, Server};

const RUST_INTEROP_CONFIG_JSON: &str = include_str!("../../../tests/rust_interop_config.json");
static RELEASED_PYTHON_ANP_VERSION: OnceLock<String> = OnceLock::new();

pub fn released_python_anp_version() -> &'static str {
    RELEASED_PYTHON_ANP_VERSION
        .get_or_init(|| {
            let value: Value = serde_json::from_str(RUST_INTEROP_CONFIG_JSON)
                .expect("rust interop config must be valid JSON");
            value
                .get("released_python_anp_version")
                .and_then(Value::as_str)
                .expect("released_python_anp_version must be configured")
                .to_string()
        })
        .as_str()
}

pub struct TestTempDir {
    path: PathBuf,
}

impl TestTempDir {
    pub fn new(prefix: &str) -> std::io::Result<Self> {
        let mut attempts = 0_u32;
        loop {
            let path = std::env::temp_dir().join(format!(
                "{}-{}-{}",
                prefix,
                std::process::id(),
                unique_suffix()
            ));
            match std::fs::create_dir(&path) {
                Ok(()) => return Ok(Self { path }),
                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists && attempts < 100 => {
                    attempts += 1;
                }
                Err(err) => return Err(err),
            }
        }
    }

    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TestTempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.path);
    }
}

pub struct TestNamedTempFile {
    path: PathBuf,
}

impl TestNamedTempFile {
    pub fn new(prefix: &str) -> std::io::Result<Self> {
        let dir = std::env::temp_dir();
        let mut attempts = 0_u32;
        loop {
            let path = dir.join(format!(
                "{}-{}-{}.json",
                prefix,
                std::process::id(),
                unique_suffix()
            ));
            match std::fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .open(&path)
            {
                Ok(_) => return Ok(Self { path }),
                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists && attempts < 100 => {
                    attempts += 1;
                }
                Err(err) => return Err(err),
            }
        }
    }

    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TestNamedTempFile {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.path);
    }
}

pub fn tempdir(prefix: &str) -> std::io::Result<TestTempDir> {
    TestTempDir::new(prefix)
}

pub fn named_temp_file(prefix: &str) -> std::io::Result<TestNamedTempFile> {
    TestNamedTempFile::new(prefix)
}

pub struct JsonTestServer {
    uri: String,
}

impl JsonTestServer {
    pub fn start(routes: impl IntoIterator<Item = (&'static str, Value)>) -> Self {
        let server = Server::http("127.0.0.1:0").expect("test HTTP server should bind");
        let uri = format!("http://{}", server.server_addr());
        let routes: HashMap<String, String> = routes
            .into_iter()
            .map(|(path, body)| {
                (
                    path.to_string(),
                    serde_json::to_string(&body).expect("test JSON body should serialize"),
                )
            })
            .collect();

        thread::spawn(move || loop {
            match server.recv_timeout(Duration::from_millis(100)) {
                Ok(Some(request)) => {
                    let body = routes.get(request.url()).cloned();
                    let response = match body {
                        Some(body) if request.method().as_str() == "GET" => {
                            Response::from_string(body).with_header(
                                Header::from_bytes("Content-Type", "application/json")
                                    .expect("valid header"),
                            )
                        }
                        _ => Response::from_string("not found").with_status_code(404),
                    };
                    let _ = request.respond(response);
                }
                Ok(None) => continue,
                Err(_) => break,
            }
        });

        Self { uri }
    }

    pub fn uri(&self) -> String {
        self.uri.clone()
    }
}

pub struct RecordingJsonTestServer {
    uri: String,
    requests: Arc<Mutex<Vec<RecordedRequest>>>,
}

#[derive(Clone, Debug, Default)]
pub struct RecordedRequest {
    pub path: String,
    pub headers: BTreeMap<String, String>,
}

impl RecordingJsonTestServer {
    pub fn start(routes: impl IntoIterator<Item = (&'static str, Value)>) -> Self {
        let server = Server::http("127.0.0.1:0").expect("test HTTP server should bind");
        let uri = format!("http://{}", server.server_addr());
        let routes: HashMap<String, String> = routes
            .into_iter()
            .map(|(path, body)| {
                (
                    path.to_string(),
                    serde_json::to_string(&body).expect("test JSON body should serialize"),
                )
            })
            .collect();
        let requests = Arc::new(Mutex::new(Vec::<RecordedRequest>::new()));
        let captured = Arc::clone(&requests);

        thread::spawn(move || loop {
            match server.recv_timeout(Duration::from_millis(100)) {
                Ok(Some(request)) => {
                    let path = request.url().to_string();
                    let headers = request
                        .headers()
                        .iter()
                        .map(|header| {
                            (
                                header.field.as_str().to_string(),
                                header.value.as_str().to_string(),
                            )
                        })
                        .collect::<BTreeMap<_, _>>();
                    captured
                        .lock()
                        .expect("request capture should not be poisoned")
                        .push(RecordedRequest {
                            path: path.clone(),
                            headers,
                        });
                    let body = routes.get(&path).cloned();
                    let response = match body {
                        Some(body) if request.method().as_str() == "GET" => {
                            Response::from_string(body).with_header(
                                Header::from_bytes("Content-Type", "application/json")
                                    .expect("valid header"),
                            )
                        }
                        _ => Response::from_string("not found").with_status_code(404),
                    };
                    let _ = request.respond(response);
                }
                Ok(None) => continue,
                Err(_) => break,
            }
        });

        Self { uri, requests }
    }

    pub fn uri(&self) -> String {
        self.uri.clone()
    }

    pub fn requests(&self) -> Vec<RecordedRequest> {
        self.requests
            .lock()
            .expect("request capture should not be poisoned")
            .clone()
    }
}

fn unique_suffix() -> String {
    use std::sync::atomic::{AtomicU64, Ordering};

    static COUNTER: AtomicU64 = AtomicU64::new(0);
    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or_default();
    format!("{}-{}", nanos, counter)
}