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