roswire 0.1.1

JSON-first RouterOS CLI bridge for AI agents and automation.
use crate::protocol::classic::ResourceInfo;
use crate::protocol::RouterOsMajor;
use std::collections::BTreeMap;

pub trait Dialect {
    fn name(&self) -> &'static str;
    fn routeros_major(&self) -> RouterOsMajor;
    fn normalize_fields(
        &self,
        command: &str,
        row: &BTreeMap<String, String>,
    ) -> BTreeMap<String, String>;
    fn command_supported(&self, command: &str) -> bool;
    fn error_hint(&self, error_message: &str) -> Option<String>;
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClassicDialect {
    Unknown,
    V6,
    V7,
}

impl ClassicDialect {
    pub fn from_resource_info(resource: &ResourceInfo) -> Self {
        Self::from_version(&resource.version)
    }

    pub fn from_version(version: &str) -> Self {
        let trimmed = version.trim_start();
        if trimmed.starts_with('6') {
            Self::V6
        } else if trimmed.starts_with('7') {
            Self::V7
        } else {
            Self::Unknown
        }
    }
}

impl Dialect for ClassicDialect {
    fn name(&self) -> &'static str {
        match self {
            Self::Unknown => "unknown",
            Self::V6 => "v6",
            Self::V7 => "v7",
        }
    }

    fn routeros_major(&self) -> RouterOsMajor {
        match self {
            Self::Unknown => RouterOsMajor::Unknown,
            Self::V6 => RouterOsMajor::V6,
            Self::V7 => RouterOsMajor::V7,
        }
    }

    fn normalize_fields(
        &self,
        command: &str,
        row: &BTreeMap<String, String>,
    ) -> BTreeMap<String, String> {
        let mut normalized = row.clone();
        normalize_resource_fields(&mut normalized);
        if command == "interface print" {
            normalize_interface_fields(&mut normalized);
        }
        if command == "ip address print" {
            normalize_ip_address_fields(&mut normalized);
        }
        normalized
    }

    fn command_supported(&self, command: &str) -> bool {
        match (self, command) {
            (Self::V6, "rest") => false,
            (Self::V6, "system resource print") => true,
            (Self::V6, "interface print") => true,
            (Self::V6, "ip address print") => true,
            (Self::V6, "ip address add") => true,
            (Self::V6, "ip address set") => true,
            (Self::V6, "ip address remove") => true,
            (Self::V6, _) => false,
            (Self::V7, _) => true,
            (Self::Unknown, _) => true,
        }
    }

    fn error_hint(&self, error_message: &str) -> Option<String> {
        let message = error_message.to_ascii_lowercase();
        if message.contains("no such item") {
            return Some("refresh item IDs with a print command before retrying".to_owned());
        }
        match self {
            Self::V6 if message.contains("unknown parameter") => Some(
                "RouterOS v6 may use a different field name; inspect schema/print output first"
                    .to_owned(),
            ),
            Self::V7 if message.contains("not found") => {
                Some("RouterOS v7 REST/native paths can differ; verify command support".to_owned())
            }
            _ => None,
        }
    }
}

pub fn normalize_rows(
    dialect: &impl Dialect,
    command: &str,
    rows: &[BTreeMap<String, String>],
) -> Vec<BTreeMap<String, String>> {
    rows.iter()
        .map(|row| dialect.normalize_fields(command, row))
        .collect()
}

fn normalize_resource_fields(row: &mut BTreeMap<String, String>) {
    copy_alias(row, "architecture", "architecture-name");
    copy_alias(row, "architecture-name", "architecture");
    copy_alias(row, "board", "board-name");
}

fn normalize_interface_fields(row: &mut BTreeMap<String, String>) {
    copy_alias(row, "mac-address", "mac_address");
    copy_alias(row, "actual-mtu", "actual_mtu");
    copy_alias(row, "l2mtu", "l2_mtu");
}

fn normalize_ip_address_fields(row: &mut BTreeMap<String, String>) {
    copy_alias(row, "network", "network-address");
    copy_alias(row, "actual-interface", "interface");
}

fn copy_alias(row: &mut BTreeMap<String, String>, from: &str, to: &str) {
    if row.contains_key(to) {
        return;
    }
    if let Some(value) = row.get(from).cloned() {
        row.insert(to.to_owned(), value);
    }
}

#[cfg(test)]
mod tests {
    use super::{normalize_rows, ClassicDialect, Dialect};
    use crate::protocol::classic::ResourceInfo;
    use crate::protocol::RouterOsMajor;
    use std::collections::BTreeMap;

    #[test]
    fn dialect_is_selected_from_resource_version() {
        let v6 = ResourceInfo {
            version: "6.49.10".to_owned(),
            architecture: "mipsbe".to_owned(),
            board_name: "RB2011".to_owned(),
        };
        let v7 = ResourceInfo {
            version: "7.15.3 (stable)".to_owned(),
            architecture: "arm64".to_owned(),
            board_name: "RB5009".to_owned(),
        };

        assert_eq!(ClassicDialect::from_resource_info(&v6), ClassicDialect::V6);
        assert_eq!(ClassicDialect::from_resource_info(&v7), ClassicDialect::V7);
        assert_eq!(
            ClassicDialect::from_version("unknown"),
            ClassicDialect::Unknown
        );
        assert_eq!(ClassicDialect::V6.name(), "v6");
        assert_eq!(ClassicDialect::V7.routeros_major(), RouterOsMajor::V7);
    }

    #[test]
    fn v6_and_v7_resource_fixtures_normalize_to_common_fields() {
        let v6_row = BTreeMap::from([
            ("version".to_owned(), "6.49.10".to_owned()),
            ("architecture".to_owned(), "mipsbe".to_owned()),
            ("board".to_owned(), "RB2011".to_owned()),
        ]);
        let v7_row = BTreeMap::from([
            ("version".to_owned(), "7.15.3".to_owned()),
            ("architecture-name".to_owned(), "arm64".to_owned()),
            ("board-name".to_owned(), "RB5009".to_owned()),
        ]);

        let normalized_v6 = ClassicDialect::V6.normalize_fields("system resource print", &v6_row);
        let normalized_v7 = ClassicDialect::V7.normalize_fields("system resource print", &v7_row);

        assert_eq!(
            normalized_v6.get("architecture-name"),
            Some(&"mipsbe".to_owned())
        );
        assert_eq!(normalized_v6.get("board-name"), Some(&"RB2011".to_owned()));
        assert_eq!(normalized_v7.get("architecture"), Some(&"arm64".to_owned()));
        assert_eq!(normalized_v7.get("board-name"), Some(&"RB5009".to_owned()));
    }

    #[test]
    fn unknown_fields_are_preserved_without_panic() {
        let row = BTreeMap::from([
            ("name".to_owned(), "ether1".to_owned()),
            ("custom-vendor-field".to_owned(), "kept".to_owned()),
            ("mac-address".to_owned(), "AA:BB:CC:DD:EE:FF".to_owned()),
        ]);

        let normalized = ClassicDialect::Unknown.normalize_fields("interface print", &row);

        assert_eq!(
            normalized.get("custom-vendor-field"),
            Some(&"kept".to_owned())
        );
        assert_eq!(
            normalized.get("mac_address"),
            Some(&"AA:BB:CC:DD:EE:FF".to_owned())
        );
    }

    #[test]
    fn command_support_tracks_v6_v7_differences() {
        assert!(!ClassicDialect::V6.command_supported("rest"));
        assert!(ClassicDialect::V6.command_supported("interface print"));
        assert!(ClassicDialect::V7.command_supported("rest"));
        assert!(ClassicDialect::Unknown.command_supported("future command"));
    }

    #[test]
    fn dialect_specific_error_hints_are_available() {
        assert_eq!(
            ClassicDialect::V6
                .error_hint("unknown parameter foo")
                .as_deref(),
            Some("RouterOS v6 may use a different field name; inspect schema/print output first"),
        );
        assert_eq!(
            ClassicDialect::V7.error_hint("item not found").as_deref(),
            Some("RouterOS v7 REST/native paths can differ; verify command support"),
        );
        assert_eq!(
            ClassicDialect::Unknown
                .error_hint("no such item")
                .as_deref(),
            Some("refresh item IDs with a print command before retrying"),
        );
    }

    #[test]
    fn normalizes_rows_for_snapshot_like_comparisons() {
        let rows = vec![BTreeMap::from([
            ("address".to_owned(), "192.0.2.1/24".to_owned()),
            ("actual-interface".to_owned(), "bridge".to_owned()),
        ])];

        let normalized = normalize_rows(&ClassicDialect::V7, "ip address print", &rows);

        assert_eq!(normalized[0].get("interface"), Some(&"bridge".to_owned()));
        assert_eq!(
            normalized[0].get("address"),
            Some(&"192.0.2.1/24".to_owned())
        );
    }
}