feat(rust): add browse CLI subcommand (§4.6)
This commit is contained in:
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user