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