1use crate::assets::{self, AssetsLayout, SetupOptions};
2use crate::process::{self, ProcessHandle, RunningProcess, StartPlan};
3use crate::state::{ProcessKind, ProcessRecord, ProcessStatus, State};
4use anyhow::{Result, anyhow};
5use backoff::ExponentialBackoff;
6use backoff::backoff::Backoff;
7use casper_types::U512;
8use casper_types::contract_messages::MessagePayload;
9use casper_types::execution::ExecutionResult;
10use clap::{Args, Parser, Subcommand};
11use directories::BaseDirs;
12use futures::StreamExt;
13use spinners::{Spinner, Spinners};
14use std::collections::{HashMap, HashSet};
15use std::os::unix::process::ExitStatusExt;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::Ordering;
19use tokio::sync::Mutex;
20use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
21use veles_casper_rust_sdk::sse::event::SseEvent;
22use veles_casper_rust_sdk::sse::{self, config::ListenerConfig};
23
24#[derive(Parser)]
26#[command(name = "nctl")]
27#[command(
28 about = "casper-devnet launcher for local Casper Network development networks",
29 long_about = None
30)]
31pub struct Cli {
32 #[command(subcommand)]
33 command: Command,
34}
35
36#[derive(Subcommand)]
38enum Command {
39 Start(StartArgs),
41 Assets(AssetsArgs),
43}
44
45#[derive(Args, Clone)]
47struct StartArgs {
48 #[arg(long, default_value = "casper-dev")]
50 network_name: String,
51
52 #[arg(long, value_name = "PATH")]
54 net_path: Option<PathBuf>,
55
56 #[arg(long)]
58 protocol_version: Option<String>,
59
60 #[arg(long = "node-count", aliases = ["nodes", "validators"], default_value_t = 4)]
62 node_count: u32,
63
64 #[arg(long)]
66 users: Option<u32>,
67
68 #[arg(long, default_value_t = 3)]
70 delay: u64,
71
72 #[arg(long = "log-level", default_value = "info")]
74 log_level: String,
75
76 #[arg(long, default_value = "json")]
78 node_log_format: String,
79
80 #[arg(long)]
82 setup_only: bool,
83
84 #[arg(long)]
86 force_setup: bool,
87
88 #[arg(long, default_value = "default")]
90 seed: Arc<str>,
91}
92
93#[derive(Args)]
95struct AssetsArgs {
96 #[command(subcommand)]
97 command: AssetsCommand,
98}
99
100#[derive(Subcommand)]
102enum AssetsCommand {
103 Add(AssetsAddArgs),
105 Pull(AssetsPullArgs),
107 List,
109}
110
111#[derive(Args, Clone)]
113struct AssetsAddArgs {
114 #[arg(value_name = "PATH")]
116 path: PathBuf,
117}
118
119#[derive(Args, Clone)]
121struct AssetsPullArgs {
122 #[arg(long)]
124 target: Option<String>,
125
126 #[arg(long)]
128 force: bool,
129}
130
131pub async fn run() -> Result<()> {
133 let cli = Cli::parse();
134 match cli.command {
135 Command::Start(args) => run_start(args).await,
136 Command::Assets(args) => run_assets(args).await,
137 }
138}
139
140async fn run_start(args: StartArgs) -> Result<()> {
141 let assets_root = match &args.net_path {
142 Some(path) => path.clone(),
143 None => assets::default_assets_root()?,
144 };
145 let layout = AssetsLayout::new(assets_root, args.network_name.clone());
146 let assets_path = shorten_home_path(&layout.net_dir().display().to_string());
147 println!("assets path: {}", assets_path);
148 let assets_exist = layout.exists().await;
149 if !args.setup_only && !args.force_setup && assets_exist {
150 println!("resuming network operations on {}", layout.network_name());
151 }
152 let protocol_version = resolve_protocol_version(&args.protocol_version).await?;
153
154 if args.setup_only {
155 return run_setup_only(&layout, &args, &protocol_version).await;
156 }
157
158 if args.force_setup {
159 assets::teardown(&layout).await?;
160 assets::setup_local(&layout, &setup_options(&args, &protocol_version)).await?;
161 } else if !assets_exist {
162 assets::setup_local(&layout, &setup_options(&args, &protocol_version)).await?;
163 }
164
165 if !layout.exists().await {
166 return Err(anyhow!(
167 "assets missing under {}; run with --setup-only to create them",
168 shorten_home_path(&layout.net_dir().display().to_string())
169 ));
170 }
171
172 let rust_log = args.log_level.clone();
173
174 let plan = StartPlan { rust_log };
175
176 let state_path = layout.net_dir().join("state.json");
177 let state = Arc::new(Mutex::new(State::new(state_path).await?));
178 let started = {
179 let mut state = state.lock().await;
180 process::start(&layout, &plan, &mut state).await?
181 };
182
183 print_pids(&started);
184 print_start_banner(&layout, &started).await;
185 print_derived_accounts_summary(&layout).await;
186
187 let node_ids = unique_node_ids(&started);
188 let details = format_network_details(&layout, &started).await;
189 let health = Arc::new(Mutex::new(SseHealth::new(node_ids.clone(), details)));
190 start_sse_spinner(&health).await;
191 spawn_sse_listeners(&layout, &node_ids, health, Arc::clone(&state)).await;
192
193 let (event_tx, mut event_rx) = unbounded_channel();
194 spawn_ctrlc_listener(event_tx.clone());
195 spawn_exit_watchers(started, event_tx);
196
197 if let Some(event) = event_rx.recv().await {
198 match event {
199 RunEvent::CtrlC => {
200 let mut state = state.lock().await;
201 process::stop(&mut state).await?;
202 }
203 RunEvent::ProcessExit {
204 id,
205 pid,
206 code,
207 signal,
208 } => {
209 let mut state = state.lock().await;
210 update_exited_process(&mut state, &id, code, signal).await?;
211 log_exit(&id, pid, code, signal);
212 process::stop(&mut state).await?;
213 }
214 }
215 }
216
217 Ok(())
218}
219
220async fn run_setup_only(
221 layout: &AssetsLayout,
222 args: &StartArgs,
223 protocol_version: &str,
224) -> Result<()> {
225 if args.force_setup {
226 assets::teardown(layout).await?;
227 assets::setup_local(layout, &setup_options(args, protocol_version)).await?;
228 print_derived_accounts_summary(layout).await;
229 return Ok(());
230 }
231
232 if layout.exists().await {
233 println!(
234 "assets already exist at {}; use --force-setup to rebuild",
235 shorten_home_path(&layout.net_dir().display().to_string())
236 );
237 print_derived_accounts_summary(layout).await;
238 return Ok(());
239 }
240
241 assets::setup_local(layout, &setup_options(args, protocol_version)).await?;
242 print_derived_accounts_summary(layout).await;
243 Ok(())
244}
245
246fn record_pid(record: &ProcessRecord) -> Option<u32> {
247 if let Some(handle) = &record.pid_handle {
248 let pid = handle.load(Ordering::SeqCst);
249 if pid != 0 {
250 return Some(pid);
251 }
252 }
253 record.pid
254}
255
256fn setup_options(args: &StartArgs, protocol_version: &str) -> SetupOptions {
257 SetupOptions {
258 nodes: args.node_count,
259 users: args.users,
260 delay_seconds: args.delay,
261 network_name: args.network_name.clone(),
262 protocol_version: protocol_version.to_string(),
263 node_log_format: args.node_log_format.clone(),
264 seed: Arc::clone(&args.seed),
265 }
266}
267
268fn print_pids(records: &[RunningProcess]) {
269 for record in records {
270 if let Some(pid) = record_pid(&record.record) {
271 println!(
272 "{} pid={} ({:?})",
273 record.record.id, pid, record.record.kind
274 );
275 }
276 }
277}
278
279async fn format_network_details(layout: &AssetsLayout, processes: &[RunningProcess]) -> String {
280 let symlink_root = layout.net_dir();
281 let mut node_pids: HashMap<u32, u32> = HashMap::new();
282 let mut sidecar_pids: HashMap<u32, u32> = HashMap::new();
283 let mut process_logs: HashMap<u32, Vec<(ProcessKind, u32)>> = HashMap::new();
284
285 for process in processes {
286 if let Some(pid) = record_pid(&process.record) {
287 match process.record.kind {
288 ProcessKind::Node => {
289 node_pids.insert(process.record.node_id, pid);
290 }
291 ProcessKind::Sidecar => {
292 sidecar_pids.insert(process.record.node_id, pid);
293 }
294 }
295 process_logs
296 .entry(process.record.node_id)
297 .or_default()
298 .push((process.record.kind.clone(), pid));
299 }
300 }
301
302 let node_ids = unique_node_ids(processes);
303
304 let mut lines = Vec::new();
305 lines.push("network details".to_string());
306 for node_id in node_ids {
307 let node_pid = node_pids
308 .get(&node_id)
309 .map(|pid| pid.to_string())
310 .unwrap_or_else(|| "-".to_string());
311 let sidecar_pid = sidecar_pids
312 .get(&node_id)
313 .map(|pid| pid.to_string())
314 .unwrap_or_else(|| "-".to_string());
315 lines.push(format!(
316 "node-{} pid={} sidecar pid={}",
317 node_id, node_pid, sidecar_pid
318 ));
319 if let Some(entries) = process_logs.get(&node_id) {
320 let mut entries = entries.clone();
321 entries.sort_by_key(|entry| process_kind_label(&entry.0).to_string());
322 for (kind, pid) in entries {
323 let (stdout_link, stderr_link) = log_symlink_paths(&symlink_root, &kind, node_id);
324 lines.push(format!(
325 " {} pid={} stdout={} stderr={}",
326 process_kind_label(&kind),
327 pid,
328 stdout_link,
329 stderr_link
330 ));
331 }
332 }
333 lines.push(format!(" rest: {}", assets::rest_endpoint(node_id)));
334 lines.push(format!(" sse: {}", assets::sse_endpoint(node_id)));
335 lines.push(format!(" rpc: {}", assets::rpc_endpoint(node_id)));
336 lines.push(format!(" binary: {}", assets::binary_address(node_id)));
337 lines.push(format!(" gossip: {}", assets::network_address(node_id)));
338 }
339
340 lines.join("\n")
341}
342
343fn process_kind_label(kind: &ProcessKind) -> &'static str {
344 match kind {
345 ProcessKind::Node => "node",
346 ProcessKind::Sidecar => "sidecar",
347 }
348}
349
350fn shorten_home_path(path: &str) -> String {
351 let path = Path::new(path);
352 let Some(base_dirs) = BaseDirs::new() else {
353 return path.display().to_string();
354 };
355 let home = base_dirs.home_dir();
356 match path.strip_prefix(home) {
357 Ok(stripped) => {
358 if stripped.as_os_str().is_empty() {
359 return "~".to_string();
360 }
361 let mut shorthand = PathBuf::from("~");
362 shorthand.push(stripped);
363 shorthand.display().to_string()
364 }
365 Err(_) => path.display().to_string(),
366 }
367}
368
369fn log_symlink_paths(symlink_root: &Path, kind: &ProcessKind, node_id: u32) -> (String, String) {
370 let base = match kind {
371 ProcessKind::Node => format!("node-{}", node_id),
372 ProcessKind::Sidecar => format!("sidecar-{}", node_id),
373 };
374 let stdout_link = symlink_root.join(format!("{}.stdout", base));
375 let stderr_link = symlink_root.join(format!("{}.stderr", base));
376 (
377 shorten_home_path(&stdout_link.display().to_string()),
378 shorten_home_path(&stderr_link.display().to_string()),
379 )
380}
381
382async fn print_derived_accounts_summary(layout: &AssetsLayout) {
383 if let Some(summary) = assets::derived_accounts_summary(layout).await {
384 println!("derived accounts");
385 for line in summary.lines() {
386 println!(" {}", line);
387 }
388 }
389}
390
391fn unique_node_ids(processes: &[RunningProcess]) -> Vec<u32> {
392 let mut nodes = HashSet::new();
393 for process in processes {
394 nodes.insert(process.record.node_id);
395 }
396 let mut ids: Vec<u32> = nodes.into_iter().collect();
397 ids.sort_unstable();
398 ids
399}
400
401enum RunEvent {
402 CtrlC,
403 ProcessExit {
404 id: String,
405 pid: Option<u32>,
406 code: Option<i32>,
407 signal: Option<i32>,
408 },
409}
410
411fn spawn_ctrlc_listener(tx: UnboundedSender<RunEvent>) {
412 tokio::spawn(async move {
413 if tokio::signal::ctrl_c().await.is_ok() {
414 let _ = tx.send(RunEvent::CtrlC);
415 }
416 });
417}
418
419fn spawn_exit_watchers(processes: Vec<RunningProcess>, tx: UnboundedSender<RunEvent>) {
420 for running in processes {
421 let tx = tx.clone();
422 tokio::spawn(async move {
423 let id = running.record.id.clone();
424 match running.handle {
425 ProcessHandle::Child(mut child) => {
426 if let Ok(status) = child.wait().await {
427 let pid = record_pid(&running.record).or_else(|| child.id());
428 let code = status.code();
429 let signal = status.signal();
430 let _ = tx.send(RunEvent::ProcessExit {
431 id: id.clone(),
432 pid,
433 code,
434 signal,
435 });
436 }
437 }
438 ProcessHandle::Task(handle) => {
439 let status = handle.await;
440 let pid = record_pid(&running.record);
441 let (code, signal) = match status {
442 Ok(Ok(())) => (Some(0), None),
443 Ok(Err(_)) => (None, None),
444 Err(_) => (None, None),
445 };
446 let _ = tx.send(RunEvent::ProcessExit {
447 id: id.clone(),
448 pid,
449 code,
450 signal,
451 });
452 }
453 }
454 });
455 }
456}
457
458const SSE_WAIT_MESSAGE: &str = "Waiting for SSE connection...";
459const BLOCK_WAIT_MESSAGE: &str = "Waiting for new blocks...";
460
461struct SseHealth {
462 expected_nodes: HashSet<u32>,
463 versions: HashMap<u32, String>,
464 announced: bool,
465 block_seen: bool,
466 sse_spinner: Option<Spinner>,
467 block_spinner: Option<Spinner>,
468 details: String,
469}
470
471impl SseHealth {
472 fn new(node_ids: Vec<u32>, details: String) -> Self {
473 Self {
474 expected_nodes: node_ids.into_iter().collect(),
475 versions: HashMap::new(),
476 announced: false,
477 block_seen: false,
478 sse_spinner: None,
479 block_spinner: None,
480 details,
481 }
482 }
483}
484
485async fn should_log_primary(node_id: u32, health: &Arc<Mutex<SseHealth>>) -> bool {
486 if node_id != 1 {
487 return false;
488 }
489 let state = health.lock().await;
490 state.announced
491}
492
493fn start_spinner(message: &str) -> Spinner {
494 Spinner::new(Spinners::Dots, message.to_string())
495}
496
497async fn start_sse_spinner(health: &Arc<Mutex<SseHealth>>) {
498 let mut state = health.lock().await;
499 if state.sse_spinner.is_none() {
500 state.sse_spinner = Some(start_spinner(SSE_WAIT_MESSAGE));
501 }
502}
503
504async fn spawn_sse_listeners(
505 _layout: &AssetsLayout,
506 node_ids: &[u32],
507 health: Arc<Mutex<SseHealth>>,
508 state: Arc<Mutex<State>>,
509) {
510 for node_id in node_ids {
511 let node_id = *node_id;
512 let endpoint = assets::sse_endpoint(node_id);
513 let health = Arc::clone(&health);
514 let state = Arc::clone(&state);
515 tokio::spawn(async move {
516 run_sse_listener(node_id, endpoint, health, state).await;
517 });
518 }
519}
520
521async fn run_sse_listener(
522 node_id: u32,
523 endpoint: String,
524 health: Arc<Mutex<SseHealth>>,
525 state: Arc<Mutex<State>>,
526) {
527 let mut backoff = ExponentialBackoff::default();
528
529 loop {
530 let config = match ListenerConfig::builder()
531 .with_endpoint(endpoint.clone())
532 .build()
533 {
534 Ok(config) => config,
535 Err(_) => {
536 if !sleep_backoff(&mut backoff).await {
537 return;
538 }
539 continue;
540 }
541 };
542
543 let stream = match sse::listener(config).await {
544 Ok(stream) => {
545 backoff.reset();
546 stream
547 }
548 Err(_) => {
549 if !sleep_backoff(&mut backoff).await {
550 return;
551 }
552 continue;
553 }
554 };
555
556 futures::pin_mut!(stream);
557 let mut stream_failed = false;
558 while let Some(event) = stream.next().await {
559 match event {
560 Ok(sse_event) => match sse_event {
561 SseEvent::ApiVersion(version) => {
562 record_api_version(node_id, version.to_string(), &health).await;
563 }
564 SseEvent::BlockAdded { block_hash, block } => {
565 if node_id == 1
566 && let Err(err) =
567 record_last_block_height(&state, block.height()).await
568 {
569 eprintln!("warning: failed to record last block height: {}", err);
570 }
571 if should_log_primary(node_id, &health).await {
572 mark_block_seen(&health).await;
573 let prefix = timestamp_prefix();
574 println!(
575 "{} Block {} added (height={} era={})",
576 prefix,
577 block_hash,
578 block.height(),
579 block.era_id().value()
580 );
581 }
582 }
583 SseEvent::TransactionAccepted(transaction) => {
584 if node_id == 1 {
585 let prefix = timestamp_prefix();
586 println!("{} Transaction {} accepted", prefix, transaction.hash());
587 }
588 }
589 SseEvent::TransactionProcessed {
590 transaction_hash,
591 execution_result,
592 messages,
593 ..
594 } => {
595 if node_id == 1 {
596 let tx_hash = transaction_hash.to_string();
597 let prefix = timestamp_prefix();
598 log_transaction_processed(
599 &prefix,
600 &tx_hash,
601 &execution_result,
602 &messages,
603 );
604 }
605 }
606 _ => {}
607 },
608 Err(_) => {
609 stream_failed = true;
610 break;
611 }
612 }
613 }
614
615 if stream_failed && !sleep_backoff(&mut backoff).await {
616 return;
617 }
618 }
619}
620
621async fn record_api_version(node_id: u32, version: String, health: &Arc<Mutex<SseHealth>>) {
622 let (summary, details, sse_spinner) = {
623 let mut state = health.lock().await;
624 if !state.expected_nodes.contains(&node_id) {
625 return;
626 }
627 state.versions.insert(node_id, version);
628 if state.announced || state.versions.len() != state.expected_nodes.len() {
629 return;
630 }
631
632 let summary = version_summary(&state.versions);
633 let details = state.details.clone();
634 let sse_spinner = state.sse_spinner.take();
635 if state.block_spinner.is_none() {
636 state.block_spinner = Some(start_spinner(BLOCK_WAIT_MESSAGE));
637 }
638 state.announced = true;
639 state.block_seen = false;
640 (summary, details, sse_spinner)
641 };
642
643 if let Some(mut spinner) = sse_spinner {
644 spinner.stop_with_message("SSE connection established.".to_string());
645 }
646 println!("Network is healthy ({})", summary);
647 println!("{}", details);
648}
649
650async fn mark_block_seen(health: &Arc<Mutex<SseHealth>>) {
651 let block_spinner = {
652 let mut state = health.lock().await;
653 if state.block_seen {
654 return;
655 }
656 state.block_seen = true;
657 state.block_spinner.take()
658 };
659
660 if let Some(mut spinner) = block_spinner {
661 spinner.stop_with_message(BLOCK_WAIT_MESSAGE.to_string());
662 }
663}
664
665async fn record_last_block_height(state: &Arc<Mutex<State>>, height: u64) -> Result<()> {
666 let mut state = state.lock().await;
667 if state.last_block_height == Some(height) {
668 return Ok(());
669 }
670 state.last_block_height = Some(height);
671 state.touch().await?;
672 Ok(())
673}
674
675fn version_summary(versions: &HashMap<u32, String>) -> String {
676 let mut unique: Vec<String> = versions.values().cloned().collect();
677 unique.sort();
678 unique.dedup();
679 if unique.len() == 1 {
680 format!("version {}", unique[0])
681 } else {
682 format!("versions {}", unique.join(", "))
683 }
684}
685
686async fn sleep_backoff(backoff: &mut ExponentialBackoff) -> bool {
687 if let Some(delay) = backoff.next_backoff() {
688 tokio::time::sleep(delay).await;
689 return true;
690 }
691 false
692}
693
694fn log_transaction_processed(
695 prefix: &str,
696 transaction_hash: &str,
697 execution_result: &ExecutionResult,
698 messages: &[casper_types::contract_messages::Message],
699) {
700 let consumed = execution_result.consumed();
701 let consumed_cspr = format_cspr_u512(&consumed);
702 if let Some(error) = execution_result.error_message() {
703 println!(
704 "{} Transaction {} processed failed ({}) gas={} gas_cspr={}",
705 prefix, transaction_hash, error, consumed, consumed_cspr
706 );
707 } else {
708 println!(
709 "{} Transaction {} processed succeeded gas={} gas_cspr={}",
710 prefix, transaction_hash, consumed, consumed_cspr
711 );
712 }
713
714 for message in messages {
715 let entity = message.entity_addr().to_formatted_string();
716 let topic = message.topic_name();
717 let payload = format_message_payload(message.payload());
718 println!("{} 📨 {} {}: {}", prefix, entity, topic, payload);
719 }
720}
721
722fn timestamp_prefix() -> String {
723 time::OffsetDateTime::now_utc()
724 .format(&time::format_description::well_known::Rfc3339)
725 .unwrap_or_else(|_| "unknown-time".to_string())
726}
727
728fn format_message_payload(payload: &MessagePayload) -> String {
729 match payload {
730 MessagePayload::Bytes(bytes) => format!("0x{}", encode_hex(bytes.as_ref())),
731 MessagePayload::String(value) => format!("{:?}", value),
732 }
733}
734
735fn encode_hex(bytes: &[u8]) -> String {
736 let mut out = String::with_capacity(bytes.len() * 2);
737 for byte in bytes {
738 use std::fmt::Write;
739 let _ = write!(&mut out, "{:02x}", byte);
740 }
741 out
742}
743
744fn format_cspr_u512(motes: &U512) -> String {
745 let motes_str = motes.to_string();
746 let digits = motes_str.len();
747 if digits <= 9 {
748 let frac = format!("{:0>9}", motes_str);
749 let frac = frac.trim_end_matches('0');
750 if frac.is_empty() {
751 return "0".to_string();
752 }
753 return format!("0.{}", frac);
754 }
755
756 let split = digits - 9;
757 let (whole, frac) = motes_str.split_at(split);
758 let frac = frac.trim_end_matches('0');
759 if frac.is_empty() {
760 return whole.to_string();
761 }
762 format!("{}.{}", whole, frac)
763}
764
765async fn update_exited_process(
766 state: &mut State,
767 id: &str,
768 code: Option<i32>,
769 signal: Option<i32>,
770) -> Result<()> {
771 for record in &mut state.processes {
772 if record.id == id {
773 record.last_status = ProcessStatus::Exited;
774 record.exit_code = code;
775 record.exit_signal = signal;
776 record.stopped_at = Some(time::OffsetDateTime::now_utc());
777 break;
778 }
779 }
780 state.touch().await?;
781 Ok(())
782}
783
784fn log_exit(id: &str, pid: Option<u32>, code: Option<i32>, signal: Option<i32>) {
785 if let Some(pid) = pid {
786 if let Some(signal) = signal {
787 println!(
788 "process {} (pid {}) exited due to signal {}",
789 id, pid, signal
790 );
791 } else if let Some(code) = code {
792 println!("process {} (pid {}) exited with code {}", id, pid, code);
793 } else {
794 println!("process {} (pid {}) exited", id, pid);
795 }
796 } else if let Some(signal) = signal {
797 println!("process {} exited due to signal {}", id, signal);
798 } else if let Some(code) = code {
799 println!("process {} exited with code {}", id, code);
800 } else {
801 println!("process {} exited", id);
802 }
803}
804
805async fn print_start_banner(layout: &AssetsLayout, processes: &[RunningProcess]) {
806 let total_nodes = layout.count_nodes().await.unwrap_or(0);
807 let target = format!("all nodes ({})", total_nodes);
808 let sidecars = processes
809 .iter()
810 .filter(|proc| matches!(proc.record.kind, crate::state::ProcessKind::Sidecar))
811 .count();
812 println!(
813 "started {} process(es) for {} (sidecars: {})",
814 processes.len(),
815 target,
816 sidecars
817 );
818}
819
820fn looks_like_url(path: &Path) -> bool {
821 let value = path.to_string_lossy();
822 value.starts_with("http://") || value.starts_with("https://")
823}
824
825async fn run_assets(args: AssetsArgs) -> Result<()> {
826 match args.command {
827 AssetsCommand::Add(add) => run_assets_add(add).await,
828 AssetsCommand::Pull(pull) => run_assets_pull(pull).await,
829 AssetsCommand::List => run_assets_list().await,
830 }
831}
832
833async fn run_assets_add(args: AssetsAddArgs) -> Result<()> {
834 if looks_like_url(&args.path) {
835 return Err(anyhow!(
836 "assets URL is not supported yet; provide a local .tar.gz path"
837 ));
838 }
839 assets::install_assets_bundle(&args.path).await?;
840 println!(
841 "assets installed into {}",
842 assets::assets_bundle_root()?.display()
843 );
844 Ok(())
845}
846
847async fn run_assets_pull(args: AssetsPullArgs) -> Result<()> {
848 assets::pull_assets_bundles(args.target.as_deref(), args.force).await?;
849 Ok(())
850}
851
852async fn run_assets_list() -> Result<()> {
853 let mut versions = assets::list_bundle_versions().await?;
854 if versions.is_empty() {
855 println!("no assets found");
856 return Ok(());
857 }
858 versions.sort_by(|a, b| b.cmp(a));
859 for version in versions {
860 println!("{}", version);
861 }
862 Ok(())
863}
864
865async fn resolve_protocol_version(candidate: &Option<String>) -> Result<String> {
866 if let Some(raw) = candidate {
867 let version = assets::parse_protocol_version(raw)?;
868 if !assets::has_bundle_version(&version).await? {
869 let argv0 = std::env::args()
870 .next()
871 .unwrap_or_else(|| "casper-devnet".to_string());
872 let pull_cmd = format!("{} assets pull", argv0);
873 let add_cmd = format!("{} assets add <path-to-assets.tar.gz>", argv0);
874 return Err(anyhow!(
875 "assets for version {} not found; run `{}` or `{}`",
876 version,
877 pull_cmd,
878 add_cmd
879 ));
880 }
881 return Ok(version.to_string());
882 }
883 match assets::most_recent_bundle_version().await {
884 Ok(version) => Ok(version.to_string()),
885 Err(_) => {
886 let argv0 = std::env::args()
887 .next()
888 .unwrap_or_else(|| "casper-devnet".to_string());
889 let pull_cmd = format!("{} assets pull", argv0);
890 let add_cmd = format!("{} assets add <path-to-assets.tar.gz>", argv0);
891 Err(anyhow!(
892 "no assets found; run `{}` or `{}`",
893 pull_cmd,
894 add_cmd
895 ))
896 }
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::{encode_hex, format_cspr_u512, format_message_payload, shorten_home_path};
903 use casper_types::U512;
904 use casper_types::contract_messages::MessagePayload;
905 use directories::BaseDirs;
906
907 #[test]
908 fn format_cspr_u512_handles_whole_and_fractional() {
909 assert_eq!(format_cspr_u512(&U512::zero()), "0");
910 assert_eq!(format_cspr_u512(&U512::from(1u64)), "0.000000001");
911 assert_eq!(format_cspr_u512(&U512::from(1_000_000_000u64)), "1");
912 assert_eq!(
913 format_cspr_u512(&U512::from(1_000_000_001u64)),
914 "1.000000001"
915 );
916 assert_eq!(
917 format_cspr_u512(&U512::from_dec_str("123000000000").unwrap()),
918 "123"
919 );
920 assert_eq!(
921 format_cspr_u512(&U512::from_dec_str("123000000456").unwrap()),
922 "123.000000456"
923 );
924 }
925
926 #[test]
927 fn format_message_payload_renders_string_with_quotes() {
928 let payload = MessagePayload::String("hello".to_string());
929 assert_eq!(format_message_payload(&payload), "\"hello\"");
930 }
931
932 #[test]
933 fn encode_hex_renders_lowercase() {
934 assert_eq!(encode_hex(&[0x00, 0xAB, 0x0f]), "00ab0f");
935 }
936
937 #[test]
938 fn shorten_home_path_replaces_home_prefix() {
939 let Some(base_dirs) = BaseDirs::new() else {
940 return;
941 };
942 let home = base_dirs.home_dir();
943 let shortened = shorten_home_path(&home.to_string_lossy());
944 assert_eq!(shortened, "~");
945
946 let nested = home.join("devnet/logs/stdout.log");
947 let shortened_nested = shorten_home_path(&nested.to_string_lossy());
948 assert!(shortened_nested.starts_with("~"));
949 assert!(shortened_nested.contains("devnet"));
950 }
951
952 #[test]
953 fn shorten_home_path_keeps_relative_paths() {
954 let input = "relative/path";
955 assert_eq!(shorten_home_path(input), input);
956 }
957}