ant-core 0.2.0

Headless Rust library for the Autonomi network: data storage and retrieval with self-encryption and EVM payments, plus node lifecycle management.
Documentation
use std::net::IpAddr;

use ant_core::node::binary::NoopProgress;
use ant_core::node::daemon::server;
use ant_core::node::registry::NodeRegistry;
use ant_core::node::types::{AddNodeOpts, BinarySource, DaemonConfig, DaemonStatus, NodeInfo};

fn test_config(dir: &tempfile::TempDir) -> DaemonConfig {
    DaemonConfig {
        listen_addr: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
        port: Some(0), // random available port
        registry_path: dir.path().join("registry.json"),
        log_path: dir.path().join("daemon.log"),
        port_file_path: dir.path().join("daemon.port"),
        pid_file_path: dir.path().join("daemon.pid"),
    }
}

#[tokio::test]
async fn start_daemon_get_status_stop() {
    let dir = tempfile::tempdir().unwrap();
    let config = test_config(&dir);
    let registry = NodeRegistry::load(&config.registry_path).unwrap();
    let shutdown = tokio_util::sync::CancellationToken::new();

    let addr = server::start(config, registry, shutdown.clone())
        .await
        .unwrap();

    // Hit the status endpoint
    let url = format!("http://{addr}/api/v1/status");
    let resp = reqwest::get(&url).await.unwrap();
    assert!(resp.status().is_success());

    let status: DaemonStatus = resp.json().await.unwrap();
    assert!(status.running);
    assert!(status.pid.is_some());
    assert_eq!(status.nodes_total, 0);
    assert_eq!(status.nodes_running, 0);
    assert_eq!(status.nodes_stopped, 0);
    assert_eq!(status.nodes_errored, 0);

    // Verify uptime is reasonable (should be very small)
    assert!(status.uptime_secs.unwrap() < 5);

    // Stop the daemon
    shutdown.cancel();
    // Give server time to shut down
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}

#[tokio::test]
async fn openapi_spec_is_valid_json() {
    let dir = tempfile::tempdir().unwrap();
    let config = test_config(&dir);
    let registry = NodeRegistry::load(&config.registry_path).unwrap();
    let shutdown = tokio_util::sync::CancellationToken::new();

    let addr = server::start(config, registry, shutdown.clone())
        .await
        .unwrap();

    let url = format!("http://{addr}/api/v1/openapi.json");
    let resp = reqwest::get(&url).await.unwrap();
    assert!(resp.status().is_success());

    let spec: serde_json::Value = resp.json().await.unwrap();
    assert_eq!(spec["openapi"], "3.1.0");
    assert_eq!(spec["info"]["title"], "Ant Daemon API");
    assert!(spec["paths"]["/api/v1/status"].is_object());

    shutdown.cancel();
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}

#[tokio::test]
async fn port_and_pid_files_written() {
    let dir = tempfile::tempdir().unwrap();
    let config = test_config(&dir);
    let port_file = config.port_file_path.clone();
    let pid_file = config.pid_file_path.clone();
    let registry = NodeRegistry::load(&config.registry_path).unwrap();
    let shutdown = tokio_util::sync::CancellationToken::new();

    let addr = server::start(config, registry, shutdown.clone())
        .await
        .unwrap();

    // Verify port file
    let port_contents = std::fs::read_to_string(&port_file).unwrap();
    let port: u16 = port_contents.trim().parse().unwrap();
    assert_eq!(port, addr.port());

    // Verify PID file
    let pid_contents = std::fs::read_to_string(&pid_file).unwrap();
    let pid: u32 = pid_contents.trim().parse().unwrap();
    assert_eq!(pid, std::process::id());

    shutdown.cancel();
    tokio::time::sleep(std::time::Duration::from_millis(200)).await;

    // After shutdown, files should be cleaned up
    assert!(
        !port_file.exists(),
        "Port file should be removed after shutdown"
    );
    assert!(
        !pid_file.exists(),
        "PID file should be removed after shutdown"
    );
}

#[tokio::test]
async fn console_returns_html() {
    let dir = tempfile::tempdir().unwrap();
    let config = test_config(&dir);
    let registry = NodeRegistry::load(&config.registry_path).unwrap();
    let shutdown = tokio_util::sync::CancellationToken::new();

    let addr = server::start(config, registry, shutdown.clone())
        .await
        .unwrap();

    let url = format!("http://{addr}/console");
    let resp = reqwest::get(&url).await.unwrap();
    assert!(resp.status().is_success());

    let content_type = resp
        .headers()
        .get("content-type")
        .unwrap()
        .to_str()
        .unwrap();
    assert!(
        content_type.contains("text/html"),
        "Expected text/html, got {content_type}"
    );

    let body = resp.text().await.unwrap();
    assert!(body.contains("Node Console"));
    assert!(body.contains("/api/v1"));

    shutdown.cancel();
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}

/// Create a fake binary that responds to `--version`.
fn create_fake_binary(dir: &std::path::Path) -> std::path::PathBuf {
    #[cfg(unix)]
    {
        let binary_path = dir.join("fake-antnode");
        std::fs::write(&binary_path, "#!/bin/sh\necho \"antnode 0.1.0-test\"\n").unwrap();
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755)).unwrap();
        binary_path
    }
    #[cfg(windows)]
    {
        let binary_path = dir.join("fake-antnode.cmd");
        std::fs::write(&binary_path, "@echo off\r\necho antnode 0.1.0-test\r\n").unwrap();
        binary_path
    }
}

#[tokio::test]
async fn get_node_detail_returns_full_config() {
    let dir = tempfile::tempdir().unwrap();
    let config = test_config(&dir);
    let reg_path = config.registry_path.clone();

    // Add a node to the registry
    let binary = create_fake_binary(dir.path());
    let opts = AddNodeOpts {
        count: 1,
        rewards_address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
        data_dir_path: Some(dir.path().join("data")),
        log_dir_path: Some(dir.path().join("logs")),
        binary_source: BinarySource::LocalPath(binary),
        ..Default::default()
    };
    ant_core::node::add_nodes(opts, &reg_path, &NoopProgress)
        .await
        .unwrap();

    // Start the daemon
    let registry = NodeRegistry::load(&reg_path).unwrap();
    let shutdown = tokio_util::sync::CancellationToken::new();
    let addr = server::start(config, registry, shutdown.clone())
        .await
        .unwrap();

    // GET /api/v1/nodes/1 — should return full config + runtime state
    let url = format!("http://{addr}/api/v1/nodes/1");
    let resp = reqwest::get(&url).await.unwrap();
    assert!(resp.status().is_success());

    let detail: NodeInfo = resp.json().await.unwrap();
    assert_eq!(detail.config.id, 1);
    assert_eq!(detail.config.service_name, "node1");
    assert_eq!(
        detail.config.rewards_address,
        "0x1234567890abcdef1234567890abcdef12345678"
    );
    assert!(detail.config.data_dir.exists());
    assert_eq!(detail.status, ant_core::node::types::NodeStatus::Stopped);
    assert!(detail.pid.is_none());
    assert!(detail.uptime_secs.is_none());

    // Verify JSON includes flattened config fields (serde flatten)
    let raw: serde_json::Value = reqwest::get(&url).await.unwrap().json().await.unwrap();
    assert!(raw.get("id").is_some(), "should have flattened id");
    assert!(
        raw.get("service_name").is_some(),
        "should have flattened service_name"
    );
    assert!(
        raw.get("data_dir").is_some(),
        "should have flattened data_dir"
    );
    assert!(
        raw.get("rewards_address").is_some(),
        "should have flattened rewards_address"
    );
    assert!(raw.get("status").is_some());

    // GET /api/v1/nodes/999 — should 404
    let resp_404 = reqwest::get(format!("http://{addr}/api/v1/nodes/999"))
        .await
        .unwrap();
    assert_eq!(resp_404.status(), 404);

    shutdown.cancel();
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}