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