use std::collections::BTreeMap;
use pointlock_ir::ErrorClass;
use pointlock_provider_kit::{ProviderError, RetryableSource};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const DEFAULT_DAEMON_COMMAND: &str = "devicerail-daemon";
pub const DEFAULT_SHUTDOWN_GRACE_MS: u64 = 5_000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SpawnSpec {
#[serde(default = "default_command")]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default = "default_shutdown_grace_ms")]
pub shutdown_grace_ms: u64,
}
fn default_command() -> String {
DEFAULT_DAEMON_COMMAND.to_owned()
}
fn default_shutdown_grace_ms() -> u64 {
DEFAULT_SHUTDOWN_GRACE_MS
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct EndpointWire {
#[serde(default)]
spawn: Option<SpawnSpec>,
#[serde(default)]
attach: Option<Value>,
}
pub(crate) fn parse_spawn_endpoint(endpoint: &Value) -> Result<SpawnSpec, ProviderError> {
let invalid = |detail: String| {
ProviderError::new(
ErrorClass::BindArgumentsInvalid,
format!("invalid DeviceRail endpoint: {detail}"),
RetryableSource::Classifier,
)
};
let wire: EndpointWire =
serde_json::from_value(endpoint.clone()).map_err(|error| invalid(error.to_string()))?;
match (wire.spawn, wire.attach) {
(Some(spawn), None) => Ok(spawn),
(None, Some(_)) => Err(invalid(
"the attach endpoint form is reserved for a later milestone; \
M1 implements spawn only (04 §9.1)"
.to_owned(),
)),
(Some(_), Some(_)) => Err(invalid(
"endpoint must be exactly one of { spawn } | { attach }".to_owned(),
)),
(None, None) => Err(invalid(
"endpoint must carry a { spawn: SpawnSpec } object".to_owned(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn spawn_endpoint_defaults_apply() {
let spec = parse_spawn_endpoint(&json!({ "spawn": {} })).expect("parse");
assert_eq!(spec.command, "devicerail-daemon");
assert!(spec.args.is_empty());
assert_eq!(spec.shutdown_grace_ms, 5_000);
}
#[test]
fn spawn_endpoint_round_trips_explicit_fields() {
let spec = parse_spawn_endpoint(&json!({
"spawn": {
"command": "/opt/devicerail/bin/devicerail-daemon",
"args": ["--verbose"],
"env": { "DEVICERAIL_ANDROID": "off" },
"cwd": "/tmp/run",
"shutdownGraceMs": 250
}
}))
.expect("parse");
assert_eq!(spec.command, "/opt/devicerail/bin/devicerail-daemon");
assert_eq!(spec.args, ["--verbose"]);
assert_eq!(spec.env["DEVICERAIL_ANDROID"], "off");
assert_eq!(spec.cwd.as_deref(), Some("/tmp/run"));
assert_eq!(spec.shutdown_grace_ms, 250);
}
#[test]
fn attach_and_malformed_endpoints_are_rejected_typed() {
for endpoint in [
json!({ "attach": { "transport": "socket" } }),
json!({}),
json!({ "spawn": {}, "attach": {} }),
json!({ "spwan": {} }),
] {
let error = parse_spawn_endpoint(&endpoint).expect_err("rejected");
assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
}
}
}