pointlock_provider_devicerail/
endpoint.rs1use std::collections::BTreeMap;
10
11use pointlock_ir::ErrorClass;
12use pointlock_provider_kit::{ProviderError, RetryableSource};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16pub const DEFAULT_DAEMON_COMMAND: &str = "devicerail-daemon";
18
19pub const DEFAULT_SHUTDOWN_GRACE_MS: u64 = 5_000;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct SpawnSpec {
26 #[serde(default = "default_command")]
28 pub command: String,
29 #[serde(default)]
32 pub args: Vec<String>,
33 #[serde(default)]
35 pub env: BTreeMap<String, String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub cwd: Option<String>,
39 #[serde(default = "default_shutdown_grace_ms")]
41 pub shutdown_grace_ms: u64,
42}
43
44fn default_command() -> String {
45 DEFAULT_DAEMON_COMMAND.to_owned()
46}
47
48fn default_shutdown_grace_ms() -> u64 {
49 DEFAULT_SHUTDOWN_GRACE_MS
50}
51
52#[derive(Debug, Clone, PartialEq, Deserialize)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55struct EndpointWire {
56 #[serde(default)]
57 spawn: Option<SpawnSpec>,
58 #[serde(default)]
59 attach: Option<Value>,
60}
61
62pub(crate) fn parse_spawn_endpoint(endpoint: &Value) -> Result<SpawnSpec, ProviderError> {
64 let invalid = |detail: String| {
65 ProviderError::new(
66 ErrorClass::BindArgumentsInvalid,
67 format!("invalid DeviceRail endpoint: {detail}"),
68 RetryableSource::Classifier,
69 )
70 };
71 let wire: EndpointWire =
72 serde_json::from_value(endpoint.clone()).map_err(|error| invalid(error.to_string()))?;
73 match (wire.spawn, wire.attach) {
74 (Some(spawn), None) => Ok(spawn),
75 (None, Some(_)) => Err(invalid(
76 "the attach endpoint form is reserved for a later milestone; \
77 M1 implements spawn only (04 §9.1)"
78 .to_owned(),
79 )),
80 (Some(_), Some(_)) => Err(invalid(
81 "endpoint must be exactly one of { spawn } | { attach }".to_owned(),
82 )),
83 (None, None) => Err(invalid(
84 "endpoint must carry a { spawn: SpawnSpec } object".to_owned(),
85 )),
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use serde_json::json;
93
94 #[test]
95 fn spawn_endpoint_defaults_apply() {
96 let spec = parse_spawn_endpoint(&json!({ "spawn": {} })).expect("parse");
97 assert_eq!(spec.command, "devicerail-daemon");
98 assert!(spec.args.is_empty());
99 assert_eq!(spec.shutdown_grace_ms, 5_000);
100 }
101
102 #[test]
103 fn spawn_endpoint_round_trips_explicit_fields() {
104 let spec = parse_spawn_endpoint(&json!({
105 "spawn": {
106 "command": "/opt/devicerail/bin/devicerail-daemon",
107 "args": ["--verbose"],
108 "env": { "DEVICERAIL_ANDROID": "off" },
109 "cwd": "/tmp/run",
110 "shutdownGraceMs": 250
111 }
112 }))
113 .expect("parse");
114 assert_eq!(spec.command, "/opt/devicerail/bin/devicerail-daemon");
115 assert_eq!(spec.args, ["--verbose"]);
116 assert_eq!(spec.env["DEVICERAIL_ANDROID"], "off");
117 assert_eq!(spec.cwd.as_deref(), Some("/tmp/run"));
118 assert_eq!(spec.shutdown_grace_ms, 250);
119 }
120
121 #[test]
122 fn attach_and_malformed_endpoints_are_rejected_typed() {
123 for endpoint in [
124 json!({ "attach": { "transport": "socket" } }),
125 json!({}),
126 json!({ "spawn": {}, "attach": {} }),
127 json!({ "spwan": {} }),
128 ] {
129 let error = parse_spawn_endpoint(&endpoint).expect_err("rejected");
130 assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
131 }
132 }
133}