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