#[cfg(unix)]
use std::future::Future;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::time::Duration;
use degenbot_config::EnvVars;
use serde_json::{json, Map, Value};
#[cfg(unix)]
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
#[cfg(unix)]
use tokio::net::UnixStream;
use crate::error::CliError;
pub const SOCKET_ENV: &str = "DEGENBOT_OPERATOR_SOCKET";
pub const SOCKET_DEFAULT: &str = "~/.config/degenbot/operator.sock";
const HOME_ENV: &str = "HOME";
#[cfg(unix)]
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(unix))]
const UDS_UNSUPPORTED: &str = "the operator command channel requires a Unix domain socket, \
which this platform does not provide";
pub const FLEET_POSTURE_THRESHOLD_KEYS: [&str; 6] = [
"cordon_enter_events",
"cordon_enter_window_ms",
"cordon_duty_percent",
"cordon_duty_window_ms",
"cordon_exit_clean_ms",
"cordon_sim_intake_floor",
];
pub const SIM_INTAKE_FLOOR_RESTORE: &str = "null";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathFamily {
V2,
V3,
V4,
}
impl PathFamily {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::V2 => "V2",
Self::V3 => "V3",
Self::V4 => "V4",
}
}
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw.to_ascii_uppercase().as_str() {
"V2" => Some(Self::V2),
"V3" => Some(Self::V3),
"V4" => Some(Self::V4),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathStep {
pub family: PathFamily,
pub address: String,
pub hash: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathDirection {
Zfo,
Ozf,
}
impl PathDirection {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Zfo => "zfo",
Self::Ozf => "ozf",
}
}
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"zfo" => Some(Self::Zfo),
"ozf" => Some(Self::Ozf),
_ => None,
}
}
#[must_use]
pub const fn is_zfo(self) -> bool {
matches!(self, Self::Zfo)
}
}
pub fn parse_hop_token(hop: &str) -> Result<PathStep, CliError> {
let parts: Vec<&str> = hop.split(':').collect();
let raw_family = parts.first().copied().unwrap_or_default();
let family = PathFamily::parse(raw_family).ok_or_else(|| {
CliError::OperatorHygiene(format!("--hop family must be V2|V3|V4, got {raw_family:?}"))
})?;
let address = parts
.get(1)
.copied()
.filter(|address| !address.is_empty())
.ok_or_else(|| CliError::OperatorHygiene(format!("--hop {hop:?} is missing an address")))?;
let hash = if family == PathFamily::V4 {
parts
.get(2)
.copied()
.filter(|hash| !hash.is_empty())
.map(ToString::to_string)
} else {
None
};
Ok(PathStep {
family,
address: address.to_string(),
hash,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PosturePatchValue {
Int(u64),
Float(f64),
Null,
}
impl PosturePatchValue {
#[must_use]
pub fn json_value(self) -> Value {
match self {
Self::Int(value) => Value::from(value),
Self::Float(value) => {
serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number)
}
Self::Null => Value::Null,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PosturePatchEntry {
pub key: String,
pub value: PosturePatchValue,
}
impl PosturePatchEntry {
#[must_use]
pub fn new(key: impl Into<String>, value: PosturePatchValue) -> Self {
Self {
key: key.into(),
value,
}
}
#[must_use]
pub fn int(key: impl Into<String>, value: u64) -> Self {
Self::new(key, PosturePatchValue::Int(value))
}
#[must_use]
pub fn float(key: impl Into<String>, value: f64) -> Self {
Self::new(key, PosturePatchValue::Float(value))
}
}
pub fn parse_sim_intake_floor(raw: &str) -> Result<PosturePatchValue, CliError> {
if raw.eq_ignore_ascii_case(SIM_INTAKE_FLOOR_RESTORE) {
return Ok(PosturePatchValue::Null);
}
raw.trim()
.parse::<u64>()
.map(PosturePatchValue::Int)
.map_err(|_| {
CliError::OperatorHygiene(format!(
"cordon_sim_intake_floor must be an integer or {SIM_INTAKE_FLOOR_RESTORE:?} to restore half the slot cap, got {raw:?}"
))
})
}
pub fn validate_posture_patch(patch: &[PosturePatchEntry]) -> Result<(), CliError> {
let mut unknown: Vec<&str> = patch
.iter()
.map(|entry| entry.key.as_str())
.filter(|key| !FLEET_POSTURE_THRESHOLD_KEYS.contains(key))
.collect();
unknown.sort_unstable();
unknown.dedup();
if !unknown.is_empty() {
return Err(CliError::OperatorHygiene(format!(
"unknown fleet-posture threshold key(s): {}",
unknown.join(", ")
)));
}
if patch.is_empty() {
return Err(CliError::OperatorHygiene(
"set_fleet_posture needs at least one threshold key (empty patch)".to_string(),
));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub enum WireRequest {
AddPath {
steps: Vec<PathStep>,
directions: Option<Vec<bool>>,
},
Discover {
bound: Option<u64>,
},
SetFleetPosture {
patch: Vec<PosturePatchEntry>,
},
GetFleetPosture,
}
impl WireRequest {
#[must_use]
pub const fn op_name(&self) -> &'static str {
match self {
Self::AddPath { .. } => "add_path",
Self::Discover { .. } => "discover",
Self::SetFleetPosture { .. } => "set_fleet_posture",
Self::GetFleetPosture => "get_fleet_posture",
}
}
#[must_use]
pub fn payload(&self) -> Value {
match self {
Self::AddPath { steps, directions } => {
let steps: Vec<Value> = steps.iter().map(step_json).collect();
let directions = directions.as_ref().map_or(Value::Null, |bits| json!(bits));
json!({ "steps": steps, "directions": directions })
}
Self::Discover { bound } => json!({ "bound": bound }),
Self::SetFleetPosture { patch } => {
let mut payload = Map::new();
for entry in patch {
payload.insert(entry.key.clone(), entry.value.json_value());
}
Value::Object(payload)
}
Self::GetFleetPosture => json!({}),
}
}
#[must_use]
pub fn encode_line(&self) -> String {
let mut envelope = Map::new();
envelope.insert("op".to_string(), Value::from(self.op_name()));
envelope.insert("payload".to_string(), self.payload());
let mut line =
serde_json::to_string(&Value::Object(envelope)).unwrap_or_else(|_| "{}".to_string());
line.push('\n');
line
}
}
fn step_json(step: &PathStep) -> Value {
let mut object = Map::new();
object.insert("family".to_string(), Value::from(step.family.as_str()));
object.insert("address".to_string(), Value::from(step.address.clone()));
if let Some(hash) = &step.hash {
object.insert("hash".to_string(), Value::from(hash.clone()));
}
Value::Object(object)
}
#[derive(Debug, Clone, PartialEq)]
pub enum WireResponse {
Ok {
detail: String,
effective: Option<Value>,
},
Err {
error: String,
},
}
impl WireResponse {
#[must_use]
pub const fn is_ok(&self) -> bool {
matches!(self, Self::Ok { .. })
}
#[must_use]
pub fn detail(&self) -> &str {
match self {
Self::Ok { detail, .. } => detail,
Self::Err { .. } => "",
}
}
#[must_use]
pub fn effective(&self) -> Option<&Value> {
match self {
Self::Ok { effective, .. } => effective.as_ref(),
Self::Err { .. } => None,
}
}
#[must_use]
pub fn error(&self) -> Option<&str> {
match self {
Self::Err { error } => Some(error),
Self::Ok { .. } => None,
}
}
}
pub fn decode_response(line: &str) -> Result<WireResponse, CliError> {
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
return Err(CliError::OperatorProtocol(
"operator host sent an empty response line".to_string(),
));
}
let value: Value = serde_json::from_str(trimmed).map_err(|err| {
CliError::OperatorProtocol(format!("invalid operator response JSON: {err}"))
})?;
let Some(object) = value.as_object() else {
return Err(CliError::OperatorProtocol(
"operator response is not a JSON object".to_string(),
));
};
match object.get("ok") {
Some(Value::Bool(true)) => Ok(WireResponse::Ok {
detail: object
.get("detail")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
effective: object.get("effective").cloned(),
}),
Some(Value::Bool(false)) => Ok(WireResponse::Err {
error: object
.get("error")
.and_then(Value::as_str)
.unwrap_or("operator host reported failure without an error message")
.to_string(),
}),
Some(_) => Err(CliError::OperatorProtocol(
"operator response 'ok' is not a boolean".to_string(),
)),
None => Err(CliError::OperatorProtocol(
"operator response is missing 'ok'".to_string(),
)),
}
}
#[must_use]
pub fn render_json_sorted(value: Option<&Value>) -> String {
match value {
Some(Value::Object(map)) => {
let mut entries: Vec<(&String, &Value)> = map.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let inner = entries
.iter()
.map(|(key, value)| {
let key = serde_json::to_string(key).unwrap_or_default();
let value = serde_json::to_string(value).unwrap_or_default();
format!("{key}: {value}")
})
.collect::<Vec<_>>()
.join(", ");
format!("{{{inner}}}")
}
Some(other) => serde_json::to_string(other).unwrap_or_default(),
None => "{}".to_string(),
}
}
#[must_use]
pub fn resolve_socket(env: &dyn EnvVars, cli_socket: Option<&str>) -> PathBuf {
let env_value = env.get(SOCKET_ENV);
let raw = match (non_empty(cli_socket), non_empty(env_value.as_deref())) {
(Some(cli), _) => cli,
(None, Some(value)) => value,
(None, None) => SOCKET_DEFAULT,
};
expand_tilde(env, raw)
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.filter(|value| !value.is_empty())
}
fn expand_tilde(env: &dyn EnvVars, raw: &str) -> PathBuf {
let home = env.get(HOME_ENV).filter(|home| !home.is_empty());
if raw == "~" {
if let Some(home) = home {
return PathBuf::from(home);
}
} else if let Some(rest) = raw.strip_prefix("~/") {
if let Some(home) = home {
return Path::new(&home).join(rest);
}
}
PathBuf::from(raw)
}
pub fn send_request(socket: &Path, request: &WireRequest) -> Result<WireResponse, CliError> {
let line = request.encode_line();
exchange_blocking(socket, line)
}
#[cfg(unix)]
fn exchange_blocking(socket: &Path, line: String) -> Result<WireResponse, CliError> {
block_on_operator(exchange(socket, line))?
}
#[cfg(not(unix))]
fn exchange_blocking(socket: &Path, line: String) -> Result<WireResponse, CliError> {
let _ = (socket, line);
Err(CliError::OperatorProtocol(UDS_UNSUPPORTED.to_string()))
}
#[cfg(unix)]
fn block_on_operator<F: Future>(future: F) -> Result<F::Output, CliError> {
crate::block::block_on(future).map_err(|err| match err {
CliError::RuntimeNested => err,
other => CliError::OperatorProtocol(other.message()),
})
}
#[cfg(unix)]
async fn exchange(socket: &Path, line: String) -> Result<WireResponse, CliError> {
let connect = tokio::time::timeout(REQUEST_TIMEOUT, UnixStream::connect(socket))
.await
.map_err(|_| {
CliError::OperatorProtocol(format!(
"timed out connecting to operator socket {}",
socket.display()
))
})?;
let mut stream = connect.map_err(|err| {
CliError::OperatorProtocol(format!(
"cannot reach operator socket {}: {err}",
socket.display()
))
})?;
stream.write_all(line.as_bytes()).await.map_err(|err| {
CliError::OperatorProtocol(format!(
"failed writing to operator socket {}: {err}",
socket.display()
))
})?;
stream.flush().await.map_err(|err| {
CliError::OperatorProtocol(format!(
"failed flushing operator socket {}: {err}",
socket.display()
))
})?;
let mut reader = BufReader::new(stream);
let mut buffer = Vec::new();
let read = tokio::time::timeout(REQUEST_TIMEOUT, reader.read_until(b'\n', &mut buffer))
.await
.map_err(|_| {
CliError::OperatorProtocol(format!(
"timed out waiting for a response from operator socket {}",
socket.display()
))
})?;
let read = read.map_err(|err| {
CliError::OperatorProtocol(format!(
"failed reading from operator socket {}: {err}",
socket.display()
))
})?;
if read == 0 {
return Err(CliError::OperatorProtocol(format!(
"no response from operator server at {}",
socket.display()
)));
}
let text = String::from_utf8(buffer).map_err(|err| {
CliError::OperatorProtocol(format!("operator response is not valid UTF-8: {err}"))
})?;
decode_response(&text)
}