use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const DEFAULT_DAEMON_ENDPOINT: &str = "127.0.0.1:7588";
const DEFAULT_SIDECAR_RUNTIME_PATH: &str = "artifacts/sidecar/gateway-sidecar-runtime.json";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CliRuntimeMode {
Embedded,
Daemon,
}
fn parse_runtime_mode(args: &[String]) -> Result<CliRuntimeMode, String> {
match option_value(args, "--mode").as_deref() {
None => Ok(CliRuntimeMode::Embedded),
Some("embedded") => Ok(CliRuntimeMode::Embedded),
Some("daemon") => Ok(CliRuntimeMode::Daemon),
Some(other) => Err(format!(
"unsupported --mode value: {other} (expected embedded|daemon)"
)),
}
}
pub fn resolve_runtime_endpoint(
args: &[String],
daemon_default: &str,
embedded_missing_endpoint_message: &str,
) -> Result<String, String> {
let endpoint = resolve_runtime_endpoint_optional(args, daemon_default)?
.unwrap_or_else(|| daemon_default.to_string());
let _ = embedded_missing_endpoint_message;
Ok(endpoint)
}
pub fn resolve_runtime_endpoint_optional(
args: &[String],
daemon_default: &str,
) -> Result<Option<String>, String> {
let mode = parse_runtime_mode(args)?;
let endpoint = option_value(args, "--endpoint");
match (mode, endpoint) {
(CliRuntimeMode::Daemon, Some(raw)) => {
crate::gateway::normalize_udp_endpoint(&raw).map(Some)
}
(CliRuntimeMode::Daemon, None) => {
resolve_automatic_endpoint(args, daemon_default).map(Some)
}
(CliRuntimeMode::Embedded, Some(raw)) => {
crate::gateway::normalize_udp_endpoint(&raw).map(Some)
}
(CliRuntimeMode::Embedded, None) => {
resolve_automatic_endpoint(args, daemon_default).map(Some)
}
}
}
pub fn resolve_runtime_endpoint_from_hint(
args: &[String],
endpoint_hint: Option<String>,
daemon_default: &str,
) -> Result<String, String> {
if let Some(raw) = endpoint_hint {
return crate::gateway::normalize_udp_endpoint(&raw);
}
resolve_automatic_endpoint(args, daemon_default)
}
pub fn discover_cluster_endpoints(args: &[String]) -> Vec<String> {
let mut endpoints = Vec::new();
if let Some(runtime) = load_sidecar_runtime_json(args) {
push_unique_endpoint(
&mut endpoints,
runtime
.get("advertise_endpoint")
.and_then(serde_json::Value::as_str),
);
push_unique_endpoint(
&mut endpoints,
runtime
.get("endpoint")
.and_then(serde_json::Value::as_str),
);
if let Some(peers) = runtime
.get("cluster")
.and_then(|item| item.get("peers"))
.and_then(serde_json::Value::as_array)
{
for peer in peers {
push_unique_endpoint(&mut endpoints, peer.as_str());
}
}
}
endpoints
}
fn resolve_automatic_endpoint(args: &[String], daemon_default: &str) -> Result<String, String> {
if let Some(raw) = std::env::var("ROBOTRT_GATEWAY_ENDPOINT").ok()
&& !raw.trim().is_empty()
{
return crate::gateway::normalize_udp_endpoint(raw.trim());
}
if let Some(raw) = load_sidecar_runtime_json(args).and_then(|runtime| {
runtime
.get("advertise_endpoint")
.and_then(serde_json::Value::as_str)
.or_else(|| runtime.get("endpoint").and_then(serde_json::Value::as_str))
.map(ToString::to_string)
}) {
return crate::gateway::normalize_udp_endpoint(&raw);
}
crate::gateway::normalize_udp_endpoint(if daemon_default.trim().is_empty() {
DEFAULT_DAEMON_ENDPOINT
} else {
daemon_default
})
}
fn load_sidecar_runtime_json(args: &[String]) -> Option<serde_json::Value> {
let from_args = option_value(args, "--sidecar-registry");
let from_env = std::env::var("ROBOTRT_SIDECAR_REGISTRY").ok();
let path = from_args
.or(from_env)
.unwrap_or_else(|| String::from(DEFAULT_SIDECAR_RUNTIME_PATH));
let body = fs::read_to_string(path).ok()?;
serde_json::from_str::<serde_json::Value>(&body).ok()
}
fn push_unique_endpoint(items: &mut Vec<String>, maybe_raw: Option<&str>) {
let Some(raw) = maybe_raw else {
return;
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return;
}
let normalized = match crate::gateway::normalize_udp_endpoint(trimmed) {
Ok(value) => value,
Err(_) => return,
};
if !items.iter().any(|item| item == &normalized) {
items.push(normalized);
}
}
pub fn parse_report_path(args: &[String], default_path: &str) -> Result<PathBuf, String> {
let mut idx = 0usize;
while idx < args.len() {
if args[idx] == "--report" {
if idx + 1 >= args.len() {
return Err(String::from("missing value for --report"));
}
return Ok(PathBuf::from(&args[idx + 1]));
}
idx += 1;
}
let default = PathBuf::from(default_path);
if default.exists() {
return Ok(default);
}
if let Some(discovered) = discover_report_path(&default) {
return Ok(discovered);
}
Ok(default)
}
fn discover_report_path(default_path: &Path) -> Option<PathBuf> {
let parent = default_path.parent()?;
let stem = default_path.file_stem()?.to_string_lossy();
let stem_suffix = stem.rsplit('-').next().unwrap_or(stem.as_ref()).to_string();
let mut latest: Option<(SystemTime, PathBuf)> = None;
let entries = fs::read_dir(parent).ok()?;
for entry in entries {
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Some(candidate_stem) = path.file_stem().and_then(|item| item.to_str()) else {
continue;
};
let matches = candidate_stem == stem_suffix
|| candidate_stem.ends_with(&format!("-{stem_suffix}"));
if !matches {
continue;
}
let modified = entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
match &latest {
Some((latest_modified, _)) if modified <= *latest_modified => {}
_ => latest = Some((modified, path)),
}
}
latest.map(|(_, path)| path)
}
pub fn has_flag(args: &[String], flag: &str) -> bool {
args.iter().any(|arg| arg == flag)
}
pub fn is_help_token(arg: &str) -> bool {
matches!(arg, "help" | "--help" | "-h")
}
pub fn option_value(args: &[String], option: &str) -> Option<String> {
let mut idx = 0usize;
while idx + 1 < args.len() {
if args[idx] == option {
return Some(args[idx + 1].clone());
}
idx += 1;
}
None
}
pub fn parse_usize_option(args: &[String], option: &str, default: usize) -> Result<usize, String> {
let Some(raw) = option_value(args, option) else {
return Ok(default);
};
raw.parse::<usize>()
.map_err(|err| format!("invalid value for {option}: {raw} ({err})"))
}
pub fn parse_u64_option(args: &[String], option: &str, default: u64) -> Result<u64, String> {
let Some(raw) = option_value(args, option) else {
return Ok(default);
};
raw.parse::<u64>()
.map_err(|err| format!("invalid value for {option}: {raw} ({err})"))
}
pub fn first_positional(args: &[String]) -> Option<String> {
let mut idx = 0usize;
while idx < args.len() {
let token = &args[idx];
if token.starts_with('-') {
if option_takes_value(token) {
idx += 2;
} else {
idx += 1;
}
continue;
}
return Some(token.clone());
}
None
}
fn option_takes_value(option: &str) -> bool {
matches!(
option,
"--report"
| "--endpoint"
| "--timeout-ms"
| "--input"
| "--output"
| "--bind"
| "--source"
| "--count"
| "--topic"
| "--domain"
| "--speed"
| "--limit"
| "--iterations"
| "--interval-ms"
| "--format"
| "--alert-template"
| "--template"
| "--topic-warn-utilization"
| "--topic-critical-utilization"
| "--baseline-report"
| "--baseline-endpoint"
| "--baseline-timeout-ms"
| "--policy"
| "--project"
| "--reports"
| "--endpoints"
| "--baseline-reports"
| "--baseline-endpoints"
| "--input-bag"
| "--obs-format"
| "--profile-template"
| "--target-schema"
| "--projects"
| "--projects-file"
| "--request"
| "--backup"
| "--scan-root"
| "--filter-schema"
| "--filter-template"
| "--batch-report"
| "--severity-map"
| "--file"
| "--task"
| "--profile"
| "--overlay"
| "--group"
| "--max-parallel"
| "--state-file"
| "--mode"
| "--sidecar-registry"
| "--enabled"
| "--rate-max"
| "--rate-burst"
| "--atomic-write"
| "--include"
| "--goal"
| "--goal-id"
| "--message"
| "--max-items"
| "--status-report"
| "--runtime-report"
| "--middleware-report"
| "--resource-report"
| "--field"
| "--service-retry-timeout-ms"
| "--action-retry-timeout-ms"
| "--mission-retry-timeout-ms"
| "--topic-dedupe-window"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_file_path(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{nanos}.json"))
}
#[test]
fn resolve_runtime_endpoint_from_hint_uses_sidecar_registry() {
let registry = temp_file_path("robotrt-sidecar-runtime");
let body = serde_json::json!({
"api_version": "robotrt.gateway.sidecar.runtime.v1",
"endpoint": "127.0.0.1:7588",
"advertise_endpoint": "127.0.0.1:7688",
"cluster": {
"id": "cluster-alpha",
"zone": "zone-a",
"peers": ["127.0.0.1:7788"]
}
});
fs::write(
®istry,
serde_json::to_string(&body).expect("serialize sidecar runtime"),
)
.expect("write sidecar runtime file");
let args = vec![
String::from("runtime"),
String::from("load"),
String::from("--sidecar-registry"),
registry.display().to_string(),
];
let resolved = resolve_runtime_endpoint_from_hint(&args, None, DEFAULT_DAEMON_ENDPOINT)
.expect("resolve runtime endpoint");
assert_eq!(resolved, "127.0.0.1:7688");
let _ = fs::remove_file(registry);
}
#[test]
fn discover_cluster_endpoints_reads_registry_and_peers() {
let registry = temp_file_path("robotrt-sidecar-cluster");
let body = serde_json::json!({
"api_version": "robotrt.gateway.sidecar.runtime.v1",
"endpoint": "127.0.0.1:7588",
"advertise_endpoint": "127.0.0.1:7688",
"cluster": {
"id": "cluster-alpha",
"zone": "zone-a",
"peers": ["127.0.0.1:7788", "127.0.0.1:7888"]
}
});
fs::write(
®istry,
serde_json::to_string(&body).expect("serialize sidecar cluster runtime"),
)
.expect("write sidecar cluster runtime");
let args = vec![
String::from("ops"),
String::from("fleet"),
String::from("--sidecar-registry"),
registry.display().to_string(),
];
let endpoints = discover_cluster_endpoints(&args);
assert_eq!(
endpoints,
vec![
String::from("127.0.0.1:7688"),
String::from("127.0.0.1:7588"),
String::from("127.0.0.1:7788"),
String::from("127.0.0.1:7888"),
]
);
let _ = fs::remove_file(registry);
}
}