1use bhtune_db::SqlitePool;
6use bhtune_db::models::LiveOperationKind;
7use bhtune_driver::{
8 BrowseNode, BrowseNodeKind, BrowsePage, BrowsePageRequest, Driver, OpcDaDriver,
9 OpcDaGatewayInfo, Quality, SearchEvent, SearchIndexControlAction, SearchIndexRequest,
10 SearchIndexResponse, SearchIndexStatus, SearchMatch, SearchRequest, TagWrite, WriteOutcome,
11 check_gateway_compatibility, close_opcda_browse_session, get_opcda_gateway_info,
12 list_opcda_servers,
13};
14use bhtune_runtime::live_ownership::{AuditedDriver, LiveOperationGuard};
15
16use crate::args::{OpcCommand, OpcSearchMatchModeArg, SearchIndexCommand};
17use crate::output::OutputFormat;
18
19pub async fn run(
20 pool: &SqlitePool,
21 command: OpcCommand,
22 config: &crate::config::BhtuneConfig,
23) -> anyhow::Result<()> {
24 run_with_output(pool, command, config, OutputFormat::Table).await
25}
26
27pub async fn run_with_output(
28 pool: &SqlitePool,
29 command: OpcCommand,
30 config: &crate::config::BhtuneConfig,
31 output: OutputFormat,
32) -> anyhow::Result<()> {
33 match command {
34 OpcCommand::GatewayInfo { bridge_host } => {
35 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
36 gateway_info_with_output(&bridge_host, output).await
37 }
38 OpcCommand::Servers { bridge_host } => {
39 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
40 servers_with_output(&bridge_host, output).await
41 }
42 OpcCommand::Read {
43 bridge_host,
44 server,
45 tags,
46 } => {
47 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
48 let server = crate::config::resolve_server(server, config)?;
49 read_with_output(&bridge_host, &server, &tags, output).await
50 }
51 OpcCommand::Write {
52 bridge_host,
53 server,
54 tag,
55 value,
56 } => {
57 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
58 let server = crate::config::resolve_server(server, config)?;
59 write_with_output(pool, &bridge_host, &server, &tag, &value, output).await
60 }
61 OpcCommand::Browse {
62 bridge_host,
63 server,
64 session_id,
65 parent_node_key,
66 page_token,
67 page_size,
68 all,
69 refresh,
70 } => {
71 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
72 let server = crate::config::resolve_server(server, config)?;
73 browse_with_output(
74 &bridge_host,
75 &server,
76 BrowseOptions {
77 session_id,
78 parent_node_key,
79 page_token,
80 page_size,
81 all,
82 refresh,
83 },
84 output,
85 )
86 .await
87 }
88 OpcCommand::Close {
89 bridge_host,
90 session_id,
91 } => {
92 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
93 close_with_output(&bridge_host, &session_id, output).await
94 }
95 OpcCommand::Search {
96 bridge_host,
97 server,
98 query,
99 match_mode,
100 max_results,
101 session_id,
102 scope_node_key,
103 include_branches,
104 refresh,
105 } => {
106 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
107 let server = crate::config::resolve_server(server, config)?;
108 search_with_output(
109 &bridge_host,
110 &server,
111 SearchOptions {
112 query,
113 match_mode,
114 max_results,
115 session_id,
116 scope_node_key,
117 include_branches,
118 refresh,
119 },
120 output,
121 )
122 .await
123 }
124 OpcCommand::SearchIndex { command } => {
125 run_search_index_command(command, config, output).await
126 }
127 }
128}
129
130async fn run_search_index_command(
131 command: SearchIndexCommand,
132 config: &crate::config::BhtuneConfig,
133 output: OutputFormat,
134) -> anyhow::Result<()> {
135 match command {
136 SearchIndexCommand::Status {
137 bridge_host,
138 server,
139 } => {
140 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
141 let server = crate::config::resolve_server(server, config)?;
142 let driver = OpcDaDriver::connect(&bridge_host, &server).await?;
143 let status = driver.search_index_status().await?;
144 print_search_index_status(&status, output)
145 }
146 SearchIndexCommand::Search {
147 bridge_host,
148 server,
149 query,
150 match_mode,
151 max_results,
152 } => {
153 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
154 let server = crate::config::resolve_server(server, config)?;
155 let driver = OpcDaDriver::connect(&bridge_host, &server).await?;
156 let response = driver
157 .search_index(SearchIndexRequest::new(
158 query,
159 match_mode.into(),
160 max_results,
161 ))
162 .await?;
163 print_search_index_results(&response, output)
164 }
165 SearchIndexCommand::Refresh {
166 bridge_host,
167 server,
168 force,
169 } => {
170 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
171 let server = crate::config::resolve_server(server, config)?;
172 let driver = OpcDaDriver::connect(&bridge_host, &server).await?;
173 let status = driver.refresh_search_index(force).await?;
174 print_search_index_status(&status, output)
175 }
176 SearchIndexCommand::Control {
177 bridge_host,
178 server,
179 action,
180 } => {
181 let bridge_host = crate::config::resolve_bridge_host(bridge_host, config);
182 let server = crate::config::resolve_server(server, config)?;
183 let driver = OpcDaDriver::connect(&bridge_host, &server).await?;
184 let status = driver
185 .control_search_index(SearchIndexControlAction::from(action))
186 .await?;
187 print_search_index_status(&status, output)
188 }
189 }
190}
191
192#[cfg(test)]
193async fn gateway_info(bridge_host: &str) -> anyhow::Result<()> {
194 gateway_info_with_output(bridge_host, OutputFormat::Table).await
195}
196
197async fn gateway_info_with_output(bridge_host: &str, output: OutputFormat) -> anyhow::Result<()> {
198 let info = get_opcda_gateway_info(bridge_host).await?;
199 let compatibility = check_gateway_compatibility(bridge_host, None).await?;
200 if output == OutputFormat::Json {
201 let mut value = gateway_info_json(&info);
202 value["compatibility"] = serde_json::to_value(&compatibility)?;
203 println!("{}", serde_json::to_string_pretty(&value)?);
204 return Ok(());
205 }
206 println!("Application version: {}", info.application_version);
207 println!(
208 "Compatibility schema: {}",
209 info.compatibility_schema_version
210 );
211 for feature in info.features {
212 println!(
213 "{}: {}-{}",
214 feature.feature.as_str(),
215 feature.min_version,
216 feature.max_version
217 );
218 }
219 println!("Compatibility: {}", compatibility.status.as_str());
220 if let Some(warning) = compatibility.warning_message() {
221 println!("Compatibility warning: {warning}");
222 }
223 Ok(())
224}
225
226fn gateway_info_json(info: &OpcDaGatewayInfo) -> serde_json::Value {
227 serde_json::json!({
228 "application_version": info.application_version,
229 "compatibility_schema_version": info.compatibility_schema_version,
230 "features": info.features.iter().map(|feature| {
231 serde_json::json!({
232 "feature": feature.feature.as_str(),
233 "min_version": feature.min_version,
234 "max_version": feature.max_version,
235 })
236 }).collect::<Vec<_>>(),
237 })
238}
239
240#[cfg(test)]
241async fn servers(bridge_host: &str) -> anyhow::Result<()> {
242 servers_with_output(bridge_host, OutputFormat::Table).await
243}
244
245async fn servers_with_output(bridge_host: &str, output: OutputFormat) -> anyhow::Result<()> {
246 let servers = list_opcda_servers(bridge_host).await?;
247 if output == OutputFormat::Json {
248 println!(
249 "{}",
250 serde_json::to_string_pretty(&serde_json::json!({ "servers": servers }))?
251 );
252 return Ok(());
253 }
254 if servers.is_empty() {
255 println!("No OPC DA servers registered on the gateway's host.");
256 return Ok(());
257 }
258 for server in servers {
259 println!("{server}");
260 }
261 Ok(())
262}
263
264#[cfg(test)]
265async fn read(bridge_host: &str, server: &str, tags: &[String]) -> anyhow::Result<()> {
266 read_with_output(bridge_host, server, tags, OutputFormat::Table).await
267}
268
269async fn read_with_output(
270 bridge_host: &str,
271 server: &str,
272 tags: &[String],
273 output: OutputFormat,
274) -> anyhow::Result<()> {
275 if tags.is_empty() {
276 anyhow::bail!("at least one tag is required");
277 }
278 let driver = OpcDaDriver::connect(bridge_host, server).await?;
279 let values = driver.read(tags).await?;
280 if output == OutputFormat::Json {
281 let values = values
282 .into_iter()
283 .map(|value| {
284 serde_json::json!({
285 "tag": value.tag,
286 "value": value.value,
287 "quality": quality_name(value.quality),
288 "timestamp": value.timestamp.map(|time| time.to_rfc3339()),
289 })
290 })
291 .collect::<Vec<_>>();
292 println!(
293 "{}",
294 serde_json::to_string_pretty(&serde_json::json!({ "values": values }))?
295 );
296 return Ok(());
297 }
298 println!(
299 "{:<40} {:<15} {:<10} {:<20}",
300 "TAG", "VALUE", "QUALITY", "TIMESTAMP"
301 );
302 for v in values {
303 println!(
304 "{:<40} {:<15} {:<10} {:<20}",
305 v.tag,
306 v.value,
307 format!("{:?}", v.quality),
308 format_timestamp(v.timestamp),
309 );
310 }
311 Ok(())
312}
313
314#[cfg(test)]
315async fn write(bridge_host: &str, server: &str, tag: &str, value: &str) -> anyhow::Result<()> {
316 let directory = tempfile::tempdir()?;
317 let pool = bhtune_db::connect(&directory.path().join("opc-write-test.db")).await?;
318 write_with_output(&pool, bridge_host, server, tag, value, OutputFormat::Table).await
319}
320
321async fn write_with_output(
322 pool: &SqlitePool,
323 bridge_host: &str,
324 server: &str,
325 tag: &str,
326 value: &str,
327 output: OutputFormat,
328) -> anyhow::Result<()> {
329 let write_value = match value.parse::<f32>() {
332 Ok(f) => TagWrite::Float(f),
333 Err(_) => TagWrite::Raw(value.to_string()),
334 };
335 let target = match &write_value {
336 TagWrite::Float(value) => serde_json::json!(value),
337 TagWrite::Raw(value) => serde_json::json!(value),
338 };
339 let restore_intent = serde_json::to_string(&serde_json::json!({
340 "version": 1,
341 "kind": "opc_write",
342 "tag": tag,
343 "target": target,
344 "state": "pending",
345 }))?;
346 let ownership = LiveOperationGuard::acquire(
347 pool,
348 None,
349 LiveOperationKind::OpcWrite,
350 bridge_host,
351 server,
352 tag,
353 Some(restore_intent),
354 )
355 .await?;
356 let operation_result = async {
357 let driver = OpcDaDriver::connect(bridge_host, server).await?;
358 let audited = AuditedDriver::new(&driver, pool, &ownership, None);
359 let outcome = audited
360 .write(&tag.to_string(), write_value)
361 .await
362 .map_err(anyhow::Error::from)?;
363 anyhow::Ok(outcome)
364 }
365 .await;
366 let release_result = ownership.release().await;
367 let outcome = combine_write_and_release(operation_result, release_result)?;
368 if output == OutputFormat::Json {
369 println!(
370 "{}",
371 serde_json::to_string_pretty(&serde_json::json!({
372 "tag": tag,
373 "value": value,
374 "success": outcome.success,
375 "error": outcome.error_message,
376 }))?
377 );
378 if !outcome.success {
379 anyhow::bail!(
380 "driver rejected the write: {}",
381 outcome
382 .error_message
383 .unwrap_or_else(|| "unknown reason".to_string())
384 );
385 }
386 return Ok(());
387 }
388 if outcome.success {
389 println!("Wrote '{value}' to '{tag}'.");
390 Ok(())
391 } else {
392 anyhow::bail!(
393 "driver rejected the write: {}",
394 write_rejection_reason(outcome.error_message)
395 )
396 }
397}
398
399fn combine_write_and_release(
400 operation_result: anyhow::Result<WriteOutcome>,
401 release_result: anyhow::Result<()>,
402) -> anyhow::Result<WriteOutcome> {
403 match (operation_result, release_result) {
404 (Ok(outcome), Ok(())) => Ok(outcome),
405 (Err(error), Ok(())) => Err(error),
406 (Ok(_), Err(error)) => Err(anyhow::anyhow!(
407 "OPC write completed but live ownership release was not persisted: {error}"
408 )),
409 (Err(operation_error), Err(release_error)) => Err(anyhow::anyhow!(
410 "OPC write failed ({operation_error}); live ownership release also failed: {release_error}"
411 )),
412 }
413}
414
415fn format_timestamp(timestamp: Option<chrono::DateTime<chrono::Utc>>) -> String {
416 timestamp
417 .map(|timestamp| timestamp.to_rfc3339())
418 .unwrap_or_else(|| "-".to_string())
419}
420
421fn write_rejection_reason(error_message: Option<String>) -> String {
422 error_message.unwrap_or_else(|| "unknown reason".to_string())
423}
424
425#[cfg(test)]
426async fn browse(bridge_host: &str, server: &str, _path: &str) -> anyhow::Result<()> {
427 browse_with_output(
428 bridge_host,
429 server,
430 BrowseOptions {
431 page_size: bhtune_driver::DEFAULT_PAGE_SIZE,
432 ..BrowseOptions::default()
433 },
434 OutputFormat::Table,
435 )
436 .await
437}
438
439#[derive(Debug, Default)]
440struct BrowseOptions {
441 session_id: Option<String>,
442 parent_node_key: Option<String>,
443 page_token: Option<String>,
444 page_size: u32,
445 all: bool,
446 refresh: bool,
447}
448
449#[derive(serde::Serialize)]
450struct BrowsePagesOutput {
451 session_id: String,
452 pages: Vec<serde_json::Value>,
453 complete: bool,
454}
455
456async fn browse_with_output(
457 bridge_host: &str,
458 server: &str,
459 options: BrowseOptions,
460 output: OutputFormat,
461) -> anyhow::Result<()> {
462 validate_browse_options(&options)?;
463
464 let driver = OpcDaDriver::connect(bridge_host, server).await?;
465 let first_request = BrowsePageRequest {
466 session_id: options.session_id,
467 parent_node_key: options.parent_node_key.clone(),
468 page_token: options.page_token,
469 page_size: options.page_size,
470 refresh: options.refresh,
471 };
472 let first = driver.browse(first_request).await?;
473 let session = first.session_id.clone();
474 let pages = collect_browse_pages(
475 &driver,
476 first,
477 options.parent_node_key,
478 options.page_size,
479 options.all,
480 )
481 .await?;
482 render_browse_pages(&pages, &session, output)
483}
484
485fn validate_browse_options(options: &BrowseOptions) -> anyhow::Result<()> {
486 if (options.parent_node_key.is_some() || options.page_token.is_some())
487 && options.session_id.is_none()
488 {
489 anyhow::bail!("--session-id is required with --parent-node-key or --page-token");
490 }
491 Ok(())
492}
493
494fn render_browse_pages(
495 pages: &[BrowsePage],
496 session_id: &str,
497 output: OutputFormat,
498) -> anyhow::Result<()> {
499 if output == OutputFormat::Json {
500 return render_browse_json(pages, session_id);
501 }
502 render_browse_table(pages);
503 Ok(())
504}
505
506fn render_browse_json(pages: &[BrowsePage], session_id: &str) -> anyhow::Result<()> {
507 if pages.len() == 1 {
508 println!(
509 "{}",
510 serde_json::to_string_pretty(&json_browse_page(&pages[0]))?
511 );
512 } else {
513 let response = BrowsePagesOutput {
514 session_id: session_id.to_owned(),
515 pages: pages.iter().map(json_browse_page).collect(),
516 complete: pages.last().is_some_and(|page| page.complete),
517 };
518 println!("{}", serde_json::to_string_pretty(&response)?);
519 }
520 Ok(())
521}
522
523fn render_browse_table(pages: &[BrowsePage]) {
524 for (index, page) in pages.iter().enumerate() {
525 if pages.len() > 1 {
526 println!("Page {}:", index + 1);
527 }
528 if page.nodes.is_empty() {
529 println!("No tags found at this level.");
530 }
531 for node in &page.nodes {
532 let marker = match node.kind {
533 BrowseNodeKind::Branch => "[+]",
534 BrowseNodeKind::Item => " ",
535 BrowseNodeKind::BranchAndItem => "[*]",
536 BrowseNodeKind::Unspecified => "[?]",
537 };
538 let item = node
539 .item_id
540 .as_deref()
541 .map(|id| format!(" -> {id}"))
542 .unwrap_or_default();
543 println!("{marker} {}{item}", node.display_name);
544 }
545 if let Some(token) = &page.next_page_token {
546 println!("More pages available (next token: {token}).");
547 }
548 }
549}
550
551async fn close_with_output(
552 bridge_host: &str,
553 session_id: &str,
554 output: OutputFormat,
555) -> anyhow::Result<()> {
556 if session_id.trim().is_empty() {
557 anyhow::bail!("a browse session ID is required");
558 }
559 close_opcda_browse_session(bridge_host, session_id).await?;
560 if output == OutputFormat::Json {
561 println!(
562 "{}",
563 serde_json::to_string_pretty(&serde_json::json!({
564 "session_id": session_id,
565 "closed": true,
566 }))?
567 );
568 } else {
569 println!("Browse session closed successfully.");
570 }
571 Ok(())
572}
573
574async fn collect_browse_pages(
575 driver: &OpcDaDriver,
576 first: BrowsePage,
577 parent_node_key: Option<String>,
578 page_size: u32,
579 all: bool,
580) -> anyhow::Result<Vec<BrowsePage>> {
581 const MAX_PAGES: usize = 10_000;
582 let mut pages = vec![first];
583 if !all {
584 return Ok(pages);
585 }
586 while let Some(token) = pages.last().and_then(|page| page.next_page_token.clone()) {
587 if pages.len() >= MAX_PAGES {
588 anyhow::bail!("browse continuation exceeded the safety limit of {MAX_PAGES} pages");
589 }
590 #[allow(
591 clippy::expect_used,
592 reason = "pages always contains the first page; the loop only runs after that page is pushed"
593 )]
594 let last = pages.last().expect("pages always contains the first page");
595 let page = driver
596 .browse(BrowsePageRequest::next(
597 last.session_id.clone(),
598 parent_node_key.clone(),
599 token,
600 page_size,
601 ))
602 .await?;
603 pages.push(page);
604 }
605 Ok(pages)
606}
607
608fn json_browse_page(page: &BrowsePage) -> serde_json::Value {
609 serde_json::json!({
610 "session_id": page.session_id,
611 "nodes": page.nodes.iter().map(json_browse_node).collect::<Vec<_>>(),
612 "next_page_token": page.next_page_token,
613 "complete": page.complete,
614 "organization": format!("{:?}", page.organization).to_lowercase(),
615 "source": format!("{:?}", page.source).to_lowercase(),
616 "warning": page.warning,
617 })
618}
619
620fn json_browse_node(node: &BrowseNode) -> serde_json::Value {
621 serde_json::json!({
622 "node_key": node.node_key,
623 "display_name": node.display_name,
624 "kind": browse_node_kind_name(node.kind),
625 "item_id": node.item_id,
626 })
627}
628
629fn browse_node_kind_name(kind: BrowseNodeKind) -> &'static str {
630 match kind {
631 BrowseNodeKind::Unspecified => "unspecified",
632 BrowseNodeKind::Branch => "branch",
633 BrowseNodeKind::Item => "item",
634 BrowseNodeKind::BranchAndItem => "branch_and_item",
635 }
636}
637
638#[derive(Debug)]
639struct SearchOptions {
640 query: String,
641 match_mode: OpcSearchMatchModeArg,
642 max_results: u32,
643 session_id: Option<String>,
644 scope_node_key: Option<String>,
645 include_branches: bool,
646 refresh: bool,
647}
648
649async fn search_with_output(
650 bridge_host: &str,
651 server: &str,
652 options: SearchOptions,
653 output: OutputFormat,
654) -> anyhow::Result<()> {
655 let driver = OpcDaDriver::connect(bridge_host, server).await?;
656 let request = SearchRequest {
657 query: options.query,
658 match_mode: options.match_mode.into(),
659 session_id: options.session_id,
660 scope_node_key: options.scope_node_key,
661 max_results: options.max_results,
662 include_branches: options.include_branches,
663 refresh: options.refresh,
664 };
665 let mut stream = driver.search_stream(request).await?;
666 let mut matches = Vec::new();
667 let mut progress = Vec::new();
668 let mut completed = None;
669 while let Some(event) = stream.next().await? {
670 match event {
671 SearchEvent::Match(found) => matches.push(found),
672 SearchEvent::Progress(current) => {
673 eprintln!(
674 "search progress: visited={} matches={}{}",
675 current.visited_nodes,
676 current.matches,
677 if current.partial { " (partial)" } else { "" }
678 );
679 progress.push(current);
680 }
681 SearchEvent::Completed(current) => completed = Some(current),
682 }
683 }
684 if output == OutputFormat::Json {
685 let matches = matches.iter().map(json_search_match).collect::<Vec<_>>();
686 let progress = progress
687 .iter()
688 .map(|current| {
689 serde_json::json!({
690 "visited_nodes": current.visited_nodes,
691 "matches": current.matches,
692 "partial": current.partial,
693 })
694 })
695 .collect::<Vec<_>>();
696 let completed = completed.map(|current| {
697 serde_json::json!({
698 "complete": current.complete,
699 "cancelled": current.cancelled,
700 "truncated": current.truncated,
701 "warning": current.warning,
702 })
703 });
704 println!(
705 "{}",
706 serde_json::to_string_pretty(&serde_json::json!({
707 "matches": matches,
708 "progress": progress,
709 "completed": completed,
710 }))?
711 );
712 return Ok(());
713 }
714 if matches.is_empty() {
715 println!("No matching tags found.");
716 } else {
717 for found in matches {
718 let item = found.node.item_id.as_deref().unwrap_or("-");
719 let breadcrumb = found
720 .breadcrumbs
721 .iter()
722 .map(|part| part.display_name.as_str())
723 .collect::<Vec<_>>()
724 .join("/");
725 println!("{item}\t{breadcrumb}");
726 }
727 }
728 let _ = completed.map(|done| {
729 if let Some(warning) = done.warning {
730 eprintln!("search warning: {warning}");
731 }
732 if done.truncated {
733 eprintln!("search results were truncated at the requested maximum");
734 }
735 });
736 Ok(())
737}
738
739fn json_search_match(found: &SearchMatch) -> serde_json::Value {
740 serde_json::json!({
741 "node": json_browse_node(&found.node),
742 "breadcrumbs": found.breadcrumbs.iter().map(|part| {
743 serde_json::json!({
744 "node_key": part.node_key,
745 "display_name": part.display_name,
746 })
747 }).collect::<Vec<_>>(),
748 })
749}
750
751fn print_search_index_status(
752 status: &SearchIndexStatus,
753 output: OutputFormat,
754) -> anyhow::Result<()> {
755 if output == OutputFormat::Json {
756 println!(
757 "{}",
758 serde_json::to_string_pretty(&json_search_index_status(status))?
759 );
760 return Ok(());
761 }
762 println!("Server: {}", status.server);
763 println!("State: {}", status.state);
764 println!("Auto-refresh enabled: {}", status.auto_refresh_enabled);
765 println!("Active generation: {}", status.active_generation);
766 println!("Entries: {}", status.entry_count);
767 println!("Unique items: {}", status.unique_item_count);
768 if let Some(progress) = &status.progress {
769 println!(
770 "Progress: {} entries, {:.1} items/s",
771 progress.entries_seen, progress.items_per_second
772 );
773 }
774 status.last_error.iter().for_each(|diagnostic| {
775 let label = if status.state != bhtune_driver::SearchIndexState::Failed {
776 "Last warning"
777 } else {
778 "Last error"
779 };
780 println!("{label}: {diagnostic}");
781 });
782 Ok(())
783}
784
785fn print_search_index_results(
786 response: &SearchIndexResponse,
787 output: OutputFormat,
788) -> anyhow::Result<()> {
789 if output == OutputFormat::Json {
790 println!(
791 "{}",
792 serde_json::to_string_pretty(&serde_json::json!({
793 "matches": response.matches.iter().map(json_indexed_search_match).collect::<Vec<_>>(),
794 "has_more": response.has_more,
795 "status": json_search_index_status(&response.status),
796 }))?
797 );
798 return Ok(());
799 }
800 if response.matches.is_empty() {
801 println!(
802 "No matching tags found (index state: {}).",
803 response.status.state
804 );
805 } else {
806 for found in &response.matches {
807 println!("{}\t{}", found.item_id, found.breadcrumbs.join("/"));
808 }
809 }
810 if response.has_more {
811 eprintln!("More than the requested number of matches exist; refine the query.");
812 }
813 if response.status.state != bhtune_driver::SearchIndexState::Ready {
814 eprintln!(
815 "Search results come from an {} namespace index.",
816 response.status.state
817 );
818 }
819 Ok(())
820}
821
822fn json_search_index_status(status: &SearchIndexStatus) -> serde_json::Value {
823 serde_json::json!({
824 "server": status.server,
825 "state": status.state.to_string(),
826 "auto_refresh_enabled": status.auto_refresh_enabled,
827 "active_generation": status.active_generation,
828 "entry_count": status.entry_count,
829 "unique_item_count": status.unique_item_count,
830 "started_at": status.started_at,
831 "completed_at": status.completed_at,
832 "last_error": status.last_error,
833 "database_bytes": status.database_bytes,
834 "organization": format!("{:?}", status.organization).to_lowercase(),
835 "source": format!("{:?}", status.source).to_lowercase(),
836 "scheduler": {
837 "next_refresh_at": status.scheduler.next_refresh_at,
838 "last_attempt_at": status.scheduler.last_attempt_at,
839 "last_success_at": status.scheduler.last_success_at,
840 "last_success_duration_ms": status.scheduler.last_success_duration_ms,
841 "retry_after": status.scheduler.retry_after,
842 "consecutive_failures": status.scheduler.consecutive_failures,
843 "circuit_open": status.scheduler.circuit_open,
844 },
845 "progress": status.progress.as_ref().map(|progress| serde_json::json!({
846 "branches_visited": progress.branches_visited,
847 "entries_seen": progress.entries_seen,
848 "unique_items": progress.unique_items,
849 "active_time_ms": progress.active_time_ms,
850 "paused_time_ms": progress.paused_time_ms,
851 "items_per_second": progress.items_per_second,
852 "estimated_remaining_ms": progress.estimated_remaining_ms,
853 })),
854 })
855}
856
857fn json_indexed_search_match(found: &bhtune_driver::IndexedSearchMatch) -> serde_json::Value {
858 serde_json::json!({
859 "item_id": found.item_id,
860 "display_name": found.display_name,
861 "kind": browse_node_kind_name(found.kind),
862 "breadcrumbs": found.breadcrumbs,
863 })
864}
865
866fn quality_name(quality: Quality) -> &'static str {
867 match quality {
868 Quality::Good => "good",
869 Quality::Uncertain => "uncertain",
870 Quality::Bad => "bad",
871 }
872}
873
874#[cfg(test)]
875mod tests;