mhome-app-facade-api 1.6.0

Canonical public App Facade protocol for MeowLink clients
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionMode {
    #[default]
    Direct,
    Prepare,
    Commit,
    Reject,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponseFormat {
    Json,
    Text,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct FacadeControl {
    pub mode: ActionMode,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prepared_action_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval_token: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub response_format: Option<ResponseFormat>,
}

impl FacadeControl {
    pub fn validate(&self) -> Result<(), &'static str> {
        match self.mode {
            ActionMode::Direct | ActionMode::Prepare => {
                if self.prepared_action_id.is_some() || self.approval_token.is_some() {
                    return Err("direct and prepare controls cannot carry commit credentials");
                }
            }
            ActionMode::Commit => {
                if self
                    .prepared_action_id
                    .as_deref()
                    .is_none_or(|value| value.trim().is_empty())
                    || self
                        .approval_token
                        .as_deref()
                        .is_none_or(|value| value.trim().is_empty())
                {
                    return Err("commit control requires preparedActionId and approvalToken");
                }
            }
            ActionMode::Reject => {
                if self
                    .prepared_action_id
                    .as_deref()
                    .is_none_or(|value| value.trim().is_empty())
                {
                    return Err("reject control requires preparedActionId");
                }
                if self.approval_token.is_some() {
                    return Err("reject control cannot carry approvalToken");
                }
            }
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct FacadeCall {
    pub control: FacadeControl,
    pub input: Value,
}

impl FacadeCall {
    #[must_use]
    pub fn direct(input: Value) -> Self {
        Self {
            control: FacadeControl::default(),
            input,
        }
    }

    pub fn validate(&self) -> Result<(), &'static str> {
        self.control.validate()
    }
}

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

    #[test]
    fn direct_call_has_a_strict_envelope() {
        let call: FacadeCall = serde_json::from_value(serde_json::json!({
            "control": {"mode": "direct"},
            "input": {"value": 1}
        }))
        .unwrap();
        assert_eq!(call.control.mode, ActionMode::Direct);
        assert_eq!(call.input["value"], 1);
        assert!(serde_json::from_value::<FacadeCall>(serde_json::json!({
            "mode": "direct",
            "input": {}
        }))
        .is_err());
    }

    #[test]
    fn action_credentials_are_mode_specific() {
        let invalid = FacadeControl {
            mode: ActionMode::Commit,
            prepared_action_id: Some("action-1".into()),
            ..FacadeControl::default()
        };
        assert!(invalid.validate().is_err());

        let whitespace = FacadeControl {
            mode: ActionMode::Reject,
            prepared_action_id: Some(" \t".into()),
            ..FacadeControl::default()
        };
        assert!(whitespace.validate().is_err());
    }
}