Skip to main content

concinnity_dev/debug/
protocol.rs

1// src/debug/protocol.rs
2//
3// Pure, socket-free helpers shared by the `cn debug` client commands: request
4// payload validation, reply inspection, and the `watch` target enum. Kept out
5// of the transport module (`super::wire::client`) so they stay unit-testable
6// without a live server.
7
8use serde_json::Value;
9
10// Validate a raw `send` payload: it must be a JSON object carrying a "cmd"
11// field. Returns the re-serialized object on success.
12pub(super) fn validate_payload(json: &str) -> Result<String, String> {
13    let value: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?;
14    let obj = value
15        .as_object()
16        .ok_or_else(|| "JSON must be an object with a \"cmd\" field".to_string())?;
17    if !obj.contains_key("cmd") {
18        return Err("JSON object must include a \"cmd\" field".to_string());
19    }
20    Ok(value.to_string())
21}
22
23// True when a reply carries `"ok": true`.
24pub(super) fn reply_ok(reply: &Value) -> bool {
25    reply.get("ok").and_then(Value::as_bool).unwrap_or(false)
26}
27
28/// A read-only snapshot the `watch` command can poll. Each maps to the matching
29/// server command; `label` names it in the poll banner. Argv parsing lives in
30/// the CLI binary, which maps its own value-enum onto this type.
31#[derive(Clone, Copy, Debug)]
32pub enum WatchTarget {
33    /// The camera's live pose.
34    Camera,
35    /// The world's system and component state.
36    State,
37    /// Streaming residency counts and byte budgets.
38    Streaming,
39    /// Per-frame CPU and GPU timings.
40    Profile,
41}
42
43impl WatchTarget {
44    pub(super) fn cmd(self) -> &'static str {
45        match self {
46            WatchTarget::Camera => "camera-get",
47            WatchTarget::State => "state",
48            WatchTarget::Streaming => "streaming",
49            WatchTarget::Profile => "profile",
50        }
51    }
52
53    pub(super) fn label(self) -> &'static str {
54        match self {
55            WatchTarget::Camera => "camera",
56            WatchTarget::State => "state",
57            WatchTarget::Streaming => "streaming",
58            WatchTarget::Profile => "profile",
59        }
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn validate_payload_accepts_object_with_cmd() {
69        let out = validate_payload(r#"{"cmd":"state"}"#).expect("valid payload");
70        let value: Value = serde_json::from_str(&out).unwrap();
71        assert_eq!(value.get("cmd").and_then(Value::as_str), Some("state"));
72    }
73
74    #[test]
75    fn validate_payload_preserves_extra_fields() {
76        let out = validate_payload(r#"{"cmd":"decal-remove","id":7}"#).expect("valid payload");
77        let value: Value = serde_json::from_str(&out).unwrap();
78        assert_eq!(value.get("id").and_then(Value::as_u64), Some(7));
79    }
80
81    #[test]
82    fn validate_payload_rejects_invalid_json() {
83        assert!(validate_payload("{not json").is_err());
84    }
85
86    #[test]
87    fn validate_payload_rejects_non_object() {
88        assert!(validate_payload(r#"["cmd","state"]"#).is_err());
89        assert!(validate_payload(r#""state""#).is_err());
90    }
91
92    #[test]
93    fn validate_payload_rejects_missing_cmd() {
94        assert!(validate_payload(r#"{"id":1}"#).is_err());
95    }
96
97    #[test]
98    fn reply_ok_reads_the_ok_field() {
99        assert!(reply_ok(&serde_json::json!({ "ok": true })));
100        assert!(!reply_ok(&serde_json::json!({ "ok": false })));
101        assert!(!reply_ok(&serde_json::json!({ "pong": true })));
102    }
103
104    #[test]
105    fn watch_target_maps_to_server_commands() {
106        assert_eq!(WatchTarget::Camera.cmd(), "camera-get");
107        assert_eq!(WatchTarget::State.cmd(), "state");
108        assert_eq!(WatchTarget::Streaming.cmd(), "streaming");
109        assert_eq!(WatchTarget::Profile.cmd(), "profile");
110        assert_eq!(WatchTarget::Camera.label(), "camera");
111        assert_eq!(WatchTarget::Profile.label(), "profile");
112    }
113}