shvrpc 15.0.0

Rust implementation of the SHV-RPC protocol
Documentation
use glob::Pattern;
use serde::{Deserialize, Serialize};
use shvproto::RpcValue;
use std::fmt::{Display, Formatter};

#[derive(Debug)]
pub struct Glob {
    path: Pattern,
    path_star_prefix: Option<Pattern>,
    method: Pattern,
    signal: Option<Pattern>,
    ri: ShvRI,
}

impl Glob {
    pub fn match_shv_ri(&self, shv_ri: &ShvRI) -> bool {
        // if method is granted => signal is granted as well
        if !self.path.matches(shv_ri.path())
            && !self.path_star_prefix.as_ref().is_some_and(|prefix| prefix.matches(shv_ri.path()))
        {
            return false;
        }
        if !self.method.matches(shv_ri.method()) {
            return false;
        }
        // path and method match
        match (&self.signal, shv_ri.signal()) {
            (Some(glob_signal_pattern), Some(ri_signal)) => glob_signal_pattern.matches(ri_signal),
            (Some(_glob_signal_pattern), None) => false,
            _ => true,
        }
    }
    pub fn as_ri(&self) -> &ShvRI {
        &self.ri
    }
    pub fn as_str(&self) -> &str {
        self.ri.as_str()
    }
    pub fn path_str(&self) -> &str {
        self.path.as_str()
    }
    pub fn method_str(&self) -> &str {
        self.method.as_str()
    }
    pub fn signal_str(&self) -> Option<&str> {
        self.signal.as_ref().map(Pattern::as_str)
    }
}
impl TryFrom<&str> for Glob {
    type Error = String;
    fn try_from(s: &str) -> std::result::Result<Self, <Self as TryFrom<&str>>::Error> {
        let ri = ShvRI::try_from(s)?;
        ri.to_glob()
    }
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ShvRI {
    ri: String,
    method_sep_ix: usize,
    signal_sep_ix: Option<usize>,
}

impl ShvRI {
    pub fn path(&self) -> &str {
        #[expect(clippy::string_slice, reason = "VWe expect ASCII strings")]
        &self.ri[0..self.method_sep_ix]
    }
    pub fn method(&self) -> &str {
        #[expect(clippy::string_slice, reason = "We expect ASCII strings")]
        self.signal_sep_ix.map_or_else(|| &self.ri[self.method_sep_ix + 1..], |ix| &self.ri[self.method_sep_ix + 1..ix])
    }
    pub fn signal(&self) -> Option<&str> {
        #[expect(clippy::string_slice, reason = "We expect ASCII strings")]
        self.signal_sep_ix.map(|ix| &self.ri[ix + 1..])
    }
    pub fn has_signal(&self) -> bool {
        self.signal_sep_ix.is_some()
    }
    pub fn to_glob(&self) -> Result<Glob, String> {
        Ok(Glob {
            path: Pattern::new(self.path())
                .map_err(|e| format!("Parse path glob: '{self}' error: {e}"))?,
            path_star_prefix: self.path()
                .strip_suffix("/**")
                .and_then(|prefix| Pattern::new(prefix).ok()),
            method: Pattern::new(self.method())
                .map_err(|e| format!("Parse method glob: '{self}' error: {e}"))?,
            signal: if let Some(signal) = self.signal() {
                Some(
                    Pattern::new(signal)
                        .map_err(|e| format!("Parse signal glob: '{self}' error: {e}"))?,
                )
            } else {
                None
            },
            ri: self.clone(),
        })
    }
    pub fn as_str(&self) -> &str {
        &self.ri
    }
    pub fn from_path_method_signal(path: &str, method: &str, signal: Option<&str>) -> Result<Self, String> {
        let ri = signal.map_or_else(|| format!("{path}:{method}"), |signal| {
            let method = if method.is_empty() { "*" } else { method };
            format!("{path}:{method}:{signal}")
        });
        Ok(Self::try_from(ri)?)
    }
}
impl TryFrom<&str> for ShvRI {
    type Error = &'static str;
    fn try_from(s: &str) -> std::result::Result<Self, <Self as TryFrom<&str>>::Error> {
        let ri = s.to_owned();
        Self::try_from(ri)
    }
}
impl TryFrom<String> for ShvRI {
    type Error = &'static str;
    fn try_from(s: String) -> std::result::Result<Self, <Self as TryFrom<String>>::Error> {
        #[expect(clippy::string_slice, reason = "VWe expect ASCII strings")]
        let Some(method_sep_ix) = s[..].find(':') else {
            return Err("Method separtor ':' is missing.");
        };
        #[expect(clippy::string_slice, reason = "VWe expect ASCII strings")]
        let signal_sep_ix = s[method_sep_ix + 1..]
            .find(':')
            .map(|ix| ix + method_sep_ix + 1);
        let ri = ShvRI {
            ri: s,
            method_sep_ix,
            signal_sep_ix,
        };
        if ri.method().is_empty() {
            Err("Method must not be empty.")
        } else if ri.signal().is_some_and(str::is_empty) {
            Err("Signal, if present, must not be empty.")
        }
         else {
            Ok(ri)
        }
    }
}
impl Display for ShvRI {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
        write!(f, "{}", self.as_str())
    }
}
#[derive(Debug, Clone, PartialEq)]
pub struct SubscriptionParam {
    pub ri: ShvRI,
    pub ttl: Option<u32>,
}
impl SubscriptionParam {
    pub fn from_rpcvalue(value: &RpcValue) -> Result<Self, String> {
        if value.is_map() {
            let m = value.as_map();
            let paths = m
                .get("paths")
                .unwrap_or_else(|| m.get("path").unwrap_or_default())
                .as_str();
            let source = m.get("source").unwrap_or_default().as_str();
            let signal = m
                .get("signal")
                .or_else(|| m.get("methods"))
                .or_else(|| m.get("method"))
                .map(RpcValue::as_str);
            if paths.is_empty() && source.is_empty() && signal.is_none() {
                Err("Empty map".into())
            } else {
                Ok(SubscriptionParam {
                    ri: ShvRI::from_path_method_signal(paths, source, signal)?,
                    ttl: None,
                })
            }
        } else if value.is_list() {
            let lst = value.as_list();
            let ri = lst.first().map(RpcValue::as_str).unwrap_or_default();
            if ri.is_empty() {
                Err("Empty SHV RI".into())
            } else {
                let ttl = lst.get(1).unwrap_or_default().clone();
                Ok(SubscriptionParam {
                    ri: ri.try_into()?,
                    ttl: if ttl.is_null() { None } else { Some(ttl.as_u32()) },
                })
            }
        } else if value.is_string() {
            let ri = value.as_str();
            if ri.is_empty() {
                Err("Empty SHV RI".into())
            } else {
                Ok(SubscriptionParam {
                    ri: ri.try_into()?,
                    ttl: None,
                })
            }        } else {
            Err("Unsupported RPC value type.".into())
        }
    }
    pub fn to_rpcvalue(&self) -> RpcValue {
        let lst = vec![
            RpcValue::from(self.ri.to_string()),
            RpcValue::from(self.ttl),
        ];
        RpcValue::from(lst)
    }
}
impl Display for SubscriptionParam {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
        write!(f, "{{ri: '{}', ttl: {:?}}}", self.ri, self.ttl)
    }
}

#[cfg(test)]
mod tests {
    use crate::rpc::{ShvRI, SubscriptionParam};

    #[test]
    fn test_shvri() -> Result<(), String> {
        for (ri, path, method, signal, glob) in [
            (":*:*", "", "*", Some("*"), ":*:*"),
            ("some/path:method:signal", "some/path", "method", Some("signal"), "some/path:method:signal",),
            (":*", "", "*", None, ":*"),
            ("**:*:*", "**", "*", Some("*"), "**:*:*"),
        ] {
            let ri = ShvRI::try_from(ri)?;
            assert_eq!(
                (ri.path(), ri.method(), ri.signal(), ri.to_glob()?.as_str()),
                (path, method, signal, glob)
            );
        }
        Ok(())
    }
    #[test]
    #[should_panic]
    fn test_invalid_shvri() {
        ShvRI::try_from("::").unwrap();
    }
    #[test]
    fn test_glob() -> Result<(), String> {
        for (path, ri, is_match) in vec![
            (".app:name", "**:*", true),
            (".app:name", "**:get", false),
            (".app:name", "test:*", false),
            (".app:name", "test/**:get:*chng", false),

            ("sub/device/track:get", "**:*", true),
            ("sub/device/track:get", "**:get", true),
            ("sub/device/track:get", "test:*", false),
            ("sub/device/track:get", "test/**:get:*chng", false),

            ("test/device/track:get", "**:*", true),
            ("test/device/track:get", "**:get", true),
            ("test/device/track:get", "test/**:*", true),
            ("test/device/track:get", "test/**:get:*chng", false),
            ("test:get", "test/**:*", true),
            ("test:get", "test/*/**:*", false),
            ("test/foo:get", "test/*/**:*", true),
            ("test/foo/bar:get", "test/*/**:*", true),

            ("test/device/track:get:chng", "**:*:*", true),
            ("test/device/track:get:chng", "**:get:*", true),
            ("test/device/track:get:chng", "test/**:get:*chng", true),
            ("test/device/track:get:chng", "test/*:ls:lsmod", false),
            ("test/device/track:get:chng", "test/**:get", true),

            ("test/device/track:get:mod", "**:*:*", true),
            ("test/device/track:get:mod", "**:get:*", true),
            ("test/device/track:get:mod", "test/**:get:*chng", false),
            ("test/device/track:get:mod", "test/*:ls:lsmod", false),
            ("test/device/track:get:mod", "test/**:get", true),

            ("test/device/track:ls:lsmod", "**:*:*", true),
            ("test/device/track:ls:lsmod", "**:get:*", false),
            ("test/device/track:ls:lsmod", "test/**:get:*chng", false),
            ("test/device/track:ls:lsmod", "test/*:ls:lsmod", true),
            ("test/device/track:ls:lsmod", "test/**:get", false),

            ("test/device/track:*:chng", "test/device/*:*:chng", true),
            ("test/device/track:*:chng", "test/device/track:*:chng", true),
            ("test/device/track:*:chng", "test/device/track:*:chng", true),

            ("value:*:chng", "value:*:*", true),
        ] {
            // println!("{path} {ri}");
            let glob = ShvRI::try_from(ri)?.to_glob()?;
            let m = glob.match_shv_ri(&ShvRI::try_from(path)?);
            assert_eq!(m, is_match, "glob: {}, ri: {path}, matches: {m}, expect: {is_match}", glob.as_str());
        }
        Ok(())
    }
    #[test]
    fn test_subscription_param() -> Result<(), String> {
        for sp1 in [
            SubscriptionParam { ri: ShvRI::try_from("*:*:*")?, ttl: None },
            SubscriptionParam { ri: ShvRI::try_from("*:*:chng")?, ttl: Some(0) },
            SubscriptionParam { ri: ShvRI::try_from("*:*:fchng")?, ttl: Some(123) },
        ] {
            let rv = sp1.to_rpcvalue();
            let sp2 = SubscriptionParam::from_rpcvalue(&rv)?;
            assert_eq!(sp1, sp2);
        }
        Ok(())
    }
}