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::config_doctor;
37use crate::durability;
38use crate::economics_oracle;
39use crate::manifest_walker::{self, InspectResult};
40use crate::stamp_preview;
41use crate::utility_verbs;
42use crate::version_check;
43
44/// Top-level result that's printed (as text or JSON) and converted to
45/// an exit code.
46#[derive(Debug, Serialize)]
47pub struct OnceResult {
48    pub verb: String,
49    pub status: OnceStatus,
50    pub message: String,
51    #[serde(skip_serializing_if = "Value::is_null")]
52    pub data: Value,
53}
54
55#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum OnceStatus {
58    Ok,
59    Unhealthy,
60    Error,
61    UsageError,
62}
63
64impl OnceStatus {
65    pub fn exit_code(self) -> ExitCode {
66        match self {
67            Self::Ok => ExitCode::SUCCESS,
68            Self::Unhealthy | Self::Error => ExitCode::from(1),
69            Self::UsageError => ExitCode::from(2),
70        }
71    }
72}
73
74impl OnceResult {
75    pub fn ok(verb: &str, message: impl Into<String>) -> Self {
76        Self {
77            verb: verb.into(),
78            status: OnceStatus::Ok,
79            message: message.into(),
80            data: Value::Null,
81        }
82    }
83    pub fn ok_with_data(verb: &str, message: impl Into<String>, data: Value) -> Self {
84        Self {
85            verb: verb.into(),
86            status: OnceStatus::Ok,
87            message: message.into(),
88            data,
89        }
90    }
91    pub fn unhealthy(verb: &str, message: impl Into<String>, data: Value) -> Self {
92        Self {
93            verb: verb.into(),
94            status: OnceStatus::Unhealthy,
95            message: message.into(),
96            data,
97        }
98    }
99    pub fn error(verb: &str, message: impl Into<String>) -> Self {
100        Self {
101            verb: verb.into(),
102            status: OnceStatus::Error,
103            message: message.into(),
104            data: Value::Null,
105        }
106    }
107    pub fn usage(verb: &str, message: impl Into<String>) -> Self {
108        Self {
109            verb: verb.into(),
110            status: OnceStatus::UsageError,
111            message: message.into(),
112            data: Value::Null,
113        }
114    }
115}
116
117/// Top-level entrypoint for `--once`. Fetches what the chosen verb
118/// needs (or nothing for pure-local ones), runs the verb, prints
119/// the result, returns the exit code.
120pub async fn run(verb: &str, args: &[String], json_output: bool) -> ExitCode {
121    let result = dispatch(verb, args).await;
122    print_result(&result, json_output);
123    result.status.exit_code()
124}
125
126async fn dispatch(verb: &str, args: &[String]) -> OnceResult {
127    match verb {
128        // ---- Pure-local verbs (no Bee call). -----------------------
129        "hash" => once_hash(args),
130        "cid" => once_cid(args),
131        "depth-table" => once_depth_table(),
132        "pss-target" => once_pss_target(args),
133        "gsoc-mine" => once_gsoc_mine(args),
134
135        // ---- Bee-API verbs. ----------------------------------------
136        "readiness" => once_readiness().await,
137        "version-check" => once_version_check().await,
138        "inspect" => once_inspect(args).await,
139        "durability-check" => once_durability_check(args).await,
140        "upload-file" => once_upload_file(args).await,
141
142        // ---- Stamp-economics verbs (one-shot fetch of chain state +
143        //      stamps list, then pure math).
144        "buy-preview" => once_buy_preview(args).await,
145        "buy-suggest" => once_buy_suggest(args).await,
146        "topup-preview" => once_topup_preview(args).await,
147        "dilute-preview" => once_dilute_preview(args).await,
148        "extend-preview" => once_extend_preview(args).await,
149        "plan-batch" => once_plan_batch(args).await,
150        "check-version" => once_check_version().await,
151        "config-doctor" => once_config_doctor(args),
152        "price" => once_price().await,
153        "basefee" => once_basefee().await,
154
155        // ---- Catch-all. --------------------------------------------
156        other => OnceResult::usage(
157            other,
158            format!(
159                "unknown --once verb {other:?}. Supported: hash, cid, depth-table, pss-target, gsoc-mine, readiness, version-check, check-version, config-doctor, price, basefee, inspect, durability-check, upload-file, buy-preview, buy-suggest, topup-preview, dilute-preview, extend-preview, plan-batch"
160            ),
161        ),
162    }
163}
164
165// ---- Pure-local handlers ----------------------------------------------
166
167fn once_hash(args: &[String]) -> OnceResult {
168    let path = match args.first() {
169        Some(p) => p.as_str(),
170        None => {
171            return OnceResult::usage("hash", "usage: --once hash <path>");
172        }
173    };
174    match utility_verbs::hash_path(path) {
175        Ok(r) => OnceResult::ok_with_data(
176            "hash",
177            format!("hash {path}: {r}"),
178            json!({ "path": path, "reference": r }),
179        ),
180        Err(e) => OnceResult::error("hash", format!("hash failed: {e}")),
181    }
182}
183
184fn once_cid(args: &[String]) -> OnceResult {
185    let ref_arg = match args.first() {
186        Some(r) => r.as_str(),
187        None => return OnceResult::usage("cid", "usage: --once cid <ref> [manifest|feed]"),
188    };
189    let kind_arg = args.get(1).map(String::as_str);
190    let kind = match utility_verbs::parse_cid_kind(kind_arg) {
191        Ok(k) => k,
192        Err(e) => return OnceResult::usage("cid", e),
193    };
194    match utility_verbs::cid_for_ref(ref_arg, kind) {
195        Ok(cid) => {
196            OnceResult::ok_with_data("cid", format!("cid: {cid}"), json!({ "cid": cid }))
197        }
198        Err(e) => OnceResult::error("cid", format!("cid failed: {e}")),
199    }
200}
201
202fn once_depth_table() -> OnceResult {
203    OnceResult::ok_with_data(
204        "depth-table",
205        utility_verbs::depth_table(),
206        json!({ "table": utility_verbs::depth_table() }),
207    )
208}
209
210fn once_pss_target(args: &[String]) -> OnceResult {
211    let overlay = match args.first() {
212        Some(o) => o.as_str(),
213        None => return OnceResult::usage("pss-target", "usage: --once pss-target <overlay>"),
214    };
215    match utility_verbs::pss_target_for(overlay) {
216        Ok(prefix) => OnceResult::ok_with_data(
217            "pss-target",
218            format!("pss target prefix: {prefix}"),
219            json!({ "prefix": prefix }),
220        ),
221        Err(e) => OnceResult::error("pss-target", format!("pss-target failed: {e}")),
222    }
223}
224
225fn once_gsoc_mine(args: &[String]) -> OnceResult {
226    let overlay = args.first().map(String::as_str);
227    let ident = args.get(1).map(String::as_str);
228    let (overlay, ident) = match (overlay, ident) {
229        (Some(o), Some(i)) => (o, i),
230        _ => {
231            return OnceResult::usage(
232                "gsoc-mine",
233                "usage: --once gsoc-mine <overlay> <identifier>",
234            );
235        }
236    };
237    match utility_verbs::gsoc_mine_for(overlay, ident) {
238        Ok(out) => OnceResult::ok_with_data(
239            "gsoc-mine",
240            out.replace('\n', " · "),
241            json!({ "result": out }),
242        ),
243        Err(e) => OnceResult::error("gsoc-mine", format!("gsoc-mine failed: {e}")),
244    }
245}
246
247// ---- Bee-API handlers ------------------------------------------------
248
249/// Build a one-shot [`ApiClient`] against the active node profile.
250/// Returns the friendly UsageError for callers to surface when the
251/// config is missing.
252fn build_api() -> Result<Arc<ApiClient>, OnceResult> {
253    let config = match Config::new() {
254        Ok(c) => c,
255        Err(e) => {
256            return Err(OnceResult::usage(
257                "_config",
258                format!("could not load config: {e}"),
259            ));
260        }
261    };
262    let node = match config.active_node() {
263        Some(n) => n,
264        None => {
265            return Err(OnceResult::usage(
266                "_config",
267                "no Bee node configured (config.nodes is empty)",
268            ));
269        }
270    };
271    let api = match ApiClient::from_node(node) {
272        Ok(a) => Arc::new(a),
273        Err(e) => {
274            return Err(OnceResult::usage(
275                "_config",
276                format!("could not build api client: {e}"),
277            ));
278        }
279    };
280    Ok(api)
281}
282
283/// `--once readiness` — gateway-proxy-style "is this Bee node ready
284/// to serve?" check. Pass when /health says ok AND topology depth
285/// is in `[1, 30]`. Mirrors `swarm-gateway`'s readiness semantics.
286async fn once_readiness() -> OnceResult {
287    let api = match build_api() {
288        Ok(a) => a,
289        Err(r) => return r,
290    };
291    let bee = api.bee();
292    let debug = bee.debug();
293    let (health, topology) = tokio::join!(debug.health(), debug.topology());
294    let health = match health {
295        Ok(h) => h,
296        Err(e) => {
297            return OnceResult::error("readiness", format!("/health failed: {e}"));
298        }
299    };
300    let topology = match topology {
301        Ok(t) => t,
302        Err(e) => {
303            return OnceResult::error("readiness", format!("/topology failed: {e}"));
304        }
305    };
306    let depth = topology.depth as u32;
307    let depth_ok = (1..=30).contains(&depth);
308    let status_ok = health.status == "ok";
309    let data = json!({
310        "health_status": health.status,
311        "version": health.version,
312        "api_version": health.api_version,
313        "depth": depth,
314        "depth_ok": depth_ok,
315        "status_ok": status_ok,
316    });
317    if status_ok && depth_ok {
318        OnceResult::ok_with_data(
319            "readiness",
320            format!(
321                "READY · status={} · depth={depth} · version={}",
322                health.status, health.version
323            ),
324            data,
325        )
326    } else {
327        OnceResult::unhealthy(
328            "readiness",
329            format!(
330                "NOT READY · status={} · depth={depth} (need [1,30]) · version={}",
331                health.status, health.version
332            ),
333            data,
334        )
335    }
336}
337
338/// `--once version-check` — print Bee's reported version + API
339/// version. Always exits 0 unless the fetch fails.
340async fn once_version_check() -> OnceResult {
341    let api = match build_api() {
342        Ok(a) => a,
343        Err(r) => return r,
344    };
345    match api.bee().debug().health().await {
346        Ok(h) => OnceResult::ok_with_data(
347            "version-check",
348            format!("bee {} · api {}", h.version, h.api_version),
349            json!({
350                "version": h.version,
351                "api_version": h.api_version,
352            }),
353        ),
354        Err(e) => OnceResult::error("version-check", format!("/health failed: {e}")),
355    }
356}
357
358/// `--once inspect <ref>` — fetch one chunk + try to parse it as a
359/// Mantaray manifest. Mirrors the cockpit's `:inspect` verb.
360async fn once_inspect(args: &[String]) -> OnceResult {
361    let ref_arg = match args.first() {
362        Some(r) => r.as_str(),
363        None => return OnceResult::usage("inspect", "usage: --once inspect <ref>"),
364    };
365    let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
366        Ok(r) => r,
367        Err(e) => return OnceResult::usage("inspect", format!("bad ref: {e}")),
368    };
369    let api = match build_api() {
370        Ok(a) => a,
371        Err(r) => return r,
372    };
373    match manifest_walker::inspect(api, reference).await {
374        InspectResult::Manifest { node, bytes_len } => OnceResult::ok_with_data(
375            "inspect",
376            format!(
377                "manifest · {bytes_len} bytes · {} forks",
378                node.forks.len()
379            ),
380            json!({
381                "kind": "manifest",
382                "bytes": bytes_len,
383                "forks": node.forks.len(),
384            }),
385        ),
386        InspectResult::RawChunk { bytes_len } => OnceResult::ok_with_data(
387            "inspect",
388            format!("raw chunk · {bytes_len} bytes"),
389            json!({
390                "kind": "raw_chunk",
391                "bytes": bytes_len,
392            }),
393        ),
394        InspectResult::Error(e) => OnceResult::error("inspect", format!("inspect failed: {e}")),
395    }
396}
397
398/// `--once upload-file <path> <batch-prefix>` — upload a single file
399/// via `POST /bzz` and emit `{"reference": ...}`. CI-friendly: the
400/// JSON output gives a workflow the swarm hash to publish without
401/// shelling out to the cockpit. 256-MiB cap matches the cockpit verb.
402async fn once_upload_file(args: &[String]) -> OnceResult {
403    let (path_str, prefix) = match (args.first(), args.get(1)) {
404        (Some(p), Some(b)) => (p.as_str(), b.as_str()),
405        _ => {
406            return OnceResult::usage(
407                "upload-file",
408                "usage: --once upload-file <path> <batch-prefix>",
409            );
410        }
411    };
412    let path = std::path::PathBuf::from(path_str);
413    let meta = match std::fs::metadata(&path) {
414        Ok(m) => m,
415        Err(e) => return OnceResult::usage("upload-file", format!("stat {path_str}: {e}")),
416    };
417    if meta.is_dir() {
418        return OnceResult::usage(
419            "upload-file",
420            format!("{path_str} is a directory; --once upload-file is single-file only"),
421        );
422    }
423    const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
424    if meta.len() > MAX_FILE_BYTES {
425        return OnceResult::usage(
426            "upload-file",
427            format!(
428                "{path_str} is {} bytes — over the {}-MiB ceiling",
429                meta.len(),
430                MAX_FILE_BYTES / (1024 * 1024),
431            ),
432        );
433    }
434    let api = match build_api() {
435        Ok(a) => a,
436        Err(r) => return r,
437    };
438    let batches = match api.bee().postage().get_postage_batches().await {
439        Ok(b) => b,
440        Err(e) => return OnceResult::error("upload-file", format!("/stamps failed: {e}")),
441    };
442    let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
443        Ok(b) => b.clone(),
444        Err(e) => return OnceResult::usage("upload-file", e),
445    };
446    if !batch.usable {
447        return OnceResult::error(
448            "upload-file",
449            format!(
450                "batch {} is not usable yet (waiting on chain confirmation)",
451                batch.batch_id.to_hex(),
452            ),
453        );
454    }
455    if batch.batch_ttl <= 0 {
456        return OnceResult::error(
457            "upload-file",
458            format!("batch {} is expired", batch.batch_id.to_hex()),
459        );
460    }
461    let data = match tokio::fs::read(&path).await {
462        Ok(b) => b,
463        Err(e) => return OnceResult::error("upload-file", format!("read {path_str}: {e}")),
464    };
465    let name = path
466        .file_name()
467        .and_then(|n| n.to_str())
468        .unwrap_or("")
469        .to_string();
470    let content_type = upload_content_type(&path);
471    let result = api
472        .bee()
473        .file()
474        .upload_file(&batch.batch_id, data, &name, &content_type, None)
475        .await;
476    match result {
477        Ok(res) => OnceResult::ok_with_data(
478            "upload-file",
479            format!(
480                "uploaded {} bytes → ref {} (batch {})",
481                meta.len(),
482                res.reference.to_hex(),
483                &batch.batch_id.to_hex()[..8],
484            ),
485            json!({
486                "path": path_str,
487                "size": meta.len(),
488                "reference": res.reference.to_hex(),
489                "batch_id": batch.batch_id.to_hex(),
490                "name": name,
491                "content_type": if content_type.is_empty() { "application/octet-stream".to_string() } else { content_type },
492            }),
493        ),
494        Err(e) => OnceResult::error("upload-file", format!("upload failed: {e}")),
495    }
496}
497
498/// Best-effort MIME guess by extension. Empty string means "let
499/// bee-rs default to application/octet-stream". Mirrors the
500/// cockpit's `guess_content_type` so both verbs behave identically.
501fn upload_content_type(path: &std::path::Path) -> String {
502    let ext = path
503        .extension()
504        .and_then(|e| e.to_str())
505        .map(|s| s.to_ascii_lowercase());
506    match ext.as_deref() {
507        Some("html") | Some("htm") => "text/html",
508        Some("txt") | Some("md") => "text/plain",
509        Some("json") => "application/json",
510        Some("css") => "text/css",
511        Some("js") => "application/javascript",
512        Some("png") => "image/png",
513        Some("jpg") | Some("jpeg") => "image/jpeg",
514        Some("gif") => "image/gif",
515        Some("svg") => "image/svg+xml",
516        Some("webp") => "image/webp",
517        Some("pdf") => "application/pdf",
518        Some("zip") => "application/zip",
519        Some("tar") => "application/x-tar",
520        Some("gz") | Some("tgz") => "application/gzip",
521        Some("wasm") => "application/wasm",
522        _ => "",
523    }
524    .to_string()
525}
526
527/// `--once buy-preview <depth> <amount-plur>` — predict cost / TTL
528/// / capacity for a fresh batch buy at the chain's current price.
529/// One-shot fetch of `/chainstate` so we get the actual price, not
530/// a cached snapshot.
531async fn once_buy_preview(args: &[String]) -> OnceResult {
532    let (depth_str, amount_str) = match (args.first(), args.get(1)) {
533        (Some(d), Some(a)) => (d.as_str(), a.as_str()),
534        _ => {
535            return OnceResult::usage(
536                "buy-preview",
537                "usage: --once buy-preview <depth> <amount-plur>",
538            );
539        }
540    };
541    let depth: u8 = match depth_str.parse() {
542        Ok(d) => d,
543        Err(_) => return OnceResult::usage("buy-preview", format!("invalid depth: {depth_str}")),
544    };
545    let amount = match stamp_preview::parse_plur_amount(amount_str) {
546        Ok(a) => a,
547        Err(e) => return OnceResult::usage("buy-preview", e),
548    };
549    let api = match build_api() {
550        Ok(a) => a,
551        Err(r) => return r,
552    };
553    let chain = match api.bee().debug().chain_state().await {
554        Ok(c) => c,
555        Err(e) => {
556            return OnceResult::error("buy-preview", format!("/chainstate failed: {e}"));
557        }
558    };
559    match stamp_preview::buy_preview(depth, amount, &chain) {
560        Ok(p) => OnceResult::ok_with_data(
561            "buy-preview",
562            p.summary(),
563            json!({
564                "depth": p.depth,
565                "amount_plur": p.amount_plur.to_string(),
566                "ttl_seconds": p.ttl_seconds,
567                "cost_bzz": p.cost_bzz,
568            }),
569        ),
570        Err(e) => OnceResult::error("buy-preview", e),
571    }
572}
573
574/// `--once buy-suggest <size> <duration>` — inverse of buy-preview.
575/// Operator says "I want X bytes for Y seconds", we return the
576/// minimum `(depth, amount)` that covers it.
577async fn once_buy_suggest(args: &[String]) -> OnceResult {
578    let (size_str, duration_str) = match (args.first(), args.get(1)) {
579        (Some(s), Some(d)) => (s.as_str(), d.as_str()),
580        _ => {
581            return OnceResult::usage(
582                "buy-suggest",
583                "usage: --once buy-suggest <size> <duration>  (e.g. 5GiB 30d)",
584            );
585        }
586    };
587    let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
588        Ok(b) => b,
589        Err(e) => return OnceResult::usage("buy-suggest", e),
590    };
591    let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
592        Ok(s) => s,
593        Err(e) => return OnceResult::usage("buy-suggest", e),
594    };
595    let api = match build_api() {
596        Ok(a) => a,
597        Err(r) => return r,
598    };
599    let chain = match api.bee().debug().chain_state().await {
600        Ok(c) => c,
601        Err(e) => {
602            return OnceResult::error("buy-suggest", format!("/chainstate failed: {e}"));
603        }
604    };
605    match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
606        Ok(p) => OnceResult::ok_with_data(
607            "buy-suggest",
608            p.summary(),
609            json!({
610                "target_bytes": p.target_bytes.to_string(),
611                "target_seconds": p.target_seconds,
612                "depth": p.depth,
613                "amount_plur": p.amount_plur.to_string(),
614                "capacity_bytes": p.capacity_bytes.to_string(),
615                "ttl_seconds": p.ttl_seconds,
616                "cost_bzz": p.cost_bzz,
617            }),
618        ),
619        Err(e) => OnceResult::error("buy-suggest", e),
620    }
621}
622
623/// `--once topup-preview <batch-prefix> <amount-plur>` — predict the
624/// effect of topping up an existing batch.
625async fn once_topup_preview(args: &[String]) -> OnceResult {
626    let (prefix, amount_str) = match (args.first(), args.get(1)) {
627        (Some(p), Some(a)) => (p.as_str(), a.as_str()),
628        _ => {
629            return OnceResult::usage(
630                "topup-preview",
631                "usage: --once topup-preview <batch-prefix> <amount-plur>",
632            );
633        }
634    };
635    let amount = match stamp_preview::parse_plur_amount(amount_str) {
636        Ok(a) => a,
637        Err(e) => return OnceResult::usage("topup-preview", e),
638    };
639    let api = match build_api() {
640        Ok(a) => a,
641        Err(r) => return r,
642    };
643    let (batches, chain) = match fetch_stamps_and_chain(&api).await {
644        Ok(p) => p,
645        Err(e) => return OnceResult::error("topup-preview", e),
646    };
647    let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
648        Ok(b) => b.clone(),
649        Err(e) => return OnceResult::usage("topup-preview", e),
650    };
651    match stamp_preview::topup_preview(&batch, amount, &chain) {
652        Ok(p) => OnceResult::ok_with_data(
653            "topup-preview",
654            p.summary(),
655            json!({
656                "batch_id": batch.batch_id.to_hex(),
657                "current_depth": p.current_depth,
658                "current_ttl_seconds": p.current_ttl_seconds,
659                "delta_amount_plur": p.delta_amount.to_string(),
660                "extra_ttl_seconds": p.extra_ttl_seconds,
661                "new_ttl_seconds": p.new_ttl_seconds,
662                "cost_bzz": p.cost_bzz,
663            }),
664        ),
665        Err(e) => OnceResult::error("topup-preview", e),
666    }
667}
668
669/// `--once dilute-preview <batch-prefix> <new-depth>` — predict the
670/// effect of diluting an existing batch (each +1 depth halves
671/// per-chunk amount + TTL, doubles capacity).
672async fn once_dilute_preview(args: &[String]) -> OnceResult {
673    let (prefix, depth_str) = match (args.first(), args.get(1)) {
674        (Some(p), Some(d)) => (p.as_str(), d.as_str()),
675        _ => {
676            return OnceResult::usage(
677                "dilute-preview",
678                "usage: --once dilute-preview <batch-prefix> <new-depth>",
679            );
680        }
681    };
682    let new_depth: u8 = match depth_str.parse() {
683        Ok(d) => d,
684        Err(_) => {
685            return OnceResult::usage(
686                "dilute-preview",
687                format!("invalid depth: {depth_str}"),
688            );
689        }
690    };
691    let api = match build_api() {
692        Ok(a) => a,
693        Err(r) => return r,
694    };
695    let batches = match api.bee().postage().get_postage_batches().await {
696        Ok(b) => b,
697        Err(e) => return OnceResult::error("dilute-preview", format!("/stamps failed: {e}")),
698    };
699    let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
700        Ok(b) => b.clone(),
701        Err(e) => return OnceResult::usage("dilute-preview", e),
702    };
703    match stamp_preview::dilute_preview(&batch, new_depth) {
704        Ok(p) => OnceResult::ok_with_data(
705            "dilute-preview",
706            p.summary(),
707            json!({
708                "batch_id": batch.batch_id.to_hex(),
709                "old_depth": p.old_depth,
710                "new_depth": p.new_depth,
711                "old_ttl_seconds": p.old_ttl_seconds,
712                "new_ttl_seconds": p.new_ttl_seconds,
713            }),
714        ),
715        Err(e) => OnceResult::error("dilute-preview", e),
716    }
717}
718
719/// `--once extend-preview <batch-prefix> <duration>` — predict the
720/// per-chunk amount + cost needed to extend the batch's TTL by the
721/// requested duration.
722async fn once_extend_preview(args: &[String]) -> OnceResult {
723    let (prefix, duration_str) = match (args.first(), args.get(1)) {
724        (Some(p), Some(d)) => (p.as_str(), d.as_str()),
725        _ => {
726            return OnceResult::usage(
727                "extend-preview",
728                "usage: --once extend-preview <batch-prefix> <duration>",
729            );
730        }
731    };
732    let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
733        Ok(s) => s,
734        Err(e) => return OnceResult::usage("extend-preview", e),
735    };
736    let api = match build_api() {
737        Ok(a) => a,
738        Err(r) => return r,
739    };
740    let (batches, chain) = match fetch_stamps_and_chain(&api).await {
741        Ok(p) => p,
742        Err(e) => return OnceResult::error("extend-preview", e),
743    };
744    let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
745        Ok(b) => b.clone(),
746        Err(e) => return OnceResult::usage("extend-preview", e),
747    };
748    match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
749        Ok(p) => OnceResult::ok_with_data(
750            "extend-preview",
751            p.summary(),
752            json!({
753                "batch_id": batch.batch_id.to_hex(),
754                "depth": p.depth,
755                "current_ttl_seconds": p.current_ttl_seconds,
756                "needed_amount_plur": p.needed_amount_plur.to_string(),
757                "cost_bzz": p.cost_bzz,
758                "new_ttl_seconds": p.new_ttl_seconds,
759            }),
760        ),
761        Err(e) => OnceResult::error("extend-preview", e),
762    }
763}
764
765/// `--once price` — print xBZZ → USD spot price. No drift detection
766/// (price moves independently of operator action), so always exits
767/// 0 on success, 1 on fetch failure.
768async fn once_price() -> OnceResult {
769    match economics_oracle::fetch_xbzz_price().await {
770        Ok(p) => OnceResult::ok_with_data(
771            "price",
772            p.summary(),
773            json!({
774                "usd": p.usd,
775                "source": p.source,
776            }),
777        ),
778        Err(e) => OnceResult::error("price", e),
779    }
780}
781
782/// `--once basefee` — print Gnosis basefee + tip. Uses
783/// `[economics].gnosis_rpc_url` from config.toml. Always exits 0
784/// on success — gas fluctuates, gating CI on a threshold should
785/// happen at the workflow level.
786async fn once_basefee() -> OnceResult {
787    let url = match Config::new()
788        .ok()
789        .and_then(|c| c.economics.gnosis_rpc_url)
790    {
791        Some(u) => u,
792        None => {
793            return OnceResult::usage(
794                "basefee",
795                "set [economics].gnosis_rpc_url in config.toml",
796            );
797        }
798    };
799    match economics_oracle::fetch_gnosis_gas(&url).await {
800        Ok(g) => OnceResult::ok_with_data(
801            "basefee",
802            g.summary(),
803            json!({
804                "base_fee_gwei": g.base_fee_gwei,
805                "max_priority_fee_gwei": g.max_priority_fee_gwei,
806                "total_gwei": g.total_gwei(),
807                "source_url": g.source_url,
808            }),
809        ),
810        Err(e) => OnceResult::error("basefee", e),
811    }
812}
813
814/// `--once config-doctor [path]` — audit a bee.yaml for deprecated
815/// keys. With `[path]` argument explicit; without it, falls back to
816/// the active node profile's `[bee].config` from bee-tui's
817/// config.toml. Read-only. Exits `1` when any finding fires.
818fn once_config_doctor(args: &[String]) -> OnceResult {
819    let path: std::path::PathBuf = match args.first() {
820        Some(p) => std::path::PathBuf::from(p),
821        None => match Config::new().ok().and_then(|c| c.bee.map(|b| b.config)) {
822            Some(p) => p,
823            None => {
824                return OnceResult::usage(
825                    "config-doctor",
826                    "usage: --once config-doctor <path-to-bee.yaml>  (or set [bee].config in bee-tui's config.toml)",
827                );
828            }
829        },
830    };
831    let report = match config_doctor::audit(&path) {
832        Ok(r) => r,
833        Err(e) => return OnceResult::error("config-doctor", e),
834    };
835    let data = json!({
836        "config_path": report.config_path.display().to_string(),
837        "findings": report.findings.len(),
838        "report": report.render(),
839    });
840    if report.is_clean() {
841        OnceResult::ok_with_data("config-doctor", report.summary(), data)
842    } else {
843        OnceResult::unhealthy("config-doctor", report.summary(), data)
844    }
845}
846
847/// `--once check-version` — pair the running Bee's `/health.version`
848/// with GitHub's `releases/latest` for `ethersphere/bee`. Exits `1`
849/// when version drift is detected so a CI job can gate on
850/// "this node has fallen behind upstream".
851async fn once_check_version() -> OnceResult {
852    let api = match build_api() {
853        Ok(a) => a,
854        Err(r) => return r,
855    };
856    let running = api
857        .bee()
858        .debug()
859        .health()
860        .await
861        .ok()
862        .map(|h| h.version);
863    match version_check::check_latest(running).await {
864        Ok(v) => {
865            let data = json!({
866                "running": v.running,
867                "latest_tag": v.latest_tag,
868                "latest_published_at": v.latest_published_at,
869                "latest_html_url": v.latest_html_url,
870                "drift_detected": v.drift_detected,
871            });
872            if v.drift_detected {
873                OnceResult::unhealthy("check-version", v.summary(), data)
874            } else {
875                OnceResult::ok_with_data("check-version", v.summary(), data)
876            }
877        }
878        Err(e) => OnceResult::error("check-version", e),
879    }
880}
881
882/// `--once plan-batch <prefix> [usage-thr] [ttl-thr] [extra-depth]` —
883/// the unified topup+dilute decision. Mirrors the cockpit's
884/// `:plan-batch` verb. Exits `1` when an action is recommended (so a
885/// CI job can gate on "this batch needs human attention").
886async fn once_plan_batch(args: &[String]) -> OnceResult {
887    let prefix = match args.first() {
888        Some(p) => p.as_str(),
889        None => {
890            return OnceResult::usage(
891                "plan-batch",
892                "usage: --once plan-batch <batch-prefix> [usage-thr] [ttl-thr] [extra-depth]",
893            );
894        }
895    };
896    let usage_thr = match args.get(1) {
897        Some(s) => match s.parse::<f64>() {
898            Ok(v) => v,
899            Err(_) => {
900                return OnceResult::usage(
901                    "plan-batch",
902                    format!("invalid usage-thr {s:?} (expected float in [0,1])"),
903                );
904            }
905        },
906        None => stamp_preview::DEFAULT_USAGE_THRESHOLD,
907    };
908    let ttl_thr = match args.get(2) {
909        Some(s) => match stamp_preview::parse_duration_seconds(s) {
910            Ok(v) => v,
911            Err(e) => return OnceResult::usage("plan-batch", format!("ttl-thr: {e}")),
912        },
913        None => stamp_preview::DEFAULT_TTL_THRESHOLD_SECONDS,
914    };
915    let extra_depth = match args.get(3) {
916        Some(s) => match s.parse::<u8>() {
917            Ok(v) => v,
918            Err(_) => {
919                return OnceResult::usage(
920                    "plan-batch",
921                    format!("invalid extra-depth {s:?}"),
922                );
923            }
924        },
925        None => stamp_preview::DEFAULT_EXTRA_DEPTH,
926    };
927    let api = match build_api() {
928        Ok(a) => a,
929        Err(r) => return r,
930    };
931    let (batches, chain) = match fetch_stamps_and_chain(&api).await {
932        Ok(p) => p,
933        Err(e) => return OnceResult::error("plan-batch", e),
934    };
935    let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
936        Ok(b) => b.clone(),
937        Err(e) => return OnceResult::usage("plan-batch", e),
938    };
939    match stamp_preview::plan_batch(&batch, &chain, usage_thr, ttl_thr, extra_depth) {
940        Ok(p) => {
941            let action_kind = match &p.action {
942                stamp_preview::PlanAction::None => "none",
943                stamp_preview::PlanAction::Topup { .. } => "topup",
944                stamp_preview::PlanAction::Dilute { .. } => "dilute",
945                stamp_preview::PlanAction::TopupThenDilute { .. } => "topup_then_dilute",
946            };
947            let data = json!({
948                "batch_id": batch.batch_id.to_hex(),
949                "current_depth": p.current_depth,
950                "current_usage_pct": p.current_usage_pct,
951                "current_ttl_seconds": p.current_ttl_seconds,
952                "usage_threshold_pct": p.usage_threshold_pct,
953                "ttl_threshold_seconds": p.ttl_threshold_seconds,
954                "extra_depth": p.extra_depth,
955                "action": action_kind,
956                "total_cost_bzz": p.total_cost_bzz,
957                "reason": p.reason.clone(),
958            });
959            // Exit 1 when an action is recommended — lets CI gate on
960            // "this batch needs attention." Status `Ok` only when no
961            // action is needed.
962            if matches!(p.action, stamp_preview::PlanAction::None) {
963                OnceResult::ok_with_data("plan-batch", p.summary(), data)
964            } else {
965                OnceResult::unhealthy("plan-batch", p.summary(), data)
966            }
967        }
968        Err(e) => OnceResult::error("plan-batch", e),
969    }
970}
971
972/// Helper: one-shot parallel fetch of the postage batches list +
973/// chain state. Used by the topup/extend paths which need both.
974async fn fetch_stamps_and_chain(
975    api: &Arc<ApiClient>,
976) -> Result<
977    (
978        Vec<bee::postage::PostageBatch>,
979        bee::debug::ChainState,
980    ),
981    String,
982> {
983    let bee = api.bee();
984    let postage = bee.postage();
985    let debug = bee.debug();
986    let (batches, chain) = tokio::join!(postage.get_postage_batches(), debug.chain_state());
987    let batches = batches.map_err(|e| format!("/stamps failed: {e}"))?;
988    let chain = chain.map_err(|e| format!("/chainstate failed: {e}"))?;
989    Ok((batches, chain))
990}
991
992/// `--once durability-check <ref>` — same chunk-graph walk the
993/// cockpit's verb does, but in batch / CI mode.
994async fn once_durability_check(args: &[String]) -> OnceResult {
995    let ref_arg = match args.first() {
996        Some(r) => r.as_str(),
997        None => {
998            return OnceResult::usage(
999                "durability-check",
1000                "usage: --once durability-check <ref>",
1001            );
1002        }
1003    };
1004    let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1005        Ok(r) => r,
1006        Err(e) => return OnceResult::usage("durability-check", format!("bad ref: {e}")),
1007    };
1008    let api = match build_api() {
1009        Ok(a) => a,
1010        Err(r) => return r,
1011    };
1012    let result = durability::check(api, reference).await;
1013    let data = json!({
1014        "chunks_total": result.chunks_total,
1015        "chunks_lost": result.chunks_lost,
1016        "chunks_errors": result.chunks_errors,
1017        "duration_ms": result.duration_ms,
1018        "root_is_manifest": result.root_is_manifest,
1019        "truncated": result.truncated,
1020    });
1021    if result.is_healthy() {
1022        OnceResult::ok_with_data("durability-check", result.summary(), data)
1023    } else {
1024        OnceResult::unhealthy("durability-check", result.summary(), data)
1025    }
1026}
1027
1028// ---- Output ----------------------------------------------------------
1029
1030fn print_result(result: &OnceResult, json_output: bool) {
1031    if json_output {
1032        match serde_json::to_string(result) {
1033            Ok(s) => println!("{s}"),
1034            Err(e) => eprintln!("(failed to serialize result: {e})"),
1035        }
1036        return;
1037    }
1038    let prefix = match result.status {
1039        OnceStatus::Ok => "OK",
1040        OnceStatus::Unhealthy => "UNHEALTHY",
1041        OnceStatus::Error => "ERROR",
1042        OnceStatus::UsageError => "USAGE",
1043    };
1044    println!("[{prefix}] {}", result.message);
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050
1051    fn args(s: &[&str]) -> Vec<String> {
1052        s.iter().map(|x| x.to_string()).collect()
1053    }
1054
1055    #[test]
1056    fn unknown_verb_returns_usage_error() {
1057        let r = once_pss_target(&[]);
1058        assert!(matches!(r.status, OnceStatus::UsageError));
1059        assert!(r.message.contains("usage"), "{}", r.message);
1060    }
1061
1062    #[test]
1063    fn cid_handler_round_trips() {
1064        let r = once_cid(&args(&[&"0".repeat(64), "feed"]));
1065        assert!(matches!(r.status, OnceStatus::Ok));
1066        assert!(r.message.contains("cid:"), "{}", r.message);
1067        // JSON data contains the CID.
1068        assert!(r.data["cid"].is_string());
1069    }
1070
1071    #[test]
1072    fn cid_handler_rejects_garbage() {
1073        let r = once_cid(&args(&["not-hex"]));
1074        assert!(matches!(r.status, OnceStatus::Error));
1075    }
1076
1077    #[test]
1078    fn cid_handler_no_args_is_usage_error() {
1079        let r = once_cid(&[]);
1080        assert!(matches!(r.status, OnceStatus::UsageError));
1081    }
1082
1083    #[test]
1084    fn pss_target_extracts_prefix() {
1085        let r = once_pss_target(&args(&[
1086            "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
1087        ]));
1088        assert!(matches!(r.status, OnceStatus::Ok));
1089        assert!(r.message.contains("abcd"), "{}", r.message);
1090    }
1091
1092    #[test]
1093    fn depth_table_renders_full_table() {
1094        let r = once_depth_table();
1095        assert!(matches!(r.status, OnceStatus::Ok));
1096        assert!(r.message.contains("depth"));
1097        assert!(r.message.contains("17"));
1098        assert!(r.message.contains("34"));
1099    }
1100
1101    #[test]
1102    fn exit_codes_map_correctly() {
1103        assert_eq!(
1104            OnceStatus::Ok.exit_code(),
1105            std::process::ExitCode::SUCCESS
1106        );
1107        // UsageError vs Error vs Unhealthy all distinguishable. We
1108        // can't equality-test ExitCode::from(N) directly, but we can
1109        // exercise that the path doesn't panic.
1110        let _ = OnceStatus::Unhealthy.exit_code();
1111        let _ = OnceStatus::Error.exit_code();
1112        let _ = OnceStatus::UsageError.exit_code();
1113    }
1114
1115    #[test]
1116    fn ok_helpers_compose_the_expected_shape() {
1117        let r = OnceResult::ok("v", "all good");
1118        assert_eq!(r.verb, "v");
1119        assert!(matches!(r.status, OnceStatus::Ok));
1120        assert_eq!(r.message, "all good");
1121        assert!(r.data.is_null());
1122
1123        let r2 = OnceResult::unhealthy("v", "broken", json!({"x": 1}));
1124        assert!(matches!(r2.status, OnceStatus::Unhealthy));
1125        assert_eq!(r2.data["x"], 1);
1126    }
1127
1128    #[test]
1129    fn print_result_json_output_is_one_line() {
1130        // Smoke test the JSON path doesn't panic. We don't capture
1131        // stdout here — that's an integration concern.
1132        let r = OnceResult::ok("hash", "hash X: abc");
1133        print_result(&r, true);
1134        print_result(&r, false);
1135    }
1136
1137    #[test]
1138    fn upload_content_type_known_extensions() {
1139        let p = std::path::PathBuf::from;
1140        assert_eq!(upload_content_type(&p("/tmp/x.html")), "text/html");
1141        assert_eq!(upload_content_type(&p("/tmp/x.PNG")), "image/png");
1142        assert_eq!(upload_content_type(&p("/tmp/x.tar.gz")), "application/gzip");
1143        // Unknown extension falls back to empty (bee-rs uses application/octet-stream).
1144        assert_eq!(upload_content_type(&p("/tmp/x.unknownext")), "");
1145    }
1146}