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 futures_util::StreamExt;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use serde_json::Value;
|
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::galaxy_repository::v1::DeployEvent;
|
||||||
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||||
alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
|
alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
|
||||||
@@ -387,6 +388,46 @@ enum GalaxyCommand {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
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.
|
/// Subscribe to the WatchDeployEvents server stream.
|
||||||
///
|
///
|
||||||
/// Prints one line per received event (or one JSON object with `--json`).
|
/// 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(())
|
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(
|
async fn session_for(
|
||||||
connection: ConnectionArgs,
|
connection: ConnectionArgs,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@@ -2131,6 +2424,37 @@ mod tests {
|
|||||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
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]
|
#[test]
|
||||||
fn parses_batch_command() {
|
fn parses_batch_command() {
|
||||||
let parsed = Cli::try_parse_from(["mxgw", "batch"]);
|
let parsed = Cli::try_parse_from(["mxgw", "batch"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user