Files
mxaccessgw/clients/rust/crates/mxgw-cli/src/main.rs
T
2026-04-29 10:39:49 -04:00

1079 lines
34 KiB
Rust

use std::env;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use clap::{Args, Parser, Subcommand, ValueEnum};
use futures_util::StreamExt;
use mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use mxgateway_client::generated::mxaccess_gateway::v1::{
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, OpenSessionRequest,
PingCommand, StreamEventsRequest,
};
use mxgateway_client::{
ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, CLIENT_VERSION,
GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
};
use serde_json::json;
use serde_json::Value;
const MAX_AGGREGATE_EVENTS: usize = 10_000;
#[derive(Debug, Parser)]
#[command(name = "mxgw")]
#[command(about = "MXAccess Gateway Rust test CLI")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Version {
#[arg(long)]
json: bool,
#[arg(long)]
jsonl: bool,
},
Ping {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long, default_value = "ping")]
message: String,
#[arg(long)]
json: bool,
},
OpenSession {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long, default_value = "mxgw-rust-cli")]
client_name: String,
#[arg(long)]
json: bool,
},
CloseSession {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
json: bool,
},
Register {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long, default_value = "mxgw-rust-cli")]
client_name: String,
#[arg(long)]
json: bool,
},
AddItem {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
item: String,
#[arg(long)]
json: bool,
},
Advise {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
item_handle: i32,
#[arg(long)]
json: bool,
},
SubscribeBulk {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long, value_delimiter = ',')]
items: Vec<String>,
#[arg(long)]
json: bool,
},
UnsubscribeBulk {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long, value_delimiter = ',')]
item_handles: Vec<i32>,
#[arg(long)]
json: bool,
},
StreamEvents {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long, default_value_t = 0)]
after_worker_sequence: u64,
#[arg(long, default_value_t = 1)]
max_events: usize,
#[arg(long)]
json: bool,
#[arg(long)]
jsonl: bool,
},
Write {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
item_handle: i32,
#[arg(long, value_enum)]
value_type: CliValueType,
#[arg(long)]
value: String,
#[arg(long, default_value_t = 0)]
user_id: i32,
#[arg(long)]
json: bool,
},
Write2 {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
item_handle: i32,
#[arg(long, value_enum)]
value_type: CliValueType,
#[arg(long)]
value: String,
#[arg(long)]
timestamp: String,
#[arg(long, default_value_t = 0)]
user_id: i32,
#[arg(long)]
json: bool,
},
Smoke {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
item: String,
#[arg(long, default_value = "mxgw-rust-smoke")]
client_name: String,
#[arg(long)]
json: bool,
},
#[command(subcommand)]
Galaxy(GalaxyCommand),
}
#[derive(Debug, Subcommand)]
enum GalaxyCommand {
TestConnection {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
json: bool,
},
LastDeployTime {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
json: bool,
},
DiscoverHierarchy {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
json: bool,
},
/// Subscribe to the WatchDeployEvents server stream.
///
/// Prints one line per received event (or one JSON object with `--json`).
/// Runs until the stream ends, the server fails the call, or the
/// process is interrupted (Ctrl+C).
#[command(alias = "watch-deploy-events")]
Watch {
#[command(flatten)]
connection: ConnectionArgs,
/// Optional RFC3339 timestamp (e.g. `2026-04-28T15:30:00Z`). When
/// supplied, the server suppresses the bootstrap event if its
/// cached deploy time matches this value.
#[arg(long)]
last_seen_deploy_time: Option<String>,
/// Optional cap on the number of events to print before exiting.
/// 0 (the default) means run until cancelled or the stream ends.
#[arg(long, default_value_t = 0)]
max_events: usize,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Args, Clone)]
struct ConnectionArgs {
#[arg(long, default_value = "http://127.0.0.1:5000")]
endpoint: String,
#[arg(long)]
api_key: Option<String>,
#[arg(long, default_value = "MXGATEWAY_API_KEY")]
api_key_env: String,
#[arg(long)]
plaintext: bool,
#[arg(long)]
tls: bool,
#[arg(long)]
ca_file: Option<PathBuf>,
#[arg(long)]
server_name_override: Option<String>,
#[arg(long, default_value_t = 10)]
connect_timeout_seconds: u64,
#[arg(long, default_value_t = 30)]
call_timeout_seconds: u64,
}
impl ConnectionArgs {
fn options(&self) -> ClientOptions {
let mut options = ClientOptions::new(self.endpoint.clone())
.with_plaintext(!self.tls || self.plaintext)
.with_connect_timeout(Duration::from_secs(self.connect_timeout_seconds))
.with_call_timeout(Duration::from_secs(self.call_timeout_seconds));
if let Some(api_key) = self
.api_key
.clone()
.or_else(|| env::var(&self.api_key_env).ok())
.filter(|value| !value.is_empty())
{
options = options.with_api_key(ApiKey::new(api_key));
}
if let Some(ca_file) = &self.ca_file {
options = options.with_ca_file(ca_file);
}
if let Some(server_name_override) = &self.server_name_override {
options = options.with_server_name_override(server_name_override);
}
options
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CliValueType {
Bool,
Int32,
Int64,
Float,
Double,
String,
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli).await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
async fn run(cli: Cli) -> Result<(), Error> {
match cli.command {
Command::Version { json, .. } => print_version(json),
Command::Ping {
connection,
message,
json,
} => {
let client = connect(connection).await?;
let reply = client
.invoke(MxCommandRequest {
client_correlation_id: "rust-cli-ping".to_owned(),
command: Some(MxCommand {
kind: MxCommandKind::Ping as i32,
payload: Some(mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::Ping(
PingCommand { message },
)),
}),
..MxCommandRequest::default()
})
.await?;
print_command_reply("ping", &reply, json);
}
Command::OpenSession {
connection,
client_name,
json,
} => {
let client = connect(connection).await?;
let reply = client
.open_session_raw(OpenSessionRequest {
client_session_name: client_name,
..OpenSessionRequest::default()
})
.await?;
if json {
println!(
"{}",
json!({
"sessionId": reply.session_id,
"backendName": reply.backend_name,
"gatewayProtocolVersion": reply.gateway_protocol_version,
"workerProtocolVersion": reply.worker_protocol_version,
})
);
} else {
println!("{}", reply.session_id);
}
}
Command::CloseSession {
connection,
session_id,
json,
} => {
let client = connect(connection).await?;
let reply = client
.close_session_raw(CloseSessionRequest {
session_id,
client_correlation_id: "rust-cli-close-session".to_owned(),
})
.await?;
if json {
println!("{}", json!({ "sessionId": reply.session_id }));
} else {
println!("closed {}", reply.session_id);
}
}
Command::Register {
connection,
session_id,
client_name,
json,
} => {
let session = session_for(connection, session_id).await?;
let server_handle = session.register(&client_name).await?;
print_handle("serverHandle", server_handle, json);
}
Command::AddItem {
connection,
session_id,
server_handle,
item,
json,
} => {
let session = session_for(connection, session_id).await?;
let item_handle = session.add_item(server_handle, &item).await?;
print_handle("itemHandle", item_handle, json);
}
Command::Advise {
connection,
session_id,
server_handle,
item_handle,
json,
} => {
let session = session_for(connection, session_id).await?;
session.advise(server_handle, item_handle).await?;
print_ok("advise", json);
}
Command::SubscribeBulk {
connection,
session_id,
server_handle,
items,
json,
} => {
let session = session_for(connection, session_id).await?;
let results = session.subscribe_bulk(server_handle, items).await?;
print_bulk_results("subscribe-bulk", &results, json);
}
Command::UnsubscribeBulk {
connection,
session_id,
server_handle,
item_handles,
json,
} => {
let session = session_for(connection, session_id).await?;
let results = session
.unsubscribe_bulk(server_handle, item_handles)
.await?;
print_bulk_results("unsubscribe-bulk", &results, json);
}
Command::StreamEvents {
connection,
session_id,
after_worker_sequence,
max_events,
json,
jsonl,
} => {
if max_events > MAX_AGGREGATE_EVENTS {
return Err(Error::InvalidArgument {
name: "max-events".to_owned(),
detail: format!("must be less than or equal to {MAX_AGGREGATE_EVENTS}"),
});
}
let client = connect(connection).await?;
let mut stream = client
.stream_events(StreamEventsRequest {
session_id,
after_worker_sequence,
})
.await?;
let mut events = Vec::new();
let mut event_count = 0usize;
while event_count < max_events {
let Some(event) = stream.next().await else {
break;
};
let event = event?;
event_count += 1;
if jsonl {
println!(
"{}",
json!({
"workerSequence": event.worker_sequence,
"family": event.family,
})
);
} else if json {
events.push(event);
} else {
println!("{} {}", event.worker_sequence, event.family);
}
}
if json {
println!("{}", json!({ "eventCount": event_count }));
}
}
Command::Write {
connection,
session_id,
server_handle,
item_handle,
value_type,
value,
user_id,
json,
} => {
let session = session_for(connection, session_id).await?;
session
.write(
server_handle,
item_handle,
parse_value(value_type, &value)?,
user_id,
)
.await?;
print_ok("write", json);
}
Command::Write2 {
connection,
session_id,
server_handle,
item_handle,
value_type,
value,
timestamp,
user_id,
json,
} => {
let session = session_for(connection, session_id).await?;
session
.write2(
server_handle,
item_handle,
parse_value(value_type, &value)?,
MxValue::string(timestamp),
user_id,
)
.await?;
print_ok("write2", json);
}
Command::Galaxy(galaxy_command) => run_galaxy(galaxy_command).await?,
Command::Smoke {
connection,
item,
client_name,
json,
} => {
let client = connect(connection).await?;
let session = client
.open_session(OpenSessionRequest {
client_session_name: client_name.clone(),
..OpenSessionRequest::default()
})
.await?;
let result = async {
let server_handle = session.register(&client_name).await?;
let item_handle = session.add_item(server_handle, &item).await?;
session.advise(server_handle, item_handle).await?;
Ok::<_, Error>((server_handle, item_handle))
}
.await;
let close_result = session.close().await;
let (server_handle, item_handle) = result?;
close_result?;
if json {
println!(
"{}",
json!({
"sessionId": session.id(),
"serverHandle": server_handle,
"itemHandle": item_handle,
"closed": true,
})
);
} else {
println!(
"session {} registered server {server_handle}, item {item_handle}, closed",
session.id()
);
}
}
}
Ok(())
}
async fn connect(connection: ConnectionArgs) -> Result<GatewayClient, Error> {
GatewayClient::connect(connection.options()).await
}
async fn connect_galaxy(connection: ConnectionArgs) -> Result<GalaxyClient, Error> {
GalaxyClient::connect(connection.options()).await
}
async fn run_galaxy(command: GalaxyCommand) -> Result<(), Error> {
match command {
GalaxyCommand::TestConnection { connection, json } => {
let mut client = connect_galaxy(connection).await?;
let ok = client.test_connection().await?;
if json {
println!("{}", json!({ "ok": ok }));
} else if ok {
println!("ok");
} else {
println!("not ok");
}
}
GalaxyCommand::LastDeployTime { connection, json } => {
let mut client = connect_galaxy(connection).await?;
let timestamp = client.get_last_deploy_time().await?;
match (json, timestamp) {
(true, Some(ts)) => {
println!(
"{}",
json!({
"present": true,
"seconds": ts.seconds,
"nanos": ts.nanos,
})
);
}
(true, None) => {
println!("{}", json!({ "present": false }));
}
(false, Some(ts)) => println!("{}.{:09}", ts.seconds, ts.nanos),
(false, None) => println!("(absent)"),
}
}
GalaxyCommand::Watch {
connection,
last_seen_deploy_time,
max_events,
json,
} => {
let mut client = connect_galaxy(connection).await?;
let last_seen = last_seen_deploy_time
.as_deref()
.map(parse_rfc3339_timestamp)
.transpose()?;
let mut stream = client.watch_deploy_events(last_seen).await?;
let mut count = 0usize;
loop {
tokio::select! {
biased;
signal = tokio::signal::ctrl_c() => {
signal.map_err(|err| Error::InvalidArgument {
name: "ctrl_c".to_owned(),
detail: err.to_string(),
})?;
// Drop the stream below by breaking; tonic tears the
// gRPC call down cooperatively.
break;
}
next = stream.next() => {
let Some(event) = next else { break; };
let event = event?;
count += 1;
print_deploy_event(&event, json);
if max_events != 0 && count >= max_events {
break;
}
}
}
}
}
GalaxyCommand::DiscoverHierarchy { connection, json } => {
let mut client = connect_galaxy(connection).await?;
let objects = client.discover_hierarchy().await?;
if json {
let payload: Vec<_> = objects
.iter()
.map(|object| {
json!({
"gobjectId": object.gobject_id,
"tagName": object.tag_name,
"containedName": object.contained_name,
"browseName": object.browse_name,
"parentGobjectId": object.parent_gobject_id,
"isArea": object.is_area,
"categoryId": object.category_id,
"hostedByGobjectId": object.hosted_by_gobject_id,
"templateChain": object.template_chain,
"attributes": object.attributes.iter().map(|attribute| json!({
"attributeName": attribute.attribute_name,
"fullTagReference": attribute.full_tag_reference,
"mxDataType": attribute.mx_data_type,
"dataTypeName": attribute.data_type_name,
"isArray": attribute.is_array,
"arrayDimension": attribute.array_dimension,
"arrayDimensionPresent": attribute.array_dimension_present,
"mxAttributeCategory": attribute.mx_attribute_category,
"securityClassification": attribute.security_classification,
"isHistorized": attribute.is_historized,
"isAlarm": attribute.is_alarm,
})).collect::<Vec<_>>(),
})
})
.collect();
println!("{}", json!({ "objects": payload }));
} else {
println!("{}", objects.len());
for object in &objects {
println!(
"{} {} {} ({} attribute(s))",
object.gobject_id,
object.tag_name,
object.browse_name,
object.attributes.len()
);
}
}
}
}
Ok(())
}
async fn session_for(
connection: ConnectionArgs,
session_id: String,
) -> Result<mxgateway_client::Session, Error> {
let client = connect(connection).await?;
Ok(client.session(session_id))
}
fn print_version(use_json: bool) {
if use_json {
println!("{}", version_json());
return;
}
println!("mxgw {CLIENT_VERSION}");
println!("gateway protocol {GATEWAY_PROTOCOL_VERSION}");
println!("worker protocol {WORKER_PROTOCOL_VERSION}");
}
fn version_json() -> Value {
json!({
"clientVersion": CLIENT_VERSION,
"gatewayProtocolVersion": GATEWAY_PROTOCOL_VERSION,
"workerProtocolVersion": WORKER_PROTOCOL_VERSION,
})
}
fn print_command_reply(
operation: &str,
reply: &mxgateway_client::generated::mxaccess_gateway::v1::MxCommandReply,
use_json: bool,
) {
if use_json {
println!(
"{}",
json!({
"operation": operation,
"sessionId": reply.session_id,
"correlationId": reply.correlation_id,
"kind": reply.kind,
})
);
} else {
println!("{operation} completed");
}
}
fn print_handle(name: &str, handle: i32, use_json: bool) {
if use_json {
println!("{}", json!({ name: handle }));
} else {
println!("{handle}");
}
}
fn print_ok(operation: &str, use_json: bool) {
if use_json {
println!("{}", json!({ "operation": operation, "ok": true }));
} else {
println!("{operation} completed");
}
}
fn print_bulk_results(
operation: &str,
results: &[mxgateway_client::generated::mxaccess_gateway::v1::SubscribeResult],
use_json: bool,
) {
if use_json {
let results_json: Vec<_> = results
.iter()
.map(|result| {
json!({
"serverHandle": result.server_handle,
"tagAddress": result.tag_address,
"itemHandle": result.item_handle,
"wasSuccessful": result.was_successful,
"errorMessage": result.error_message,
})
})
.collect();
println!(
"{}",
json!({ "operation": operation, "results": results_json })
);
} else {
println!("{}", results.len());
}
}
fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error> {
let parsed = match value_type {
CliValueType::Bool => MxValue::bool(parse_cli_value(value)?),
CliValueType::Int32 => MxValue::int32(parse_cli_value(value)?),
CliValueType::Int64 => MxValue::int64(parse_cli_value(value)?),
CliValueType::Float => MxValue::float(parse_cli_value(value)?),
CliValueType::Double => MxValue::double(parse_cli_value(value)?),
CliValueType::String => MxValue::string(value),
};
Ok(parsed)
}
fn print_deploy_event(event: &DeployEvent, use_json: bool) {
if use_json {
println!(
"{}",
json!({
"sequence": event.sequence,
"observedAt": event.observed_at.as_ref().map(|ts| json!({
"seconds": ts.seconds,
"nanos": ts.nanos,
})),
"timeOfLastDeploy": event.time_of_last_deploy.as_ref().map(|ts| json!({
"seconds": ts.seconds,
"nanos": ts.nanos,
})),
"timeOfLastDeployPresent": event.time_of_last_deploy_present,
"objectCount": event.object_count,
"attributeCount": event.attribute_count,
})
);
} else {
let observed = event
.observed_at
.as_ref()
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
.unwrap_or_else(|| "(absent)".to_owned());
let last_deploy = if event.time_of_last_deploy_present {
event
.time_of_last_deploy
.as_ref()
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
.unwrap_or_else(|| "(absent)".to_owned())
} else {
"(absent)".to_owned()
};
println!(
"seq={} observed={} lastDeploy={} objects={} attributes={}",
event.sequence, observed, last_deploy, event.object_count, event.attribute_count,
);
}
}
/// Parse a small but practically-complete subset of RFC3339:
/// `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|+HH:MM|-HH:MM]`. Returns the
/// corresponding `prost_types::Timestamp` (Unix seconds + nanoseconds).
fn parse_rfc3339_timestamp(input: &str) -> Result<prost_types::Timestamp, Error> {
fn invalid(detail: impl Into<String>) -> Error {
Error::InvalidArgument {
name: "last-seen-deploy-time".to_owned(),
detail: detail.into(),
}
}
let bytes = input.as_bytes();
if bytes.len() < 20 || (bytes[10] != b'T' && bytes[10] != b't') {
return Err(invalid(format!(
"expected RFC3339 timestamp like 2026-04-28T15:30:00Z, got {input:?}"
)));
}
let read_u32 = |start: usize, len: usize| -> Result<u32, Error> {
std::str::from_utf8(&bytes[start..start + len])
.ok()
.and_then(|slice| slice.parse::<u32>().ok())
.ok_or_else(|| invalid(format!("non-numeric digits at byte {start}")))
};
let year = read_u32(0, 4)? as i32;
if bytes[4] != b'-' {
return Err(invalid("expected '-' after year"));
}
let month = read_u32(5, 2)?;
if bytes[7] != b'-' {
return Err(invalid("expected '-' after month"));
}
let day = read_u32(8, 2)?;
let hour = read_u32(11, 2)?;
if bytes[13] != b':' {
return Err(invalid("expected ':' after hour"));
}
let minute = read_u32(14, 2)?;
if bytes[16] != b':' {
return Err(invalid("expected ':' after minute"));
}
let second = read_u32(17, 2)?;
let mut cursor = 19usize;
let mut nanos: u32 = 0;
if cursor < bytes.len() && bytes[cursor] == b'.' {
cursor += 1;
let frac_start = cursor;
while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
cursor += 1;
}
let frac_len = cursor - frac_start;
if frac_len == 0 {
return Err(invalid("expected fractional digits after '.'"));
}
let take = frac_len.min(9);
let frac = std::str::from_utf8(&bytes[frac_start..frac_start + take])
.ok()
.and_then(|slice| slice.parse::<u32>().ok())
.ok_or_else(|| invalid("invalid fractional digits"))?;
nanos = frac * 10u32.pow(9u32.saturating_sub(take as u32));
}
let mut offset_seconds: i64 = 0;
if cursor >= bytes.len() {
return Err(invalid("missing timezone designator (Z or +HH:MM)"));
}
match bytes[cursor] {
b'Z' | b'z' => cursor += 1,
sign @ (b'+' | b'-') => {
cursor += 1;
if cursor + 5 > bytes.len() {
return Err(invalid("offset must be ±HH:MM"));
}
let oh = std::str::from_utf8(&bytes[cursor..cursor + 2])
.ok()
.and_then(|slice| slice.parse::<i64>().ok())
.ok_or_else(|| invalid("invalid offset hour"))?;
if bytes[cursor + 2] != b':' {
return Err(invalid("offset must contain ':' between HH and MM"));
}
let om = std::str::from_utf8(&bytes[cursor + 3..cursor + 5])
.ok()
.and_then(|slice| slice.parse::<i64>().ok())
.ok_or_else(|| invalid("invalid offset minute"))?;
cursor += 5;
let signed = if sign == b'-' { -1 } else { 1 };
offset_seconds = signed * (oh * 3600 + om * 60);
}
other => {
return Err(invalid(format!(
"unexpected timezone designator byte {other:?}"
)));
}
}
if cursor != bytes.len() {
return Err(invalid("trailing characters after timezone"));
}
let unix = ymdhms_to_unix(year, month, day, hour, minute, second)?;
let seconds = unix - offset_seconds;
Ok(prost_types::Timestamp {
seconds,
nanos: nanos as i32,
})
}
fn ymdhms_to_unix(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> Result<i64, Error> {
if !(1..=12).contains(&month) || day < 1 || hour > 23 || minute > 59 || second > 60 {
return Err(Error::InvalidArgument {
name: "last-seen-deploy-time".to_owned(),
detail: "calendar component out of range".to_owned(),
});
}
fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
const DAYS: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut max = DAYS[(month - 1) as usize];
if month == 2 && is_leap(year) {
max = 29;
}
if day > max {
return Err(Error::InvalidArgument {
name: "last-seen-deploy-time".to_owned(),
detail: format!("day {day} out of range for month {month}/{year}"),
});
}
// Days from 1970-01-01 to year-month-day.
let mut total_days: i64 = 0;
if year >= 1970 {
for y in 1970..year {
total_days += if is_leap(y) { 366 } else { 365 };
}
} else {
for y in year..1970 {
total_days -= if is_leap(y) { 366 } else { 365 };
}
}
for m in 1..month {
let mut dim = DAYS[(m - 1) as usize];
if m == 2 && is_leap(year) {
dim = 29;
}
total_days += dim as i64;
}
total_days += (day - 1) as i64;
Ok(total_days * 86_400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64)
}
fn parse_cli_value<T>(value: &str) -> Result<T, Error>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
value.parse::<T>().map_err(|source| Error::InvalidArgument {
name: "value".to_owned(),
detail: source.to_string(),
})
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::Cli;
#[test]
fn parses_version_json_command() {
let parsed = Cli::try_parse_from(["mxgw", "version", "--json"]);
assert!(parsed.is_ok());
}
#[test]
fn parses_write_command() {
let parsed = Cli::try_parse_from([
"mxgw",
"write",
"--session-id",
"session-1",
"--server-handle",
"12",
"--item-handle",
"34",
"--value-type",
"int32",
"--value",
"123",
]);
assert!(parsed.is_ok());
}
#[test]
fn version_json_output_has_protocol_versions() {
let value = super::version_json();
assert_eq!(value["gatewayProtocolVersion"], 2);
assert_eq!(value["workerProtocolVersion"], 1);
}
#[test]
fn parses_galaxy_watch_command_with_last_seen_and_max_events() {
let parsed = Cli::try_parse_from([
"mxgw",
"galaxy",
"watch",
"--last-seen-deploy-time",
"2026-04-28T15:30:00Z",
"--max-events",
"5",
"--json",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_galaxy_watch_deploy_events_alias() {
let parsed = Cli::try_parse_from(["mxgw", "galaxy", "watch-deploy-events"]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)
let utc = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Z").unwrap();
let plus = super::parse_rfc3339_timestamp("2026-04-28T16:30:00+01:00").unwrap();
let frac = super::parse_rfc3339_timestamp("2026-04-28T15:30:00.250Z").unwrap();
assert_eq!(utc.seconds, plus.seconds);
assert_eq!(utc.nanos, 0);
assert_eq!(frac.seconds, utc.seconds);
assert_eq!(frac.nanos, 250_000_000);
}
}