feat(rust): add browse CLI subcommand (§4.6)

This commit is contained in:
Joseph Doherty
2026-06-15 09:42:16 -04:00
parent 90529dce6e
commit 639e36b1bc
+324
View File
@@ -18,6 +18,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
use futures_util::StreamExt;
use serde_json::json;
use serde_json::Value;
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode};
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
@@ -387,6 +388,46 @@ enum GalaxyCommand {
#[arg(long)]
json: bool,
},
/// Lazily browse the Galaxy hierarchy through `BrowseChildren`.
///
/// With no `--parent-gobject-id` the root objects are listed; pass a
/// parent id to list that object's direct children. `--depth` controls
/// how many further levels are eagerly expanded (0 = the requested level
/// only). The filter flags map onto `BrowseChildrenOptions` and are reused
/// at every expanded level, mirroring the lazy-browse library helper.
Browse {
#[command(flatten)]
connection: ConnectionArgs,
/// Parent gobject id whose children to browse. Omit for root objects.
#[arg(long)]
parent_gobject_id: Option<i32>,
/// Restrict to objects whose `category_id` matches one of these ids.
/// Repeatable.
#[arg(long = "category-id")]
category_ids: Vec<i32>,
/// Restrict to objects whose template chain contains this entry.
/// Repeatable (combined with AND).
#[arg(long = "template-contains")]
template_chain_contains: Vec<String>,
/// Restrict to objects whose tag name matches this SQL `LIKE`-style glob.
#[arg(long)]
tag_name_glob: Option<String>,
/// Populate `attributes` on the returned objects.
#[arg(long)]
include_attributes: bool,
/// Only return objects that own at least one alarm-bearing attribute.
#[arg(long)]
alarm_bearing_only: bool,
/// Only return objects that own at least one historized attribute.
#[arg(long)]
historized_only: bool,
/// Number of additional levels to eagerly expand beneath each returned
/// node. 0 (the default) prints only the requested level.
#[arg(long, default_value_t = 0)]
depth: usize,
#[arg(long)]
json: bool,
},
/// Subscribe to the WatchDeployEvents server stream.
///
/// Prints one line per received event (or one JSON object with `--json`).
@@ -1103,10 +1144,262 @@ async fn run_galaxy(command: GalaxyCommand) -> Result<(), Error> {
}
}
}
GalaxyCommand::Browse {
connection,
parent_gobject_id,
category_ids,
template_chain_contains,
tag_name_glob,
include_attributes,
alarm_bearing_only,
historized_only,
depth,
json,
} => {
let mut client = connect_galaxy(connection).await?;
let options = BrowseChildrenOptions {
category_ids: category_ids.clone(),
template_chain_contains: template_chain_contains.clone(),
tag_name_glob: tag_name_glob.clone(),
include_attributes: include_attributes.then_some(true),
alarm_bearing_only,
historized_only,
};
match parent_gobject_id {
// No parent → walk the lazy-browse tree from the root objects,
// eagerly expanding `depth` further levels so the print walks
// cached children without re-issuing RPCs.
None => {
let nodes = client.browse(Some(options)).await?;
for node in &nodes {
expand_to_depth(node, depth).await?;
}
if json {
let mut payload = Vec::with_capacity(nodes.len());
for node in &nodes {
payload.push(lazy_node_to_json(node).await);
}
println!("{}", json!({ "nodes": payload }));
} else {
println!("{}", nodes.len());
for node in &nodes {
print_lazy_node(node, 0).await;
}
}
}
// A specific parent → fetch exactly one level of children via
// the raw paged RPC. `--depth` is not meaningful here; the
// single-level children are returned as-is.
Some(parent) => {
let children = browse_children_one_level(&mut client, parent, &options).await?;
print_browse_children(&children, json);
}
}
}
}
Ok(())
}
/// Drive `BrowseChildren` paging by hand for a single parent and return the
/// flattened child list. Used by the `browse --parent-gobject-id` path, which
/// surfaces one level of children rather than the lazy root-tree walk.
async fn browse_children_one_level(
client: &mut GalaxyClient,
parent_gobject_id: i32,
options: &BrowseChildrenOptions,
) -> Result<Vec<GalaxyBrowseChild>, Error> {
use std::collections::HashSet;
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::{
browse_children_request, BrowseChildrenRequest,
};
let mut children = Vec::new();
let mut page_token = String::new();
let mut seen: HashSet<String> = HashSet::new();
loop {
let request = BrowseChildrenRequest {
page_size: 500,
page_token: page_token.clone(),
category_ids: options.category_ids.clone(),
template_chain_contains: options.template_chain_contains.clone(),
tag_name_glob: options.tag_name_glob.clone().unwrap_or_default(),
include_attributes: options.include_attributes,
alarm_bearing_only: options.alarm_bearing_only,
historized_only: options.historized_only,
parent: Some(browse_children_request::Parent::ParentGobjectId(
parent_gobject_id,
)),
};
let reply = client.browse_children_raw(request).await?;
let hints = reply.child_has_children;
for (index, object) in reply.children.into_iter().enumerate() {
let has_children_hint = hints.get(index).copied().unwrap_or(false);
children.push(GalaxyBrowseChild {
object,
has_children_hint,
});
}
page_token = reply.next_page_token;
if page_token.is_empty() {
return Ok(children);
}
if !seen.insert(page_token.clone()) {
return Err(Error::InvalidArgument {
name: "page_token".to_owned(),
detail: format!(
"galaxy browse children returned repeated page token `{page_token}`"
),
});
}
}
}
/// A single child returned by the raw `BrowseChildren` paging path, paired
/// with its server-supplied `child_has_children` hint.
struct GalaxyBrowseChild {
object: zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::GalaxyObject,
has_children_hint: bool,
}
/// Print the one-level children of a browsed parent, mirroring the JSON node
/// shape used by the root-tree walk (minus the recursive `children` array).
fn print_browse_children(children: &[GalaxyBrowseChild], use_json: bool) {
if use_json {
let payload: Vec<_> = children.iter().map(browse_child_to_json).collect();
println!("{}", json!({ "nodes": payload }));
} else {
println!("{}", children.len());
for child in children {
let object = &child.object;
let marker = if child.has_children_hint { "+" } else { "-" };
println!(
"{marker} {} {} (gobject {})",
object.tag_name, object.browse_name, object.gobject_id,
);
}
}
}
/// Render one raw browse child as a JSON object whose key set matches the
/// lazy-node renderer (with an empty `children` array).
fn browse_child_to_json(child: &GalaxyBrowseChild) -> Value {
let object = &child.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,
"hasChildrenHint": child.has_children_hint,
"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<_>>(),
"children": Vec::<Value>::new(),
})
}
/// Recursively expand a [`LazyBrowseNode`] up to `depth` further levels. A
/// `depth` of 0 leaves the node unexpanded so the caller prints only the
/// requested level.
fn expand_to_depth(
node: &LazyBrowseNode,
depth: usize,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
Box::pin(async move {
if depth == 0 {
return Ok(());
}
node.expand().await?;
for child in node.children().await {
expand_to_depth(&child, depth - 1).await?;
}
Ok(())
})
}
/// Print a [`LazyBrowseNode`] and any already-expanded descendants as an
/// indented tree. Indentation is two spaces per level.
fn print_lazy_node(
node: &LazyBrowseNode,
indent: usize,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>> {
Box::pin(async move {
let object = node.object();
let marker = if node.has_children_hint() { "+" } else { "-" };
println!(
"{:indent$}{marker} {} {} (gobject {})",
"",
object.tag_name,
object.browse_name,
object.gobject_id,
indent = indent,
);
if node.is_expanded().await {
for child in node.children().await {
print_lazy_node(&child, indent + 2).await;
}
}
})
}
/// Render a [`LazyBrowseNode`] (and its already-expanded children) as a JSON
/// object. Mirrors the `discover-hierarchy` object shape with an added
/// `hasChildrenHint` flag and a nested `children` array.
fn lazy_node_to_json(
node: &LazyBrowseNode,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Value> + Send + '_>> {
Box::pin(async move {
let object = node.object();
let mut children = Vec::new();
if node.is_expanded().await {
for child in node.children().await {
children.push(lazy_node_to_json(&child).await);
}
}
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,
"hasChildrenHint": node.has_children_hint(),
"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<_>>(),
"children": children,
})
})
}
async fn session_for(
connection: ConnectionArgs,
session_id: String,
@@ -2131,6 +2424,37 @@ mod tests {
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_galaxy_browse_command_with_filters_and_depth() {
let parsed = Cli::try_parse_from([
"mxgw",
"galaxy",
"browse",
"--parent-gobject-id",
"42",
"--category-id",
"3",
"--category-id",
"5",
"--template-contains",
"$DelmiaReceiver",
"--tag-name-glob",
"Recv_*",
"--include-attributes",
"--alarm-bearing-only",
"--depth",
"2",
"--json",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_galaxy_browse_command_with_defaults() {
let parsed = Cli::try_parse_from(["mxgw", "galaxy", "browse"]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_batch_command() {
let parsed = Cli::try_parse_from(["mxgw", "batch"]);