nomadnet-rs 0.1.0

Rust library for NomadNet Node Hosting and browsing over Reticulum
use std::collections::HashMap;
use std::path::Path;

#[derive(Debug, Clone)]
pub struct InterfaceConfig {
    pub name: String,
    pub iface_type: InterfaceType,
    pub enabled: bool,
}

#[derive(Debug, Clone)]
pub enum InterfaceType {
    TcpClient {
        target_host: String,
        target_port: u16,
    },
    TcpServer {
        listen_port: u16,
    },
    Udp {
        bind_addr: String,
    },
}

#[derive(Debug, Default)]
pub struct RnsConfig {
    pub interfaces: Vec<InterfaceConfig>,
    pub enable_transport: bool,
}

impl RnsConfig {
    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
        let content = std::fs::read_to_string(&path)
            .map_err(|e| ConfigError::Io(path.as_ref().display().to_string(), e))?;
        Self::parse(&content)
    }

    pub fn parse(content: &str) -> Result<Self, ConfigError> {
        let mut config = RnsConfig::default();
        let mut current_section: Option<String> = None;
        let mut current_interface: Option<String> = None;
        let mut interface_props: HashMap<String, String> = HashMap::new();

        for line in content.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            if line.starts_with('[') && line.ends_with(']') {
                if let Some(iface_name) = current_interface.take() {
                    if let Ok(iface) = parse_interface(&iface_name, &interface_props) {
                        config.interfaces.push(iface);
                    }
                    interface_props.clear();
                }
                let section = line[1..line.len() - 1].trim();
                current_section = Some(section.to_string());
                if section.starts_with('[') && section.ends_with(']') {
                    let iface_name = section[1..section.len() - 1].trim().to_string();
                    current_interface = Some(iface_name);
                } else if section == "interfaces" {
                    current_interface = None;
                }
                continue;
            }
            if let Some((key, value)) = line.split_once('=') {
                let key = key.trim();
                let value = value.trim();
                if current_interface.is_some() {
                    interface_props.insert(key.to_string(), value.to_string());
                    continue;
                }
                if current_section.as_deref() == Some("reticulum")
                    && key == "enable_transport"
                {
                    config.enable_transport = parse_bool(value);
                }
            }
        }
        if let Some(iface_name) = current_interface {
            if let Ok(iface) = parse_interface(&iface_name, &interface_props) {
                config.interfaces.push(iface);
            }
        }
        Ok(config)
    }
}

fn parse_bool(value: &str) -> bool {
    matches!(value.to_lowercase().as_str(), "true" | "yes" | "1" | "on")
}

fn parse_interface(
    name: &str,
    props: &HashMap<String, String>,
) -> Result<InterfaceConfig, ConfigError> {
    let iface_type_str = props
        .get("type")
        .or_else(|| props.get("interface_type"))
        .ok_or_else(|| ConfigError::MissingField(name.to_string(), "type"))?;

    let enabled = props
        .get("interface_enabled")
        .or_else(|| props.get("enabled"))
        .map(|v| parse_bool(v))
        .unwrap_or(true);

    let iface_type = match iface_type_str.as_str() {
        "TCPClientInterface" => {
            let target_host = props
                .get("target_host")
                .ok_or_else(|| ConfigError::MissingField(name.to_string(), "target_host"))?
                .to_string();
            let target_port = props
                .get("target_port")
                .and_then(|v| v.parse().ok())
                .ok_or_else(|| ConfigError::MissingField(name.to_string(), "target_port"))?;
            InterfaceType::TcpClient {
                target_host,
                target_port,
            }
        }
        "TCPServerInterface" => {
            let listen_port = props
                .get("listen_port")
                .and_then(|v| v.parse().ok())
                .unwrap_or(4242);
            InterfaceType::TcpServer { listen_port }
        }
        "UDPInterface" => {
            let bind_addr = props
                .get("listen_addr")
                .or_else(|| props.get("bind_addr"))
                .map(|v| v.to_string())
                .unwrap_or_else(|| "0.0.0.0:4242".to_string());
            InterfaceType::Udp { bind_addr }
        }
        _ => {
            return Err(ConfigError::UnknownInterfaceType(
                name.to_string(),
                iface_type_str.clone(),
            ));
        }
    };

    Ok(InterfaceConfig {
        name: name.to_string(),
        iface_type,
        enabled,
    })
}

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("IO error reading {0}: {1}")]
    Io(String, std::io::Error),
    #[error("Missing field {1} in interface {0}")]
    MissingField(String, &'static str),
    #[error("Unknown interface type {1} in interface {0}")]
    UnknownInterfaceType(String, String),
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_tcp_client_interface() {
        let config = r#"
[reticulum]

[[RNS Testnet Amsterdam]]
  type = TCPClientInterface
  target_host = amsterdam.reticulum.network
  target_port = 4985
"#;
        let parsed = RnsConfig::parse(config).unwrap();
        assert_eq!(parsed.interfaces.len(), 1);
        let iface = &parsed.interfaces[0];
        assert_eq!(iface.name, "RNS Testnet Amsterdam");
        assert!(iface.enabled);
        match &iface.iface_type {
            InterfaceType::TcpClient { target_host, target_port } => {
                assert_eq!(target_host, "amsterdam.reticulum.network");
                assert_eq!(*target_port, 4985);
            }
            _ => panic!("expected TcpClient"),
        }
    }

    #[test]
    fn parse_tcp_server_with_custom_port() {
        let config = r#"
[[My Server]]
  type = TCPServerInterface
  listen_port = 4321
"#;
        let parsed = RnsConfig::parse(config).unwrap();
        assert_eq!(parsed.interfaces.len(), 1);
        match &parsed.interfaces[0].iface_type {
            InterfaceType::TcpServer { listen_port } => assert_eq!(*listen_port, 4321),
            _ => panic!("expected TcpServer"),
        }
    }

    #[test]
    fn parse_udp_interface() {
        let config = r#"
[[Local UDP]]
  type = UDPInterface
  listen_addr = 0.0.0.0:4242
"#;
        let parsed = RnsConfig::parse(config).unwrap();
        assert_eq!(parsed.interfaces.len(), 1);
        match &parsed.interfaces[0].iface_type {
            InterfaceType::Udp { bind_addr } => assert_eq!(bind_addr, "0.0.0.0:4242"),
            _ => panic!("expected UDP"),
        }
    }

    #[test]
    fn disabled_interface_skipped() {
        let config = r#"
[[Disabled Iface]]
  type = TCPClientInterface
  target_host = example.com
  target_port = 1234
  interface_enabled = no
"#;
        let parsed = RnsConfig::parse(config).unwrap();
        assert_eq!(parsed.interfaces.len(), 1);
        assert!(!parsed.interfaces[0].enabled);
    }

    #[test]
    fn enable_transport_parsed() {
        let config = r#"
[reticulum]
  enable_transport = true
"#;
        let parsed = RnsConfig::parse(config).unwrap();
        assert!(parsed.enable_transport);
    }

    #[test]
    fn comments_and_empty_lines_ignored() {
        let config = r#"
# This is a comment
  # Indented comment

[[Test]]
  type = UDPInterface
"#;
        let parsed = RnsConfig::parse(config).unwrap();
        assert_eq!(parsed.interfaces.len(), 1);
    }

    #[test]
    fn unknown_type_is_skipped() {
        let config = r#"
[[Bad]]
  type = FakeInterface
"#;
        let parsed = RnsConfig::parse(config).unwrap();
        assert_eq!(parsed.interfaces.len(), 0);
    }

    #[test]
    fn missing_type_is_skipped() {
        let config = r#"
[[NoType]]
  target_host = example.com
"#;
        let parsed = RnsConfig::parse(config).unwrap();
        assert_eq!(parsed.interfaces.len(), 0);
    }
}