Skip to main content

bee_tui/
once.rs

1//! `bee-tui --once <verb> [args…]` — single-shot CI mode.
2//!
3//! The whole TUI runtime (App, screens, ratatui, supervisor, watch
4//! hub) is bypassed. We build only what each verb needs:
5//!
6//!   * Pure-local verbs (`hash`, `cid`, `depth-table`, ...) need
7//!     nothing — they call into [`crate::utility_verbs`].
8//!   * Bee-API verbs (`readiness`, `inspect`, ...) build a one-shot
9//!     [`ApiClient`] from the active node profile and call
10//!     [`bee::Client`] directly.
11//!
12//! Output formats:
13//!   * Default: one human-readable line on stdout.
14//!   * `--json`: a single JSON object on stdout
15//!     (`{ "verb": "...", "status": "ok|unhealthy|usage_error|error",
16//!     "message": "...", "data": {...} }`).
17//!
18//! Exit codes:
19//!   * `0` — verb succeeded and answer was healthy / OK.
20//!   * `1` — verb completed but answer is unhealthy / failed gate /
21//!     network said no.
22//!   * `2` — usage error (unknown verb, bad args, missing config).
23//!
24//! Why this matters: makes every preview verb usable in CI / shell
25//! pipelines without parsing TUI output. `bee-tui --once readiness`
26//! is the canonical "is my Bee node ready for traffic?" smoke test.
27
28use std::process::ExitCode;
29use std::sync::Arc;
30
31use serde::Serialize;
32use serde_json::{Value, json};
33
34use crate::api::ApiClient;
35use crate::config::Config;
36use crate::durability;
37use crate::manifest_walker::{self, InspectResult};
38use crate::stamp_preview;
39use crate::utility_verbs;
40
41/// Top-level result that's printed (as text or JSON) and converted to
42/// an exit code.
43#[derive(Debug, Serialize)]
44pub struct OnceResult {
45    pub verb: String,
46    pub status: OnceStatus,
47    pub message: String,
48    #[serde(skip_serializing_if = "Value::is_null")]
49    pub data: Value,
50}
51
52#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
53#[serde(rename_all = "snake_case")]
54pub enum OnceStatus {
55    Ok,
56    Unhealthy,
57    Error,
58    UsageError,
59}
60
61impl OnceStatus {
62    pub fn exit_code(self) -> ExitCode {
63        match self {
64            Self::Ok => ExitCode::SUCCESS,
65            Self::Unhealthy | Self::Error => ExitCode::from(1),
66            Self::UsageError => ExitCode::from(2),
67        }
68    }
69}
70
71impl OnceResult {
72    pub fn ok(verb: &str, message: impl Into<String>) -> Self {
73        Self {
74            verb: verb.into(),
75            status: OnceStatus::Ok,
76            message: message.into(),
77            data: Value::Null,
78        }
79    }
80    pub fn ok_with_data(verb: &str, message: impl Into<String>, data: Value) -> Self {
81        Self {
82            verb: verb.into(),
83            status: OnceStatus::Ok,
84            message: message.into(),
85            data,
86        }
87    }
88    pub fn unhealthy(verb: &str, message: impl Into<String>, data: Value) -> Self {
89        Self {
90            verb: verb.into(),
91            status: OnceStatus::Unhealthy,
92            message: message.into(),
93            data,
94        }
95    }
96    pub fn error(verb: &str, message: impl Into<String>) -> Self {
97        Self {
98            verb: verb.into(),
99            status: OnceStatus::Error,
100            message: message.into(),
101            data: Value::Null,
102        }
103    }
104    pub fn usage(verb: &str, message: impl Into<String>) -> Self {
105        Self {
106            verb: verb.into(),
107            status: OnceStatus::UsageError,
108            message: message.into(),
109            data: Value::Null,
110        }
111    }
112}
113
114/// Top-level entrypoint for `--once`. Fetches what the chosen verb
115/// needs (or nothing for pure-local ones), runs the verb, prints
116/// the result, returns the exit code.
117pub async fn run(verb: &str, args: &[String], json_output: bool) -> ExitCode {
118    let result = dispatch(verb, args).await;
119    print_result(&result, json_output);
120    result.status.exit_code()
121}
122
123async fn dispatch(verb: &str, args: &[String]) -> OnceResult {
124    match verb {
125        // ---- Pure-local verbs (no Bee call). -----------------------
126        "hash" => once_hash(args),
127        "cid" => once_cid(args),
128        "depth-table" => once_depth_table(),
129        "pss-target" => once_pss_target(args),
130        "gsoc-mine" => once_gsoc_mine(args),
131
132        // ---- Bee-API verbs. ----------------------------------------
133        "readiness" => once_readiness().await,
134        "version-check" => once_version_check().await,
135        "inspect" => once_inspect(args).await,
136        "durability-check" => once_durability_check(args).await,
137
138        // ---- Stamp-economics verbs (one-shot fetch of chain state +
139        //      stamps list, then pure math).
140        "buy-preview" => once_buy_preview(args).await,
141        "buy-suggest" => once_buy_suggest(args).await,
142        "topup-preview" => once_topup_preview(args).await,
143        "dilute-preview" => once_dilute_preview(args).await,
144        "extend-preview" => once_extend_preview(args).await,
145        "plan-batch" => once_plan_batch(args).await,
146
147        // ---- Catch-all. --------------------------------------------
148        other => OnceResult::usage(
149            other,
150            format!(
151                "unknown --once verb {other:?}. Supported: hash, cid, depth-table, pss-target, gsoc-mine, readiness, version-check, inspect, durability-check, buy-preview, buy-suggest, topup-preview, dilute-preview, extend-preview, plan-batch"
152            ),
153        ),
154    }
155}
156
157// ---- Pure-local handlers ----------------------------------------------
158
159fn once_hash(args: &[String]) -> OnceResult {
160    let path = match args.first() {
161        Some(p) => p.as_str(),
162        None => {
163            return OnceResult::usage("hash", "usage: --once hash <path>");
164        }
165    };
166    match utility_verbs::hash_path(path) {
167        Ok(r) => OnceResult::ok_with_data(
168            "hash",
169            format!("hash {path}: {r}"),
170            json!({ "path": path, "reference": r }),
171        ),
172        Err(e) => OnceResult::error("hash", format!("hash failed: {e}")),
173    }
174}
175
176fn once_cid(args: &[String]) -> OnceResult {
177    let ref_arg = match args.first() {
178        Some(r) => r.as_str(),
179        None => return OnceResult::usage("cid", "usage: --once cid <ref> [manifest|feed]"),
180    };
181    let kind_arg = args.get(1).map(String::as_str);
182    let kind = match utility_verbs::parse_cid_kind(kind_arg) {
183        Ok(k) => k,
184        Err(e) => return OnceResult::usage("cid", e),
185    };
186    match utility_verbs::cid_for_ref(ref_arg, kind) {
187        Ok(cid) => {
188            OnceResult::ok_with_data("cid", format!("cid: {cid}"), json!({ "cid": cid }))
189        }
190        Err(e) => OnceResult::error("cid", format!("cid failed: {e}")),
191    }
192}
193
194fn once_depth_table() -> OnceResult {
195    OnceResult::ok_with_data(
196        "depth-table",
197        utility_verbs::depth_table(),
198        json!({ "table": utility_verbs::depth_table() }),
199    )
200}
201
202fn once_pss_target(args: &[String]) -> OnceResult {
203    let overlay = match args.first() {
204        Some(o) => o.as_str(),
205        None => return OnceResult::usage("pss-target", "usage: --once pss-target <overlay>"),
206    };
207    match utility_verbs::pss_target_for(overlay) {
208        Ok(prefix) => OnceResult::ok_with_data(
209            "pss-target",
210            format!("pss target prefix: {prefix}"),
211            json!({ "prefix": prefix }),
212        ),
213        Err(e) => OnceResult::error("pss-target", format!("pss-target failed: {e}")),
214    }
215}
216
217fn once_gsoc_mine(args: &[String]) -> OnceResult {
218    let overlay = args.first().map(String::as_str);
219    let ident = args.get(1).map(String::as_str);
220    let (overlay, ident) = match (overlay, ident) {
221        (Some(o), Some(i)) => (o, i),
222        _ => {
223            return OnceResult::usage(
224                "gsoc-mine",
225                "usage: --once gsoc-mine <overlay> <identifier>",
226            );
227        }
228    };
229    match utility_verbs::gsoc_mine_for(overlay, ident) {
230        Ok(out) => OnceResult::ok_with_data(
231            "gsoc-mine",
232            out.replace('\n', " · "),
233            json!({ "result": out }),
234        ),
235        Err(e) => OnceResult::error("gsoc-mine", format!("gsoc-mine failed: {e}")),
236    }
237}
238
239// ---- Bee-API handlers ------------------------------------------------
240
241/// Build a one-shot [`ApiClient`] against the active node profile.
242/// Returns the friendly UsageError for callers to surface when the
243/// config is missing.
244fn build_api() -> Result<Arc<ApiClient>, OnceResult> {
245    let config = match Config::new() {
246        Ok(c) => c,
247        Err(e) => {
248            return Err(OnceResult::usage(
249                "_config",
250                format!("could not load config: {e}"),
251            ));
252        }
253    };
254    let node = match config.active_node() {
255        Some(n) => n,
256        None => {
257            return Err(OnceResult::usage(
258                "_config",
259                "no Bee node configured (config.nodes is empty)",
260            ));
261        }
262    };
263    let api = match ApiClient::from_node(node) {
264        Ok(a) => Arc::new(a),
265        Err(e) => {
266            return Err(OnceResult::usage(
267                "_config",
268                format!("could not build api client: {e}"),
269            ));
270        }
271    };
272    Ok(api)
273}
274
275/// `--once readiness` — gateway-proxy-style "is this Bee node ready
276/// to serve?" check. Pass when /health says ok AND topology depth
277/// is in `[1, 30]`. Mirrors `swarm-gateway`'s readiness semantics.
278async fn once_readiness() -> OnceResult {
279    let api = match build_api() {
280        Ok(a) => a,
281        Err(r) => return r,
282    };
283    let bee = api.bee();
284    let debug = bee.debug();
285    let (health, topology) = tokio::join!(debug.health(), debug.topology());
286    let health = match health {
287        Ok(h) => h,
288        Err(e) => {
289            return OnceResult::error("readiness", format!("/health failed: {e}"));
290        }
291    };
292    let topology = match topology {
293        Ok(t) => t,
294        Err(e) => {
295            return OnceResult::error("readiness", format!("/topology failed: {e}"));
296        }
297    };
298    let depth = topology.depth as u32;
299    let depth_ok = (1..=30).contains(&depth);
300    let status_ok = health.status == "ok";
301    let data = json!({
302        "health_status": health.status,
303        "version": health.version,
304        "api_version": health.api_version,
305        "depth": depth,
306        "depth_ok": depth_ok,
307        "status_ok": status_ok,
308    });
309    if status_ok && depth_ok {
310        OnceResult::ok_with_data(
311            "readiness",
312            format!(
313                "READY · status={} · depth={depth} · version={}",
314                health.status, health.version
315            ),
316            data,
317        )
318    } else {
319        OnceResult::unhealthy(
320            "readiness",
321            format!(
322                "NOT READY · status={} · depth={depth} (need [1,30]) · version={}",
323                health.status, health.version
324            ),
325            data,
326        )
327    }
328}
329
330/// `--once version-check` — print Bee's reported version + API
331/// version. Always exits 0 unless the fetch fails.
332async fn once_version_check() -> OnceResult {
333    let api = match build_api() {
334        Ok(a) => a,
335        Err(r) => return r,
336    };
337    match api.bee().debug().health().await {
338        Ok(h) => OnceResult::ok_with_data(
339            "version-check",
340            format!("bee {} · api {}", h.version, h.api_version),
341            json!({
342                "version": h.version,
343                "api_version": h.api_version,
344            }),
345        ),
346        Err(e) => OnceResult::error("version-check", format!("/health failed: {e}")),
347    }
348}
349
350/// `--once inspect <ref>` — fetch one chunk + try to parse it as a
351/// Mantaray manifest. Mirrors the cockpit's `:inspect` verb.
352async fn once_inspect(args: &[String]) -> OnceResult {
353    let ref_arg = match args.first() {
354        Some(r) => r.as_str(),
355        None => return OnceResult::usage("inspect", "usage: --once inspect <ref>"),
356    };
357    let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
358        Ok(r) => r,
359        Err(e) => return OnceResult::usage("inspect", format!("bad ref: {e}")),
360    };
361    let api = match build_api() {
362        Ok(a) => a,
363        Err(r) => return r,
364    };
365    match manifest_walker::inspect(api, reference).await {
366        InspectResult::Manifest { node, bytes_len } => OnceResult::ok_with_data(
367            "inspect",
368            format!(
369                "manifest · {bytes_len} bytes · {} forks",
370                node.forks.len()
371            ),
372            json!({
373                "kind": "manifest",
374                "bytes": bytes_len,
375                "forks": node.forks.len(),
376            }),
377        ),
378        InspectResult::RawChunk { bytes_len } => OnceResult::ok_with_data(
379            "inspect",
380            format!("raw chunk · {bytes_len} bytes"),
381            json!({
382                "kind": "raw_chunk",
383                "bytes": bytes_len,
384            }),
385        ),
386        InspectResult::Error(e) => OnceResult::error("inspect", format!("inspect failed: {e}")),
387    }
388}
389
390/// `--once buy-preview <depth> <amount-plur>` — predict cost / TTL
391/// / capacity for a fresh batch buy at the chain's current price.
392/// One-shot fetch of `/chainstate` so we get the actual price, not
393/// a cached snapshot.
394async fn once_buy_preview(args: &[String]) -> OnceResult {
395    let (depth_str, amount_str) = match (args.first(), args.get(1)) {
396        (Some(d), Some(a)) => (d.as_str(), a.as_str()),
397        _ => {
398            return OnceResult::usage(
399                "buy-preview",
400                "usage: --once buy-preview <depth> <amount-plur>",
401            );
402        }
403    };
404    let depth: u8 = match depth_str.parse() {
405        Ok(d) => d,
406        Err(_) => return OnceResult::usage("buy-preview", format!("invalid depth: {depth_str}")),
407    };
408    let amount = match stamp_preview::parse_plur_amount(amount_str) {
409        Ok(a) => a,
410        Err(e) => return OnceResult::usage("buy-preview", e),
411    };
412    let api = match build_api() {
413        Ok(a) => a,
414        Err(r) => return r,
415    };
416    let chain = match api.bee().debug().chain_state().await {
417        Ok(c) => c,
418        Err(e) => {
419            return OnceResult::error("buy-preview", format!("/chainstate failed: {e}"));
420        }
421    };
422    match stamp_preview::buy_preview(depth, amount, &chain) {
423        Ok(p) => OnceResult::ok_with_data(
424            "buy-preview",
425            p.summary(),
426            json!({
427                "depth": p.depth,
428                "amount_plur": p.amount_plur.to_string(),
429                "ttl_seconds": p.ttl_seconds,
430                "cost_bzz": p.cost_bzz,
431            }),
432        ),
433        Err(e) => OnceResult::error("buy-preview", e),
434    }
435}
436
437/// `--once buy-suggest <size> <duration>` — inverse of buy-preview.
438/// Operator says "I want X bytes for Y seconds", we return the
439/// minimum `(depth, amount)` that covers it.
440async fn once_buy_suggest(args: &[String]) -> OnceResult {
441    let (size_str, duration_str) = match (args.first(), args.get(1)) {
442        (Some(s), Some(d)) => (s.as_str(), d.as_str()),
443        _ => {
444            return OnceResult::usage(
445                "buy-suggest",
446                "usage: --once buy-suggest <size> <duration>  (e.g. 5GiB 30d)",
447            );
448        }
449    };
450    let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
451        Ok(b) => b,
452        Err(e) => return OnceResult::usage("buy-suggest", e),
453    };
454    let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
455        Ok(s) => s,
456        Err(e) => return OnceResult::usage("buy-suggest", e),
457    };
458    let api = match build_api() {
459        Ok(a) => a,
460        Err(r) => return r,
461    };
462    let chain = match api.bee().debug().chain_state().await {
463        Ok(c) => c,
464        Err(e) => {
465            return OnceResult::error("buy-suggest", format!("/chainstate failed: {e}"));
466        }
467    };
468    match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
469        Ok(p) => OnceResult::ok_with_data(
470            "buy-suggest",
471            p.summary(),
472            json!({
473                "target_bytes": p.target_bytes.to_string(),
474                "target_seconds": p.target_seconds,
475                "depth": p.depth,
476                "amount_plur": p.amount_plur.to_string(),
477                "capacity_bytes": p.capacity_bytes.to_string(),
478                "ttl_seconds": p.ttl_seconds,
479                "cost_bzz": p.cost_bzz,
480            }),
481        ),
482        Err(e) => OnceResult::error("buy-suggest", e),
483    }
484}
485
486/// `--once topup-preview <batch-prefix> <amount-plur>` — predict the
487/// effect of topping up an existing batch.
488async fn once_topup_preview(args: &[String]) -> OnceResult {
489    let (prefix, amount_str) = match (args.first(), args.get(1)) {
490        (Some(p), Some(a)) => (p.as_str(), a.as_str()),
491        _ => {
492            return OnceResult::usage(
493                "topup-preview",
494                "usage: --once topup-preview <batch-prefix> <amount-plur>",
495            );
496        }
497    };
498    let amount = match stamp_preview::parse_plur_amount(amount_str) {
499        Ok(a) => a,
500        Err(e) => return OnceResult::usage("topup-preview", e),
501    };
502    let api = match build_api() {
503        Ok(a) => a,
504        Err(r) => return r,
505    };
506    let (batches, chain) = match fetch_stamps_and_chain(&api).await {
507        Ok(p) => p,
508        Err(e) => return OnceResult::error("topup-preview", e),
509    };
510    let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
511        Ok(b) => b.clone(),
512        Err(e) => return OnceResult::usage("topup-preview", e),
513    };
514    match stamp_preview::topup_preview(&batch, amount, &chain) {
515        Ok(p) => OnceResult::ok_with_data(
516            "topup-preview",
517            p.summary(),
518            json!({
519                "batch_id": batch.batch_id.to_hex(),
520                "current_depth": p.current_depth,
521                "current_ttl_seconds": p.current_ttl_seconds,
522                "delta_amount_plur": p.delta_amount.to_string(),
523                "extra_ttl_seconds": p.extra_ttl_seconds,
524                "new_ttl_seconds": p.new_ttl_seconds,
525                "cost_bzz": p.cost_bzz,
526            }),
527        ),
528        Err(e) => OnceResult::error("topup-preview", e),
529    }
530}
531
532/// `--once dilute-preview <batch-prefix> <new-depth>` — predict the
533/// effect of diluting an existing batch (each +1 depth halves
534/// per-chunk amount + TTL, doubles capacity).
535async fn once_dilute_preview(args: &[String]) -> OnceResult {
536    let (prefix, depth_str) = match (args.first(), args.get(1)) {
537        (Some(p), Some(d)) => (p.as_str(), d.as_str()),
538        _ => {
539            return OnceResult::usage(
540                "dilute-preview",
541                "usage: --once dilute-preview <batch-prefix> <new-depth>",
542            );
543        }
544    };
545    let new_depth: u8 = match depth_str.parse() {
546        Ok(d) => d,
547        Err(_) => {
548            return OnceResult::usage(
549                "dilute-preview",
550                format!("invalid depth: {depth_str}"),
551            );
552        }
553    };
554    let api = match build_api() {
555        Ok(a) => a,
556        Err(r) => return r,
557    };
558    let batches = match api.bee().postage().get_postage_batches().await {
559        Ok(b) => b,
560        Err(e) => return OnceResult::error("dilute-preview", format!("/stamps failed: {e}")),
561    };
562    let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
563        Ok(b) => b.clone(),
564        Err(e) => return OnceResult::usage("dilute-preview", e),
565    };
566    match stamp_preview::dilute_preview(&batch, new_depth) {
567        Ok(p) => OnceResult::ok_with_data(
568            "dilute-preview",
569            p.summary(),
570            json!({
571                "batch_id": batch.batch_id.to_hex(),
572                "old_depth": p.old_depth,
573                "new_depth": p.new_depth,
574                "old_ttl_seconds": p.old_ttl_seconds,
575                "new_ttl_seconds": p.new_ttl_seconds,
576            }),
577        ),
578        Err(e) => OnceResult::error("dilute-preview", e),
579    }
580}
581
582/// `--once extend-preview <batch-prefix> <duration>` — predict the
583/// per-chunk amount + cost needed to extend the batch's TTL by the
584/// requested duration.
585async fn once_extend_preview(args: &[String]) -> OnceResult {
586    let (prefix, duration_str) = match (args.first(), args.get(1)) {
587        (Some(p), Some(d)) => (p.as_str(), d.as_str()),
588        _ => {
589            return OnceResult::usage(
590                "extend-preview",
591                "usage: --once extend-preview <batch-prefix> <duration>",
592            );
593        }
594    };
595    let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
596        Ok(s) => s,
597        Err(e) => return OnceResult::usage("extend-preview", e),
598    };
599    let api = match build_api() {
600        Ok(a) => a,
601        Err(r) => return r,
602    };
603    let (batches, chain) = match fetch_stamps_and_chain(&api).await {
604        Ok(p) => p,
605        Err(e) => return OnceResult::error("extend-preview", e),
606    };
607    let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
608        Ok(b) => b.clone(),
609        Err(e) => return OnceResult::usage("extend-preview", e),
610    };
611    match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
612        Ok(p) => OnceResult::ok_with_data(
613            "extend-preview",
614            p.summary(),
615            json!({
616                "batch_id": batch.batch_id.to_hex(),
617                "depth": p.depth,
618                "current_ttl_seconds": p.current_ttl_seconds,
619                "needed_amount_plur": p.needed_amount_plur.to_string(),
620                "cost_bzz": p.cost_bzz,
621                "new_ttl_seconds": p.new_ttl_seconds,
622            }),
623        ),
624        Err(e) => OnceResult::error("extend-preview", e),
625    }
626}
627
628/// `--once plan-batch <prefix> [usage-thr] [ttl-thr] [extra-depth]` —
629/// the unified topup+dilute decision. Mirrors the cockpit's
630/// `:plan-batch` verb. Exits `1` when an action is recommended (so a
631/// CI job can gate on "this batch needs human attention").
632async fn once_plan_batch(args: &[String]) -> OnceResult {
633    let prefix = match args.first() {
634        Some(p) => p.as_str(),
635        None => {
636            return OnceResult::usage(
637                "plan-batch",
638                "usage: --once plan-batch <batch-prefix> [usage-thr] [ttl-thr] [extra-depth]",
639            );
640        }
641    };
642    let usage_thr = match args.get(1) {
643        Some(s) => match s.parse::<f64>() {
644            Ok(v) => v,
645            Err(_) => {
646                return OnceResult::usage(
647                    "plan-batch",
648                    format!("invalid usage-thr {s:?} (expected float in [0,1])"),
649                );
650            }
651        },
652        None => stamp_preview::DEFAULT_USAGE_THRESHOLD,
653    };
654    let ttl_thr = match args.get(2) {
655        Some(s) => match stamp_preview::parse_duration_seconds(s) {
656            Ok(v) => v,
657            Err(e) => return OnceResult::usage("plan-batch", format!("ttl-thr: {e}")),
658        },
659        None => stamp_preview::DEFAULT_TTL_THRESHOLD_SECONDS,
660    };
661    let extra_depth = match args.get(3) {
662        Some(s) => match s.parse::<u8>() {
663            Ok(v) => v,
664            Err(_) => {
665                return OnceResult::usage(
666                    "plan-batch",
667                    format!("invalid extra-depth {s:?}"),
668                );
669            }
670        },
671        None => stamp_preview::DEFAULT_EXTRA_DEPTH,
672    };
673    let api = match build_api() {
674        Ok(a) => a,
675        Err(r) => return r,
676    };
677    let (batches, chain) = match fetch_stamps_and_chain(&api).await {
678        Ok(p) => p,
679        Err(e) => return OnceResult::error("plan-batch", e),
680    };
681    let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
682        Ok(b) => b.clone(),
683        Err(e) => return OnceResult::usage("plan-batch", e),
684    };
685    match stamp_preview::plan_batch(&batch, &chain, usage_thr, ttl_thr, extra_depth) {
686        Ok(p) => {
687            let action_kind = match &p.action {
688                stamp_preview::PlanAction::None => "none",
689                stamp_preview::PlanAction::Topup { .. } => "topup",
690                stamp_preview::PlanAction::Dilute { .. } => "dilute",
691                stamp_preview::PlanAction::TopupThenDilute { .. } => "topup_then_dilute",
692            };
693            let data = json!({
694                "batch_id": batch.batch_id.to_hex(),
695                "current_depth": p.current_depth,
696                "current_usage_pct": p.current_usage_pct,
697                "current_ttl_seconds": p.current_ttl_seconds,
698                "usage_threshold_pct": p.usage_threshold_pct,
699                "ttl_threshold_seconds": p.ttl_threshold_seconds,
700                "extra_depth": p.extra_depth,
701                "action": action_kind,
702                "total_cost_bzz": p.total_cost_bzz,
703                "reason": p.reason.clone(),
704            });
705            // Exit 1 when an action is recommended — lets CI gate on
706            // "this batch needs attention." Status `Ok` only when no
707            // action is needed.
708            if matches!(p.action, stamp_preview::PlanAction::None) {
709                OnceResult::ok_with_data("plan-batch", p.summary(), data)
710            } else {
711                OnceResult::unhealthy("plan-batch", p.summary(), data)
712            }
713        }
714        Err(e) => OnceResult::error("plan-batch", e),
715    }
716}
717
718/// Helper: one-shot parallel fetch of the postage batches list +
719/// chain state. Used by the topup/extend paths which need both.
720async fn fetch_stamps_and_chain(
721    api: &Arc<ApiClient>,
722) -> Result<
723    (
724        Vec<bee::postage::PostageBatch>,
725        bee::debug::ChainState,
726    ),
727    String,
728> {
729    let bee = api.bee();
730    let postage = bee.postage();
731    let debug = bee.debug();
732    let (batches, chain) = tokio::join!(postage.get_postage_batches(), debug.chain_state());
733    let batches = batches.map_err(|e| format!("/stamps failed: {e}"))?;
734    let chain = chain.map_err(|e| format!("/chainstate failed: {e}"))?;
735    Ok((batches, chain))
736}
737
738/// `--once durability-check <ref>` — same chunk-graph walk the
739/// cockpit's verb does, but in batch / CI mode.
740async fn once_durability_check(args: &[String]) -> OnceResult {
741    let ref_arg = match args.first() {
742        Some(r) => r.as_str(),
743        None => {
744            return OnceResult::usage(
745                "durability-check",
746                "usage: --once durability-check <ref>",
747            );
748        }
749    };
750    let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
751        Ok(r) => r,
752        Err(e) => return OnceResult::usage("durability-check", format!("bad ref: {e}")),
753    };
754    let api = match build_api() {
755        Ok(a) => a,
756        Err(r) => return r,
757    };
758    let result = durability::check(api, reference).await;
759    let data = json!({
760        "chunks_total": result.chunks_total,
761        "chunks_lost": result.chunks_lost,
762        "chunks_errors": result.chunks_errors,
763        "duration_ms": result.duration_ms,
764        "root_is_manifest": result.root_is_manifest,
765        "truncated": result.truncated,
766    });
767    if result.is_healthy() {
768        OnceResult::ok_with_data("durability-check", result.summary(), data)
769    } else {
770        OnceResult::unhealthy("durability-check", result.summary(), data)
771    }
772}
773
774// ---- Output ----------------------------------------------------------
775
776fn print_result(result: &OnceResult, json_output: bool) {
777    if json_output {
778        match serde_json::to_string(result) {
779            Ok(s) => println!("{s}"),
780            Err(e) => eprintln!("(failed to serialize result: {e})"),
781        }
782        return;
783    }
784    let prefix = match result.status {
785        OnceStatus::Ok => "OK",
786        OnceStatus::Unhealthy => "UNHEALTHY",
787        OnceStatus::Error => "ERROR",
788        OnceStatus::UsageError => "USAGE",
789    };
790    println!("[{prefix}] {}", result.message);
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    fn args(s: &[&str]) -> Vec<String> {
798        s.iter().map(|x| x.to_string()).collect()
799    }
800
801    #[test]
802    fn unknown_verb_returns_usage_error() {
803        let r = once_pss_target(&[]);
804        assert!(matches!(r.status, OnceStatus::UsageError));
805        assert!(r.message.contains("usage"), "{}", r.message);
806    }
807
808    #[test]
809    fn cid_handler_round_trips() {
810        let r = once_cid(&args(&[&"0".repeat(64), "feed"]));
811        assert!(matches!(r.status, OnceStatus::Ok));
812        assert!(r.message.contains("cid:"), "{}", r.message);
813        // JSON data contains the CID.
814        assert!(r.data["cid"].is_string());
815    }
816
817    #[test]
818    fn cid_handler_rejects_garbage() {
819        let r = once_cid(&args(&["not-hex"]));
820        assert!(matches!(r.status, OnceStatus::Error));
821    }
822
823    #[test]
824    fn cid_handler_no_args_is_usage_error() {
825        let r = once_cid(&[]);
826        assert!(matches!(r.status, OnceStatus::UsageError));
827    }
828
829    #[test]
830    fn pss_target_extracts_prefix() {
831        let r = once_pss_target(&args(&[
832            "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
833        ]));
834        assert!(matches!(r.status, OnceStatus::Ok));
835        assert!(r.message.contains("abcd"), "{}", r.message);
836    }
837
838    #[test]
839    fn depth_table_renders_full_table() {
840        let r = once_depth_table();
841        assert!(matches!(r.status, OnceStatus::Ok));
842        assert!(r.message.contains("depth"));
843        assert!(r.message.contains("17"));
844        assert!(r.message.contains("34"));
845    }
846
847    #[test]
848    fn exit_codes_map_correctly() {
849        assert_eq!(
850            OnceStatus::Ok.exit_code(),
851            std::process::ExitCode::SUCCESS
852        );
853        // UsageError vs Error vs Unhealthy all distinguishable. We
854        // can't equality-test ExitCode::from(N) directly, but we can
855        // exercise that the path doesn't panic.
856        let _ = OnceStatus::Unhealthy.exit_code();
857        let _ = OnceStatus::Error.exit_code();
858        let _ = OnceStatus::UsageError.exit_code();
859    }
860
861    #[test]
862    fn ok_helpers_compose_the_expected_shape() {
863        let r = OnceResult::ok("v", "all good");
864        assert_eq!(r.verb, "v");
865        assert!(matches!(r.status, OnceStatus::Ok));
866        assert_eq!(r.message, "all good");
867        assert!(r.data.is_null());
868
869        let r2 = OnceResult::unhealthy("v", "broken", json!({"x": 1}));
870        assert!(matches!(r2.status, OnceStatus::Unhealthy));
871        assert_eq!(r2.data["x"], 1);
872    }
873
874    #[test]
875    fn print_result_json_output_is_one_line() {
876        // Smoke test the JSON path doesn't panic. We don't capture
877        // stdout here — that's an integration concern.
878        let r = OnceResult::ok("hash", "hash X: abc");
879        print_result(&r, true);
880        print_result(&r, false);
881    }
882}