From 639e36b1bce841791d988ab079bfc138cb95434e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 09:42:16 -0400 Subject: [PATCH] =?UTF-8?q?feat(rust):=20add=20browse=20CLI=20subcommand?= =?UTF-8?q?=20(=C2=A74.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clients/rust/crates/mxgw-cli/src/main.rs | 324 +++++++++++++++++++++++ 1 file changed, 324 insertions(+) diff --git a/clients/rust/crates/mxgw-cli/src/main.rs b/clients/rust/crates/mxgw-cli/src/main.rs index d89fdb1..169267f 100644 --- a/clients/rust/crates/mxgw-cli/src/main.rs +++ b/clients/rust/crates/mxgw-cli/src/main.rs @@ -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, + /// Restrict to objects whose `category_id` matches one of these ids. + /// Repeatable. + #[arg(long = "category-id")] + category_ids: Vec, + /// Restrict to objects whose template chain contains this entry. + /// Repeatable (combined with AND). + #[arg(long = "template-contains")] + template_chain_contains: Vec, + /// Restrict to objects whose tag name matches this SQL `LIKE`-style glob. + #[arg(long)] + tag_name_glob: Option, + /// 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, 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 = 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::>(), + "children": Vec::::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> + 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 + 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 + 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::>(), + "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"]);