Skip to main content

bhtune_cli/commands/
opc.rs

1//! `bhtune opc servers/read/write/browse/search`: thin passthrough diagnostics directly over
2//! [`bhtune_driver::OpcDaDriver`], independent of running a full tune. Useful for checking
3//! gateway connectivity and confirming tag names before starting a real test.
4
5use 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    // Numeric-looking values are written as floats (matching a live process value or PID
330    // constant write); anything else is written raw (e.g. a mode code like "MAN").
331    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;