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::manifest_walker::{self, InspectResult};
40use crate::stamp_preview;
41use crate::utility_verbs;
42use crate::version_check;
43
44#[derive(Debug, Serialize)]
47pub struct OnceResult {
48 pub verb: String,
49 pub status: OnceStatus,
50 pub message: String,
51 #[serde(skip_serializing_if = "Value::is_null")]
52 pub data: Value,
53}
54
55#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum OnceStatus {
58 Ok,
59 Unhealthy,
60 Error,
61 UsageError,
62}
63
64impl OnceStatus {
65 pub fn exit_code(self) -> ExitCode {
66 match self {
67 Self::Ok => ExitCode::SUCCESS,
68 Self::Unhealthy | Self::Error => ExitCode::from(1),
69 Self::UsageError => ExitCode::from(2),
70 }
71 }
72}
73
74impl OnceResult {
75 pub fn ok(verb: &str, message: impl Into<String>) -> Self {
76 Self {
77 verb: verb.into(),
78 status: OnceStatus::Ok,
79 message: message.into(),
80 data: Value::Null,
81 }
82 }
83 pub fn ok_with_data(verb: &str, message: impl Into<String>, data: Value) -> Self {
84 Self {
85 verb: verb.into(),
86 status: OnceStatus::Ok,
87 message: message.into(),
88 data,
89 }
90 }
91 pub fn unhealthy(verb: &str, message: impl Into<String>, data: Value) -> Self {
92 Self {
93 verb: verb.into(),
94 status: OnceStatus::Unhealthy,
95 message: message.into(),
96 data,
97 }
98 }
99 pub fn error(verb: &str, message: impl Into<String>) -> Self {
100 Self {
101 verb: verb.into(),
102 status: OnceStatus::Error,
103 message: message.into(),
104 data: Value::Null,
105 }
106 }
107 pub fn usage(verb: &str, message: impl Into<String>) -> Self {
108 Self {
109 verb: verb.into(),
110 status: OnceStatus::UsageError,
111 message: message.into(),
112 data: Value::Null,
113 }
114 }
115}
116
117pub async fn run(verb: &str, args: &[String], json_output: bool) -> ExitCode {
121 let result = dispatch(verb, args).await;
122 print_result(&result, json_output);
123 result.status.exit_code()
124}
125
126async fn dispatch(verb: &str, args: &[String]) -> OnceResult {
127 match verb {
128 "hash" => once_hash(args),
130 "cid" => once_cid(args),
131 "depth-table" => once_depth_table(),
132 "pss-target" => once_pss_target(args),
133 "gsoc-mine" => once_gsoc_mine(args),
134
135 "readiness" => once_readiness().await,
137 "version-check" => once_version_check().await,
138 "inspect" => once_inspect(args).await,
139 "durability-check" => once_durability_check(args).await,
140 "upload-file" => once_upload_file(args).await,
141
142 "buy-preview" => once_buy_preview(args).await,
145 "buy-suggest" => once_buy_suggest(args).await,
146 "topup-preview" => once_topup_preview(args).await,
147 "dilute-preview" => once_dilute_preview(args).await,
148 "extend-preview" => once_extend_preview(args).await,
149 "plan-batch" => once_plan_batch(args).await,
150 "check-version" => once_check_version().await,
151 "config-doctor" => once_config_doctor(args),
152 "price" => once_price().await,
153 "basefee" => once_basefee().await,
154
155 other => OnceResult::usage(
157 other,
158 format!(
159 "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, buy-preview, buy-suggest, topup-preview, dilute-preview, extend-preview, plan-batch"
160 ),
161 ),
162 }
163}
164
165fn once_hash(args: &[String]) -> OnceResult {
168 let path = match args.first() {
169 Some(p) => p.as_str(),
170 None => {
171 return OnceResult::usage("hash", "usage: --once hash <path>");
172 }
173 };
174 match utility_verbs::hash_path(path) {
175 Ok(r) => OnceResult::ok_with_data(
176 "hash",
177 format!("hash {path}: {r}"),
178 json!({ "path": path, "reference": r }),
179 ),
180 Err(e) => OnceResult::error("hash", format!("hash failed: {e}")),
181 }
182}
183
184fn once_cid(args: &[String]) -> OnceResult {
185 let ref_arg = match args.first() {
186 Some(r) => r.as_str(),
187 None => return OnceResult::usage("cid", "usage: --once cid <ref> [manifest|feed]"),
188 };
189 let kind_arg = args.get(1).map(String::as_str);
190 let kind = match utility_verbs::parse_cid_kind(kind_arg) {
191 Ok(k) => k,
192 Err(e) => return OnceResult::usage("cid", e),
193 };
194 match utility_verbs::cid_for_ref(ref_arg, kind) {
195 Ok(cid) => {
196 OnceResult::ok_with_data("cid", format!("cid: {cid}"), json!({ "cid": cid }))
197 }
198 Err(e) => OnceResult::error("cid", format!("cid failed: {e}")),
199 }
200}
201
202fn once_depth_table() -> OnceResult {
203 OnceResult::ok_with_data(
204 "depth-table",
205 utility_verbs::depth_table(),
206 json!({ "table": utility_verbs::depth_table() }),
207 )
208}
209
210fn once_pss_target(args: &[String]) -> OnceResult {
211 let overlay = match args.first() {
212 Some(o) => o.as_str(),
213 None => return OnceResult::usage("pss-target", "usage: --once pss-target <overlay>"),
214 };
215 match utility_verbs::pss_target_for(overlay) {
216 Ok(prefix) => OnceResult::ok_with_data(
217 "pss-target",
218 format!("pss target prefix: {prefix}"),
219 json!({ "prefix": prefix }),
220 ),
221 Err(e) => OnceResult::error("pss-target", format!("pss-target failed: {e}")),
222 }
223}
224
225fn once_gsoc_mine(args: &[String]) -> OnceResult {
226 let overlay = args.first().map(String::as_str);
227 let ident = args.get(1).map(String::as_str);
228 let (overlay, ident) = match (overlay, ident) {
229 (Some(o), Some(i)) => (o, i),
230 _ => {
231 return OnceResult::usage(
232 "gsoc-mine",
233 "usage: --once gsoc-mine <overlay> <identifier>",
234 );
235 }
236 };
237 match utility_verbs::gsoc_mine_for(overlay, ident) {
238 Ok(out) => OnceResult::ok_with_data(
239 "gsoc-mine",
240 out.replace('\n', " · "),
241 json!({ "result": out }),
242 ),
243 Err(e) => OnceResult::error("gsoc-mine", format!("gsoc-mine failed: {e}")),
244 }
245}
246
247fn build_api() -> Result<Arc<ApiClient>, OnceResult> {
253 let config = match Config::new() {
254 Ok(c) => c,
255 Err(e) => {
256 return Err(OnceResult::usage(
257 "_config",
258 format!("could not load config: {e}"),
259 ));
260 }
261 };
262 let node = match config.active_node() {
263 Some(n) => n,
264 None => {
265 return Err(OnceResult::usage(
266 "_config",
267 "no Bee node configured (config.nodes is empty)",
268 ));
269 }
270 };
271 let api = match ApiClient::from_node(node) {
272 Ok(a) => Arc::new(a),
273 Err(e) => {
274 return Err(OnceResult::usage(
275 "_config",
276 format!("could not build api client: {e}"),
277 ));
278 }
279 };
280 Ok(api)
281}
282
283async fn once_readiness() -> OnceResult {
287 let api = match build_api() {
288 Ok(a) => a,
289 Err(r) => return r,
290 };
291 let bee = api.bee();
292 let debug = bee.debug();
293 let (health, topology) = tokio::join!(debug.health(), debug.topology());
294 let health = match health {
295 Ok(h) => h,
296 Err(e) => {
297 return OnceResult::error("readiness", format!("/health failed: {e}"));
298 }
299 };
300 let topology = match topology {
301 Ok(t) => t,
302 Err(e) => {
303 return OnceResult::error("readiness", format!("/topology failed: {e}"));
304 }
305 };
306 let depth = topology.depth as u32;
307 let depth_ok = (1..=30).contains(&depth);
308 let status_ok = health.status == "ok";
309 let data = json!({
310 "health_status": health.status,
311 "version": health.version,
312 "api_version": health.api_version,
313 "depth": depth,
314 "depth_ok": depth_ok,
315 "status_ok": status_ok,
316 });
317 if status_ok && depth_ok {
318 OnceResult::ok_with_data(
319 "readiness",
320 format!(
321 "READY · status={} · depth={depth} · version={}",
322 health.status, health.version
323 ),
324 data,
325 )
326 } else {
327 OnceResult::unhealthy(
328 "readiness",
329 format!(
330 "NOT READY · status={} · depth={depth} (need [1,30]) · version={}",
331 health.status, health.version
332 ),
333 data,
334 )
335 }
336}
337
338async fn once_version_check() -> OnceResult {
341 let api = match build_api() {
342 Ok(a) => a,
343 Err(r) => return r,
344 };
345 match api.bee().debug().health().await {
346 Ok(h) => OnceResult::ok_with_data(
347 "version-check",
348 format!("bee {} · api {}", h.version, h.api_version),
349 json!({
350 "version": h.version,
351 "api_version": h.api_version,
352 }),
353 ),
354 Err(e) => OnceResult::error("version-check", format!("/health failed: {e}")),
355 }
356}
357
358async fn once_inspect(args: &[String]) -> OnceResult {
361 let ref_arg = match args.first() {
362 Some(r) => r.as_str(),
363 None => return OnceResult::usage("inspect", "usage: --once inspect <ref>"),
364 };
365 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
366 Ok(r) => r,
367 Err(e) => return OnceResult::usage("inspect", format!("bad ref: {e}")),
368 };
369 let api = match build_api() {
370 Ok(a) => a,
371 Err(r) => return r,
372 };
373 match manifest_walker::inspect(api, reference).await {
374 InspectResult::Manifest { node, bytes_len } => OnceResult::ok_with_data(
375 "inspect",
376 format!(
377 "manifest · {bytes_len} bytes · {} forks",
378 node.forks.len()
379 ),
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_buy_preview(args: &[String]) -> OnceResult {
532 let (depth_str, amount_str) = match (args.first(), args.get(1)) {
533 (Some(d), Some(a)) => (d.as_str(), a.as_str()),
534 _ => {
535 return OnceResult::usage(
536 "buy-preview",
537 "usage: --once buy-preview <depth> <amount-plur>",
538 );
539 }
540 };
541 let depth: u8 = match depth_str.parse() {
542 Ok(d) => d,
543 Err(_) => return OnceResult::usage("buy-preview", format!("invalid depth: {depth_str}")),
544 };
545 let amount = match stamp_preview::parse_plur_amount(amount_str) {
546 Ok(a) => a,
547 Err(e) => return OnceResult::usage("buy-preview", e),
548 };
549 let api = match build_api() {
550 Ok(a) => a,
551 Err(r) => return r,
552 };
553 let chain = match api.bee().debug().chain_state().await {
554 Ok(c) => c,
555 Err(e) => {
556 return OnceResult::error("buy-preview", format!("/chainstate failed: {e}"));
557 }
558 };
559 match stamp_preview::buy_preview(depth, amount, &chain) {
560 Ok(p) => OnceResult::ok_with_data(
561 "buy-preview",
562 p.summary(),
563 json!({
564 "depth": p.depth,
565 "amount_plur": p.amount_plur.to_string(),
566 "ttl_seconds": p.ttl_seconds,
567 "cost_bzz": p.cost_bzz,
568 }),
569 ),
570 Err(e) => OnceResult::error("buy-preview", e),
571 }
572}
573
574async fn once_buy_suggest(args: &[String]) -> OnceResult {
578 let (size_str, duration_str) = match (args.first(), args.get(1)) {
579 (Some(s), Some(d)) => (s.as_str(), d.as_str()),
580 _ => {
581 return OnceResult::usage(
582 "buy-suggest",
583 "usage: --once buy-suggest <size> <duration> (e.g. 5GiB 30d)",
584 );
585 }
586 };
587 let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
588 Ok(b) => b,
589 Err(e) => return OnceResult::usage("buy-suggest", e),
590 };
591 let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
592 Ok(s) => s,
593 Err(e) => return OnceResult::usage("buy-suggest", e),
594 };
595 let api = match build_api() {
596 Ok(a) => a,
597 Err(r) => return r,
598 };
599 let chain = match api.bee().debug().chain_state().await {
600 Ok(c) => c,
601 Err(e) => {
602 return OnceResult::error("buy-suggest", format!("/chainstate failed: {e}"));
603 }
604 };
605 match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
606 Ok(p) => OnceResult::ok_with_data(
607 "buy-suggest",
608 p.summary(),
609 json!({
610 "target_bytes": p.target_bytes.to_string(),
611 "target_seconds": p.target_seconds,
612 "depth": p.depth,
613 "amount_plur": p.amount_plur.to_string(),
614 "capacity_bytes": p.capacity_bytes.to_string(),
615 "ttl_seconds": p.ttl_seconds,
616 "cost_bzz": p.cost_bzz,
617 }),
618 ),
619 Err(e) => OnceResult::error("buy-suggest", e),
620 }
621}
622
623async fn once_topup_preview(args: &[String]) -> OnceResult {
626 let (prefix, amount_str) = match (args.first(), args.get(1)) {
627 (Some(p), Some(a)) => (p.as_str(), a.as_str()),
628 _ => {
629 return OnceResult::usage(
630 "topup-preview",
631 "usage: --once topup-preview <batch-prefix> <amount-plur>",
632 );
633 }
634 };
635 let amount = match stamp_preview::parse_plur_amount(amount_str) {
636 Ok(a) => a,
637 Err(e) => return OnceResult::usage("topup-preview", e),
638 };
639 let api = match build_api() {
640 Ok(a) => a,
641 Err(r) => return r,
642 };
643 let (batches, chain) = match fetch_stamps_and_chain(&api).await {
644 Ok(p) => p,
645 Err(e) => return OnceResult::error("topup-preview", e),
646 };
647 let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
648 Ok(b) => b.clone(),
649 Err(e) => return OnceResult::usage("topup-preview", e),
650 };
651 match stamp_preview::topup_preview(&batch, amount, &chain) {
652 Ok(p) => OnceResult::ok_with_data(
653 "topup-preview",
654 p.summary(),
655 json!({
656 "batch_id": batch.batch_id.to_hex(),
657 "current_depth": p.current_depth,
658 "current_ttl_seconds": p.current_ttl_seconds,
659 "delta_amount_plur": p.delta_amount.to_string(),
660 "extra_ttl_seconds": p.extra_ttl_seconds,
661 "new_ttl_seconds": p.new_ttl_seconds,
662 "cost_bzz": p.cost_bzz,
663 }),
664 ),
665 Err(e) => OnceResult::error("topup-preview", e),
666 }
667}
668
669async fn once_dilute_preview(args: &[String]) -> OnceResult {
673 let (prefix, depth_str) = match (args.first(), args.get(1)) {
674 (Some(p), Some(d)) => (p.as_str(), d.as_str()),
675 _ => {
676 return OnceResult::usage(
677 "dilute-preview",
678 "usage: --once dilute-preview <batch-prefix> <new-depth>",
679 );
680 }
681 };
682 let new_depth: u8 = match depth_str.parse() {
683 Ok(d) => d,
684 Err(_) => {
685 return OnceResult::usage(
686 "dilute-preview",
687 format!("invalid depth: {depth_str}"),
688 );
689 }
690 };
691 let api = match build_api() {
692 Ok(a) => a,
693 Err(r) => return r,
694 };
695 let batches = match api.bee().postage().get_postage_batches().await {
696 Ok(b) => b,
697 Err(e) => return OnceResult::error("dilute-preview", format!("/stamps failed: {e}")),
698 };
699 let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
700 Ok(b) => b.clone(),
701 Err(e) => return OnceResult::usage("dilute-preview", e),
702 };
703 match stamp_preview::dilute_preview(&batch, new_depth) {
704 Ok(p) => OnceResult::ok_with_data(
705 "dilute-preview",
706 p.summary(),
707 json!({
708 "batch_id": batch.batch_id.to_hex(),
709 "old_depth": p.old_depth,
710 "new_depth": p.new_depth,
711 "old_ttl_seconds": p.old_ttl_seconds,
712 "new_ttl_seconds": p.new_ttl_seconds,
713 }),
714 ),
715 Err(e) => OnceResult::error("dilute-preview", e),
716 }
717}
718
719async fn once_extend_preview(args: &[String]) -> OnceResult {
723 let (prefix, duration_str) = match (args.first(), args.get(1)) {
724 (Some(p), Some(d)) => (p.as_str(), d.as_str()),
725 _ => {
726 return OnceResult::usage(
727 "extend-preview",
728 "usage: --once extend-preview <batch-prefix> <duration>",
729 );
730 }
731 };
732 let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
733 Ok(s) => s,
734 Err(e) => return OnceResult::usage("extend-preview", e),
735 };
736 let api = match build_api() {
737 Ok(a) => a,
738 Err(r) => return r,
739 };
740 let (batches, chain) = match fetch_stamps_and_chain(&api).await {
741 Ok(p) => p,
742 Err(e) => return OnceResult::error("extend-preview", e),
743 };
744 let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
745 Ok(b) => b.clone(),
746 Err(e) => return OnceResult::usage("extend-preview", e),
747 };
748 match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
749 Ok(p) => OnceResult::ok_with_data(
750 "extend-preview",
751 p.summary(),
752 json!({
753 "batch_id": batch.batch_id.to_hex(),
754 "depth": p.depth,
755 "current_ttl_seconds": p.current_ttl_seconds,
756 "needed_amount_plur": p.needed_amount_plur.to_string(),
757 "cost_bzz": p.cost_bzz,
758 "new_ttl_seconds": p.new_ttl_seconds,
759 }),
760 ),
761 Err(e) => OnceResult::error("extend-preview", e),
762 }
763}
764
765async fn once_price() -> OnceResult {
769 match economics_oracle::fetch_xbzz_price().await {
770 Ok(p) => OnceResult::ok_with_data(
771 "price",
772 p.summary(),
773 json!({
774 "usd": p.usd,
775 "source": p.source,
776 }),
777 ),
778 Err(e) => OnceResult::error("price", e),
779 }
780}
781
782async fn once_basefee() -> OnceResult {
787 let url = match Config::new()
788 .ok()
789 .and_then(|c| c.economics.gnosis_rpc_url)
790 {
791 Some(u) => u,
792 None => {
793 return OnceResult::usage(
794 "basefee",
795 "set [economics].gnosis_rpc_url in config.toml",
796 );
797 }
798 };
799 match economics_oracle::fetch_gnosis_gas(&url).await {
800 Ok(g) => OnceResult::ok_with_data(
801 "basefee",
802 g.summary(),
803 json!({
804 "base_fee_gwei": g.base_fee_gwei,
805 "max_priority_fee_gwei": g.max_priority_fee_gwei,
806 "total_gwei": g.total_gwei(),
807 "source_url": g.source_url,
808 }),
809 ),
810 Err(e) => OnceResult::error("basefee", e),
811 }
812}
813
814fn once_config_doctor(args: &[String]) -> OnceResult {
819 let path: std::path::PathBuf = match args.first() {
820 Some(p) => std::path::PathBuf::from(p),
821 None => match Config::new().ok().and_then(|c| c.bee.map(|b| b.config)) {
822 Some(p) => p,
823 None => {
824 return OnceResult::usage(
825 "config-doctor",
826 "usage: --once config-doctor <path-to-bee.yaml> (or set [bee].config in bee-tui's config.toml)",
827 );
828 }
829 },
830 };
831 let report = match config_doctor::audit(&path) {
832 Ok(r) => r,
833 Err(e) => return OnceResult::error("config-doctor", e),
834 };
835 let data = json!({
836 "config_path": report.config_path.display().to_string(),
837 "findings": report.findings.len(),
838 "report": report.render(),
839 });
840 if report.is_clean() {
841 OnceResult::ok_with_data("config-doctor", report.summary(), data)
842 } else {
843 OnceResult::unhealthy("config-doctor", report.summary(), data)
844 }
845}
846
847async fn once_check_version() -> OnceResult {
852 let api = match build_api() {
853 Ok(a) => a,
854 Err(r) => return r,
855 };
856 let running = api
857 .bee()
858 .debug()
859 .health()
860 .await
861 .ok()
862 .map(|h| h.version);
863 match version_check::check_latest(running).await {
864 Ok(v) => {
865 let data = json!({
866 "running": v.running,
867 "latest_tag": v.latest_tag,
868 "latest_published_at": v.latest_published_at,
869 "latest_html_url": v.latest_html_url,
870 "drift_detected": v.drift_detected,
871 });
872 if v.drift_detected {
873 OnceResult::unhealthy("check-version", v.summary(), data)
874 } else {
875 OnceResult::ok_with_data("check-version", v.summary(), data)
876 }
877 }
878 Err(e) => OnceResult::error("check-version", e),
879 }
880}
881
882async fn once_plan_batch(args: &[String]) -> OnceResult {
887 let prefix = match args.first() {
888 Some(p) => p.as_str(),
889 None => {
890 return OnceResult::usage(
891 "plan-batch",
892 "usage: --once plan-batch <batch-prefix> [usage-thr] [ttl-thr] [extra-depth]",
893 );
894 }
895 };
896 let usage_thr = match args.get(1) {
897 Some(s) => match s.parse::<f64>() {
898 Ok(v) => v,
899 Err(_) => {
900 return OnceResult::usage(
901 "plan-batch",
902 format!("invalid usage-thr {s:?} (expected float in [0,1])"),
903 );
904 }
905 },
906 None => stamp_preview::DEFAULT_USAGE_THRESHOLD,
907 };
908 let ttl_thr = match args.get(2) {
909 Some(s) => match stamp_preview::parse_duration_seconds(s) {
910 Ok(v) => v,
911 Err(e) => return OnceResult::usage("plan-batch", format!("ttl-thr: {e}")),
912 },
913 None => stamp_preview::DEFAULT_TTL_THRESHOLD_SECONDS,
914 };
915 let extra_depth = match args.get(3) {
916 Some(s) => match s.parse::<u8>() {
917 Ok(v) => v,
918 Err(_) => {
919 return OnceResult::usage(
920 "plan-batch",
921 format!("invalid extra-depth {s:?}"),
922 );
923 }
924 },
925 None => stamp_preview::DEFAULT_EXTRA_DEPTH,
926 };
927 let api = match build_api() {
928 Ok(a) => a,
929 Err(r) => return r,
930 };
931 let (batches, chain) = match fetch_stamps_and_chain(&api).await {
932 Ok(p) => p,
933 Err(e) => return OnceResult::error("plan-batch", e),
934 };
935 let batch = match stamp_preview::match_batch_prefix(&batches, prefix) {
936 Ok(b) => b.clone(),
937 Err(e) => return OnceResult::usage("plan-batch", e),
938 };
939 match stamp_preview::plan_batch(&batch, &chain, usage_thr, ttl_thr, extra_depth) {
940 Ok(p) => {
941 let action_kind = match &p.action {
942 stamp_preview::PlanAction::None => "none",
943 stamp_preview::PlanAction::Topup { .. } => "topup",
944 stamp_preview::PlanAction::Dilute { .. } => "dilute",
945 stamp_preview::PlanAction::TopupThenDilute { .. } => "topup_then_dilute",
946 };
947 let data = json!({
948 "batch_id": batch.batch_id.to_hex(),
949 "current_depth": p.current_depth,
950 "current_usage_pct": p.current_usage_pct,
951 "current_ttl_seconds": p.current_ttl_seconds,
952 "usage_threshold_pct": p.usage_threshold_pct,
953 "ttl_threshold_seconds": p.ttl_threshold_seconds,
954 "extra_depth": p.extra_depth,
955 "action": action_kind,
956 "total_cost_bzz": p.total_cost_bzz,
957 "reason": p.reason.clone(),
958 });
959 if matches!(p.action, stamp_preview::PlanAction::None) {
963 OnceResult::ok_with_data("plan-batch", p.summary(), data)
964 } else {
965 OnceResult::unhealthy("plan-batch", p.summary(), data)
966 }
967 }
968 Err(e) => OnceResult::error("plan-batch", e),
969 }
970}
971
972async fn fetch_stamps_and_chain(
975 api: &Arc<ApiClient>,
976) -> Result<
977 (
978 Vec<bee::postage::PostageBatch>,
979 bee::debug::ChainState,
980 ),
981 String,
982> {
983 let bee = api.bee();
984 let postage = bee.postage();
985 let debug = bee.debug();
986 let (batches, chain) = tokio::join!(postage.get_postage_batches(), debug.chain_state());
987 let batches = batches.map_err(|e| format!("/stamps failed: {e}"))?;
988 let chain = chain.map_err(|e| format!("/chainstate failed: {e}"))?;
989 Ok((batches, chain))
990}
991
992async fn once_durability_check(args: &[String]) -> OnceResult {
995 let ref_arg = match args.first() {
996 Some(r) => r.as_str(),
997 None => {
998 return OnceResult::usage(
999 "durability-check",
1000 "usage: --once durability-check <ref>",
1001 );
1002 }
1003 };
1004 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1005 Ok(r) => r,
1006 Err(e) => return OnceResult::usage("durability-check", format!("bad ref: {e}")),
1007 };
1008 let api = match build_api() {
1009 Ok(a) => a,
1010 Err(r) => return r,
1011 };
1012 let result = durability::check(api, reference).await;
1013 let data = json!({
1014 "chunks_total": result.chunks_total,
1015 "chunks_lost": result.chunks_lost,
1016 "chunks_errors": result.chunks_errors,
1017 "duration_ms": result.duration_ms,
1018 "root_is_manifest": result.root_is_manifest,
1019 "truncated": result.truncated,
1020 });
1021 if result.is_healthy() {
1022 OnceResult::ok_with_data("durability-check", result.summary(), data)
1023 } else {
1024 OnceResult::unhealthy("durability-check", result.summary(), data)
1025 }
1026}
1027
1028fn print_result(result: &OnceResult, json_output: bool) {
1031 if json_output {
1032 match serde_json::to_string(result) {
1033 Ok(s) => println!("{s}"),
1034 Err(e) => eprintln!("(failed to serialize result: {e})"),
1035 }
1036 return;
1037 }
1038 let prefix = match result.status {
1039 OnceStatus::Ok => "OK",
1040 OnceStatus::Unhealthy => "UNHEALTHY",
1041 OnceStatus::Error => "ERROR",
1042 OnceStatus::UsageError => "USAGE",
1043 };
1044 println!("[{prefix}] {}", result.message);
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049 use super::*;
1050
1051 fn args(s: &[&str]) -> Vec<String> {
1052 s.iter().map(|x| x.to_string()).collect()
1053 }
1054
1055 #[test]
1056 fn unknown_verb_returns_usage_error() {
1057 let r = once_pss_target(&[]);
1058 assert!(matches!(r.status, OnceStatus::UsageError));
1059 assert!(r.message.contains("usage"), "{}", r.message);
1060 }
1061
1062 #[test]
1063 fn cid_handler_round_trips() {
1064 let r = once_cid(&args(&[&"0".repeat(64), "feed"]));
1065 assert!(matches!(r.status, OnceStatus::Ok));
1066 assert!(r.message.contains("cid:"), "{}", r.message);
1067 assert!(r.data["cid"].is_string());
1069 }
1070
1071 #[test]
1072 fn cid_handler_rejects_garbage() {
1073 let r = once_cid(&args(&["not-hex"]));
1074 assert!(matches!(r.status, OnceStatus::Error));
1075 }
1076
1077 #[test]
1078 fn cid_handler_no_args_is_usage_error() {
1079 let r = once_cid(&[]);
1080 assert!(matches!(r.status, OnceStatus::UsageError));
1081 }
1082
1083 #[test]
1084 fn pss_target_extracts_prefix() {
1085 let r = once_pss_target(&args(&[
1086 "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
1087 ]));
1088 assert!(matches!(r.status, OnceStatus::Ok));
1089 assert!(r.message.contains("abcd"), "{}", r.message);
1090 }
1091
1092 #[test]
1093 fn depth_table_renders_full_table() {
1094 let r = once_depth_table();
1095 assert!(matches!(r.status, OnceStatus::Ok));
1096 assert!(r.message.contains("depth"));
1097 assert!(r.message.contains("17"));
1098 assert!(r.message.contains("34"));
1099 }
1100
1101 #[test]
1102 fn exit_codes_map_correctly() {
1103 assert_eq!(
1104 OnceStatus::Ok.exit_code(),
1105 std::process::ExitCode::SUCCESS
1106 );
1107 let _ = OnceStatus::Unhealthy.exit_code();
1111 let _ = OnceStatus::Error.exit_code();
1112 let _ = OnceStatus::UsageError.exit_code();
1113 }
1114
1115 #[test]
1116 fn ok_helpers_compose_the_expected_shape() {
1117 let r = OnceResult::ok("v", "all good");
1118 assert_eq!(r.verb, "v");
1119 assert!(matches!(r.status, OnceStatus::Ok));
1120 assert_eq!(r.message, "all good");
1121 assert!(r.data.is_null());
1122
1123 let r2 = OnceResult::unhealthy("v", "broken", json!({"x": 1}));
1124 assert!(matches!(r2.status, OnceStatus::Unhealthy));
1125 assert_eq!(r2.data["x"], 1);
1126 }
1127
1128 #[test]
1129 fn print_result_json_output_is_one_line() {
1130 let r = OnceResult::ok("hash", "hash X: abc");
1133 print_result(&r, true);
1134 print_result(&r, false);
1135 }
1136
1137 #[test]
1138 fn upload_content_type_known_extensions() {
1139 let p = std::path::PathBuf::from;
1140 assert_eq!(upload_content_type(&p("/tmp/x.html")), "text/html");
1141 assert_eq!(upload_content_type(&p("/tmp/x.PNG")), "image/png");
1142 assert_eq!(upload_content_type(&p("/tmp/x.tar.gz")), "application/gzip");
1143 assert_eq!(upload_content_type(&p("/tmp/x.unknownext")), "");
1145 }
1146}