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::feed_timeline;
41use crate::manifest_walker::{self, InspectResult};
42use crate::stamp_preview;
43use crate::utility_verbs;
44use crate::version_check;
45
46#[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
119pub 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 "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 "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 "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 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
170fn 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
250fn 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
286async 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
341async 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
361async 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
398async 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
498fn 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
527async 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
615async 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
652async 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
713async 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
760async 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
809async 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
855async 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
902async 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
948async 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
965async 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
991fn 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
1024async 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
1053async 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 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
1140async 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
1154async 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
1190fn 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 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 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 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 assert_eq!(upload_content_type(&p("/tmp/x.unknownext")), "");
1304 }
1305}