roswire 0.1.0

JSON-first RouterOS CLI bridge for AI agents and automation.
pub mod dialect;
pub mod login;
pub mod sentence;
pub mod transport;

use crate::error::{RosWireError, RosWireResult};
use crate::mapping::ProtocolRequest;
use crate::protocol::classic::dialect::{ClassicDialect, Dialect};
use crate::protocol::RouterOsMajor;
use sentence::{parse_api_sentence, read_sentence, write_sentence, SentenceKind};
use std::collections::BTreeMap;
use transport::ApiStream;

#[derive(Debug)]
pub struct ClassicApiSession<S> {
    stream: S,
}

impl<S: ApiStream> ClassicApiSession<S> {
    pub fn new(stream: S) -> Self {
        Self { stream }
    }

    pub fn login(&mut self, user: &str, password: &str) -> RosWireResult<()> {
        login::login(&mut self.stream, user, password)
    }

    pub fn probe_resource(&mut self) -> RosWireResult<ResourceInfo> {
        probe_resource(&mut self.stream)
    }

    pub fn execute_request(
        &mut self,
        request: &ProtocolRequest,
    ) -> RosWireResult<Vec<BTreeMap<String, String>>> {
        self.execute_words(&request.classic_api_words())
    }

    pub fn execute_words(
        &mut self,
        words: &[String],
    ) -> RosWireResult<Vec<BTreeMap<String, String>>> {
        write_sentence(&mut self.stream, words)?;

        let mut rows = Vec::new();
        loop {
            let sentence_words = read_sentence(&mut self.stream)?;
            let sentence = parse_api_sentence(&sentence_words)?;
            match sentence.kind {
                SentenceKind::Re => rows.push(sentence.attributes),
                SentenceKind::Done => return Ok(rows),
                SentenceKind::Trap | SentenceKind::Fatal => {
                    return Err(Box::new(sentence.trap_error().unwrap_or_else(|| {
                        RosWireError::ros_api_failure("RouterOS API command failed")
                    })));
                }
                SentenceKind::Other(kind) => {
                    return Err(Box::new(RosWireError::ros_api_failure(format!(
                        "RouterOS API returned unsupported sentence kind: {kind}",
                    ))));
                }
            }
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceInfo {
    pub version: String,
    pub architecture: String,
    pub board_name: String,
}

impl ResourceInfo {
    pub fn routeros_major(&self) -> RouterOsMajor {
        ClassicDialect::from_resource_info(self).routeros_major()
    }
}

pub fn probe_resource<S: ApiStream + ?Sized>(stream: &mut S) -> RosWireResult<ResourceInfo> {
    write_sentence(
        stream,
        &[
            "/system/resource/print".to_owned(),
            "=.proplist=version,architecture-name,architecture,board-name".to_owned(),
        ],
    )?;

    let mut row = None;
    loop {
        let words = read_sentence(stream)?;
        let sentence = parse_api_sentence(&words)?;
        match sentence.kind {
            SentenceKind::Re => {
                row = Some(sentence.attributes);
            }
            SentenceKind::Done => {
                let Some(attributes) = row else {
                    return Err(Box::new(RosWireError::ros_api_failure(
                        "RouterOS resource probe returned no rows",
                    )));
                };
                return resource_info_from_attributes(&attributes);
            }
            SentenceKind::Trap | SentenceKind::Fatal => {
                return Err(Box::new(sentence.trap_error().unwrap_or_else(|| {
                    RosWireError::ros_api_failure("resource probe failed")
                })));
            }
            SentenceKind::Other(_) => continue,
        }
    }
}

fn resource_info_from_attributes(
    attributes: &BTreeMap<String, String>,
) -> RosWireResult<ResourceInfo> {
    let version = required_attribute(attributes, "version")?;
    let architecture = attributes
        .get("architecture-name")
        .or_else(|| attributes.get("architecture"))
        .cloned()
        .ok_or_else(|| {
            Box::new(RosWireError::ros_api_failure(
                "RouterOS resource probe did not include architecture",
            ))
        })?;
    let board_name = required_attribute(attributes, "board-name")?;

    Ok(ResourceInfo {
        version,
        architecture,
        board_name,
    })
}

fn required_attribute(attributes: &BTreeMap<String, String>, name: &str) -> RosWireResult<String> {
    attributes.get(name).cloned().ok_or_else(|| {
        Box::new(RosWireError::ros_api_failure(format!(
            "RouterOS resource probe did not include {name}",
        )))
    })
}

#[cfg(test)]
mod tests {
    use super::{probe_resource, ClassicApiSession, ResourceInfo};
    use crate::args::ParsedInvocation;
    use crate::error::ErrorCode;
    use crate::mapping::build_protocol_request;
    use crate::protocol::classic::sentence::{read_sentence, write_sentence};
    use crate::protocol::RouterOsMajor;
    use std::collections::BTreeMap;
    use std::io::{Cursor, Read, Result, Write};

    struct FakeApiStream {
        rx: Cursor<Vec<u8>>,
        tx: Vec<u8>,
    }

    impl FakeApiStream {
        fn with_sentences(sentences: &[Vec<String>]) -> Self {
            let mut rx = Vec::new();
            for sentence in sentences {
                write_sentence(&mut rx, sentence).expect("fixture sentence should encode");
            }
            Self {
                rx: Cursor::new(rx),
                tx: Vec::new(),
            }
        }

        fn written_sentences(&self) -> Vec<Vec<String>> {
            let mut cursor = Cursor::new(self.tx.clone());
            let mut sentences = Vec::new();
            while (cursor.position() as usize) < cursor.get_ref().len() {
                sentences.push(read_sentence(&mut cursor).expect("written sentence should decode"));
            }
            sentences
        }
    }

    impl Read for FakeApiStream {
        fn read(&mut self, buffer: &mut [u8]) -> Result<usize> {
            self.rx.read(buffer)
        }
    }

    impl Write for FakeApiStream {
        fn write(&mut self, buffer: &[u8]) -> Result<usize> {
            self.tx.extend_from_slice(buffer);
            Ok(buffer.len())
        }

        fn flush(&mut self) -> Result<()> {
            Ok(())
        }
    }

    #[test]
    fn resource_probe_parses_version_architecture_and_board() {
        let mut stream = FakeApiStream::with_sentences(&[
            vec![
                "!re".to_owned(),
                "=version=7.15.3".to_owned(),
                "=architecture-name=arm64".to_owned(),
                "=board-name=RB5009".to_owned(),
            ],
            vec!["!done".to_owned()],
        ]);

        let info = probe_resource(&mut stream).expect("resource probe should succeed");

        assert_eq!(info.version, "7.15.3");
        assert_eq!(info.architecture, "arm64");
        assert_eq!(info.board_name, "RB5009");
        assert_eq!(info.routeros_major(), RouterOsMajor::V7);
        assert_eq!(stream.written_sentences()[0][0], "/system/resource/print");
    }

    #[test]
    fn resource_info_major_handles_v6_v7_and_unknown() {
        let mut info = ResourceInfo {
            version: "6.49.10".to_owned(),
            architecture: "mipsbe".to_owned(),
            board_name: "RB2011".to_owned(),
        };
        assert_eq!(info.routeros_major(), RouterOsMajor::V6);

        info.version = "7.15.3".to_owned();
        assert_eq!(info.routeros_major(), RouterOsMajor::V7);

        info.version = "unknown".to_owned();
        assert_eq!(info.routeros_major(), RouterOsMajor::Unknown);
    }

    #[test]
    fn executor_collects_re_rows_until_done() {
        let stream = FakeApiStream::with_sentences(&[
            vec![
                "!re".to_owned(),
                "=.id=*1".to_owned(),
                "=name=ether1".to_owned(),
            ],
            vec![
                "!re".to_owned(),
                "=.id=*2".to_owned(),
                "=name=bridge".to_owned(),
            ],
            vec!["!done".to_owned()],
        ]);
        let mut session = ClassicApiSession::new(stream);
        let request = build_protocol_request(&ParsedInvocation {
            path: vec!["interface".to_owned()],
            action: "print".to_owned(),
            resolved_args: BTreeMap::new(),
        })
        .expect("request should map");

        let rows = session
            .execute_request(&request)
            .expect("executor should succeed");

        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].get("name").map(String::as_str), Some("ether1"));
        assert_eq!(rows[1].get(".id").map(String::as_str), Some("*2"));
        assert_eq!(session.stream.written_sentences()[0][0], "/interface/print");
    }

    #[test]
    fn executor_maps_trap_to_ros_api_failure() {
        let stream = FakeApiStream::with_sentences(&[vec![
            "!trap".to_owned(),
            "=message=no such item".to_owned(),
        ]]);
        let mut session = ClassicApiSession::new(stream);

        let error = session
            .execute_words(&["/ip/address/print".to_owned()])
            .expect_err("trap should fail");

        assert_eq!(error.error_code, ErrorCode::RosApiFailure);
        assert_eq!(error.message, "no such item");
    }

    #[test]
    fn executor_sends_write_requests_for_add_set_and_remove() {
        for (action, args, expected) in [
            (
                "add",
                vec![("address", "192.0.2.10/24"), ("interface", "bridge")],
                vec![
                    "/ip/address/add".to_owned(),
                    "=address=192.0.2.10/24".to_owned(),
                    "=interface=bridge".to_owned(),
                ],
            ),
            (
                "set",
                vec![(".id", "*1"), ("disabled", "yes")],
                vec![
                    "/ip/address/set".to_owned(),
                    "=.id=*1".to_owned(),
                    "=disabled=yes".to_owned(),
                ],
            ),
            (
                "remove",
                vec![(".id", "*1")],
                vec!["/ip/address/remove".to_owned(), "=.id=*1".to_owned()],
            ),
        ] {
            let stream = FakeApiStream::with_sentences(&[vec!["!done".to_owned()]]);
            let mut session = ClassicApiSession::new(stream);
            let request = build_protocol_request(&ParsedInvocation {
                path: vec!["ip".to_owned(), "address".to_owned()],
                action: action.to_owned(),
                resolved_args: args
                    .into_iter()
                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
                    .collect(),
            })
            .expect("write request should map");

            let rows = session
                .execute_request(&request)
                .expect("write request should execute");

            assert!(rows.is_empty());
            assert_eq!(session.stream.written_sentences()[0], expected);
        }
    }
}