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