roswire 0.1.1

JSON-first RouterOS CLI bridge for AI agents and automation.
use serde::Serialize;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};

pub const DEFAULT_REMOTE_SCHEMA_TTL_SECONDS: u64 = 604_800;

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DeviceFingerprint {
    pub host_id_hashed: String,
    pub routeros_version: String,
    pub build_time: String,
    pub architecture: String,
    pub board_name: String,
    pub packages_hash: String,
    pub selected_protocol: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CacheMeta {
    pub profile_name: String,
    pub cache_key: String,
    pub created_unix: u64,
    pub ttl_seconds: u64,
    pub fingerprint: DeviceFingerprint,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum CacheLookupStatus {
    Hit,
    Miss,
    Stale,
    Refresh,
}

impl CacheLookupStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Hit => "hit",
            Self::Miss => "miss",
            Self::Stale => "stale",
            Self::Refresh => "refresh",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CacheLookup {
    pub status: CacheLookupStatus,
    pub should_refresh: bool,
    pub cache_key: String,
    pub ttl_seconds: u64,
}

pub fn hash_host_id(host: &str) -> String {
    let mut hasher = DefaultHasher::new();
    host.hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

pub fn compute_cache_key(profile_name: &str, fingerprint: &DeviceFingerprint) -> String {
    let mut hasher = DefaultHasher::new();
    profile_name.hash(&mut hasher);
    fingerprint.host_id_hashed.hash(&mut hasher);
    fingerprint.routeros_version.hash(&mut hasher);
    fingerprint.build_time.hash(&mut hasher);
    fingerprint.architecture.hash(&mut hasher);
    fingerprint.board_name.hash(&mut hasher);
    fingerprint.packages_hash.hash(&mut hasher);
    fingerprint.selected_protocol.hash(&mut hasher);
    format!("cache:{:016x}", hasher.finish())
}

pub fn new_cache_meta(
    profile_name: &str,
    created_unix: u64,
    ttl_seconds: u64,
    fingerprint: DeviceFingerprint,
) -> CacheMeta {
    let cache_key = compute_cache_key(profile_name, &fingerprint);
    CacheMeta {
        profile_name: profile_name.to_owned(),
        cache_key,
        created_unix,
        ttl_seconds,
        fingerprint,
    }
}

pub fn is_cache_stale(meta: &CacheMeta, now_unix: u64, current: &DeviceFingerprint) -> bool {
    if now_unix.saturating_sub(meta.created_unix) > meta.ttl_seconds {
        return true;
    }

    if &meta.fingerprint != current {
        return true;
    }

    let current_key = compute_cache_key(&meta.profile_name, current);
    meta.cache_key != current_key
}

pub fn evaluate_cache_lookup(
    profile_name: &str,
    cached: Option<&CacheMeta>,
    now_unix: u64,
    current: &DeviceFingerprint,
    refresh_requested: bool,
) -> CacheLookup {
    let status = if refresh_requested {
        CacheLookupStatus::Refresh
    } else {
        match cached {
            None => CacheLookupStatus::Miss,
            Some(meta) if is_cache_stale(meta, now_unix, current) => CacheLookupStatus::Stale,
            Some(_) => CacheLookupStatus::Hit,
        }
    };

    let ttl_seconds = cached
        .map(|meta| meta.ttl_seconds)
        .unwrap_or(DEFAULT_REMOTE_SCHEMA_TTL_SECONDS);

    CacheLookup {
        status,
        should_refresh: !matches!(status, CacheLookupStatus::Hit),
        cache_key: compute_cache_key(profile_name, current),
        ttl_seconds,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        compute_cache_key, evaluate_cache_lookup, hash_host_id, is_cache_stale, new_cache_meta,
        CacheLookupStatus, DeviceFingerprint, DEFAULT_REMOTE_SCHEMA_TTL_SECONDS,
    };

    fn fingerprint(version: &str) -> DeviceFingerprint {
        DeviceFingerprint {
            host_id_hashed: hash_host_id("192.168.88.1"),
            routeros_version: version.to_owned(),
            build_time: "2026-01-01".to_owned(),
            architecture: "arm64".to_owned(),
            board_name: "RB5009".to_owned(),
            packages_hash: "pkg-hash".to_owned(),
            selected_protocol: "rest".to_owned(),
        }
    }

    #[test]
    fn host_hash_hides_plain_host_value() {
        let hashed = hash_host_id("192.168.88.1");
        assert!(!hashed.contains("192.168.88.1"));
        assert_eq!(hashed.len(), 16);
    }

    #[test]
    fn cache_key_changes_when_fingerprint_changes() {
        let v7 = fingerprint("7.15.3");
        let v6 = fingerprint("6.49.17");

        let key_v7 = compute_cache_key("home", &v7);
        let key_v6 = compute_cache_key("home", &v6);

        assert_ne!(key_v7, key_v6);
    }

    #[test]
    fn cache_is_stale_after_ttl() {
        let fp = fingerprint("7.15.3");
        let meta = new_cache_meta("home", 100, 10, fp.clone());

        assert!(is_cache_stale(&meta, 111, &fp));
    }

    #[test]
    fn cache_is_stale_when_fingerprint_changes() {
        let meta = new_cache_meta("home", 100, 600, fingerprint("7.15.3"));
        assert!(is_cache_stale(&meta, 200, &fingerprint("7.15.4")));
    }

    #[test]
    fn cache_is_fresh_when_ttl_and_fingerprint_match() {
        let fp = fingerprint("7.15.3");
        let meta = new_cache_meta("home", 100, 600, fp.clone());

        assert!(!is_cache_stale(&meta, 200, &fp));
    }

    #[test]
    fn cache_lookup_reports_miss_without_cached_meta() {
        let fp = fingerprint("7.15.3");
        let lookup = evaluate_cache_lookup("home", None, 200, &fp, false);

        assert_eq!(lookup.status, CacheLookupStatus::Miss);
        assert!(lookup.should_refresh);
        assert_eq!(lookup.ttl_seconds, DEFAULT_REMOTE_SCHEMA_TTL_SECONDS);
        assert!(lookup.cache_key.starts_with("cache:"));
    }

    #[test]
    fn cache_lookup_reports_hit_for_fresh_meta() {
        let fp = fingerprint("7.15.3");
        let meta = new_cache_meta("home", 100, 600, fp.clone());
        let lookup = evaluate_cache_lookup("home", Some(&meta), 200, &fp, false);

        assert_eq!(lookup.status, CacheLookupStatus::Hit);
        assert!(!lookup.should_refresh);
        assert_eq!(lookup.cache_key, meta.cache_key);
    }

    #[test]
    fn cache_lookup_reports_stale_for_ttl_or_fingerprint_change() {
        let fp = fingerprint("7.15.3");
        let stale_by_ttl = new_cache_meta("home", 100, 10, fp.clone());
        let stale_by_fingerprint = new_cache_meta("home", 100, 600, fp.clone());

        let ttl_lookup = evaluate_cache_lookup("home", Some(&stale_by_ttl), 111, &fp, false);
        let fingerprint_lookup = evaluate_cache_lookup(
            "home",
            Some(&stale_by_fingerprint),
            200,
            &fingerprint("7.15.4"),
            false,
        );

        assert_eq!(ttl_lookup.status, CacheLookupStatus::Stale);
        assert_eq!(fingerprint_lookup.status, CacheLookupStatus::Stale);
        assert!(ttl_lookup.should_refresh);
        assert!(fingerprint_lookup.should_refresh);
    }

    #[test]
    fn cache_lookup_reports_refresh_when_requested() {
        let fp = fingerprint("7.15.3");
        let meta = new_cache_meta("home", 100, 600, fp.clone());
        let lookup = evaluate_cache_lookup("home", Some(&meta), 200, &fp, true);

        assert_eq!(lookup.status, CacheLookupStatus::Refresh);
        assert!(lookup.should_refresh);
        assert_eq!(lookup.cache_key, meta.cache_key);
    }
}