1use crate::assets::{self, AssetsLayout, SetupOptions};
2use crate::process::{self, ProcessHandle, RunningProcess, StartPlan};
3use crate::state::{ProcessKind, ProcessRecord, ProcessStatus, State};
4use anyhow::{anyhow, Result};
5use backoff::backoff::Backoff;
6use backoff::ExponentialBackoff;
7use casper_types::contract_messages::MessagePayload;
8use casper_types::execution::ExecutionResult;
9use casper_types::U512;
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::atomic::Ordering;
18use std::sync::Arc;
19use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
20use tokio::sync::Mutex;
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 let mut shorthand = PathBuf::from("~");
359 shorthand.push(stripped);
360 shorthand.display().to_string()
361 }
362 Err(_) => path.display().to_string(),
363 }
364}
365
366fn log_symlink_paths(symlink_root: &Path, kind: &ProcessKind, node_id: u32) -> (String, String) {
367 let base = match kind {
368 ProcessKind::Node => format!("node-{}", node_id),
369 ProcessKind::Sidecar => format!("sidecar-{}", node_id),
370 };
371 let stdout_link = symlink_root.join(format!("{}.stdout", base));
372 let stderr_link = symlink_root.join(format!("{}.stderr", base));
373 (
374 shorten_home_path(&stdout_link.display().to_string()),
375 shorten_home_path(&stderr_link.display().to_string()),
376 )
377}
378
379async fn print_derived_accounts_summary(layout: &AssetsLayout) {
380 if let Some(summary) = assets::derived_accounts_summary(layout).await {
381 println!("derived accounts");
382 for line in summary.lines() {
383 println!(" {}", line);
384 }
385 }
386}
387
388fn unique_node_ids(processes: &[RunningProcess]) -> Vec<u32> {
389 let mut nodes = HashSet::new();
390 for process in processes {
391 nodes.insert(process.record.node_id);
392 }
393 let mut ids: Vec<u32> = nodes.into_iter().collect();
394 ids.sort_unstable();
395 ids
396}
397
398enum RunEvent {
399 CtrlC,
400 ProcessExit {
401 id: String,
402 pid: Option<u32>,
403 code: Option<i32>,
404 signal: Option<i32>,
405 },
406}
407
408fn spawn_ctrlc_listener(tx: UnboundedSender<RunEvent>) {
409 tokio::spawn(async move {
410 if tokio::signal::ctrl_c().await.is_ok() {
411 let _ = tx.send(RunEvent::CtrlC);
412 }
413 });
414}
415
416fn spawn_exit_watchers(processes: Vec<RunningProcess>, tx: UnboundedSender<RunEvent>) {
417 for running in processes {
418 let tx = tx.clone();
419 tokio::spawn(async move {
420 let id = running.record.id.clone();
421 match running.handle {
422 ProcessHandle::Child(mut child) => {
423 if let Ok(status) = child.wait().await {
424 let pid = record_pid(&running.record).or_else(|| child.id());
425 let code = status.code();
426 let signal = status.signal();
427 let _ = tx.send(RunEvent::ProcessExit {
428 id: id.clone(),
429 pid,
430 code,
431 signal,
432 });
433 }
434 }
435 ProcessHandle::Task(handle) => {
436 let status = handle.await;
437 let pid = record_pid(&running.record);
438 let (code, signal) = match status {
439 Ok(Ok(())) => (Some(0), None),
440 Ok(Err(_)) => (None, None),
441 Err(_) => (None, None),
442 };
443 let _ = tx.send(RunEvent::ProcessExit {
444 id: id.clone(),
445 pid,
446 code,
447 signal,
448 });
449 }
450 }
451 });
452 }
453}
454
455const SSE_WAIT_MESSAGE: &str = "Waiting for SSE connection...";
456const BLOCK_WAIT_MESSAGE: &str = "Waiting for new blocks...";
457
458struct SseHealth {
459 expected_nodes: HashSet<u32>,
460 versions: HashMap<u32, String>,
461 announced: bool,
462 block_seen: bool,
463 sse_spinner: Option<Spinner>,
464 block_spinner: Option<Spinner>,
465 details: String,
466}
467
468impl SseHealth {
469 fn new(node_ids: Vec<u32>, details: String) -> Self {
470 Self {
471 expected_nodes: node_ids.into_iter().collect(),
472 versions: HashMap::new(),
473 announced: false,
474 block_seen: false,
475 sse_spinner: None,
476 block_spinner: None,
477 details,
478 }
479 }
480}
481
482async fn should_log_primary(node_id: u32, health: &Arc<Mutex<SseHealth>>) -> bool {
483 if node_id != 1 {
484 return false;
485 }
486 let state = health.lock().await;
487 state.announced
488}
489
490fn start_spinner(message: &str) -> Spinner {
491 Spinner::new(Spinners::Dots, message.to_string())
492}
493
494async fn start_sse_spinner(health: &Arc<Mutex<SseHealth>>) {
495 let mut state = health.lock().await;
496 if state.sse_spinner.is_none() {
497 state.sse_spinner = Some(start_spinner(SSE_WAIT_MESSAGE));
498 }
499}
500
501async fn spawn_sse_listeners(
502 _layout: &AssetsLayout,
503 node_ids: &[u32],
504 health: Arc<Mutex<SseHealth>>,
505 state: Arc<Mutex<State>>,
506) {
507 for node_id in node_ids {
508 let node_id = *node_id;
509 let endpoint = assets::sse_endpoint(node_id);
510 let health = Arc::clone(&health);
511 let state = Arc::clone(&state);
512 tokio::spawn(async move {
513 run_sse_listener(node_id, endpoint, health, state).await;
514 });
515 }
516}
517
518async fn run_sse_listener(
519 node_id: u32,
520 endpoint: String,
521 health: Arc<Mutex<SseHealth>>,
522 state: Arc<Mutex<State>>,
523) {
524 let mut backoff = ExponentialBackoff::default();
525
526 loop {
527 let config = match ListenerConfig::builder()
528 .with_endpoint(endpoint.clone())
529 .build()
530 {
531 Ok(config) => config,
532 Err(_) => {
533 if !sleep_backoff(&mut backoff).await {
534 return;
535 }
536 continue;
537 }
538 };
539
540 let stream = match sse::listener(config).await {
541 Ok(stream) => {
542 backoff.reset();
543 stream
544 }
545 Err(_) => {
546 if !sleep_backoff(&mut backoff).await {
547 return;
548 }
549 continue;
550 }
551 };
552
553 futures::pin_mut!(stream);
554 let mut stream_failed = false;
555 while let Some(event) = stream.next().await {
556 match event {
557 Ok(sse_event) => match sse_event {
558 SseEvent::ApiVersion(version) => {
559 record_api_version(node_id, version.to_string(), &health).await;
560 }
561 SseEvent::BlockAdded { block_hash, block } => {
562 if node_id == 1 {
563 if let Err(err) = record_last_block_height(&state, block.height()).await
564 {
565 eprintln!("warning: failed to record last block height: {}", err);
566 }
567 }
568 if should_log_primary(node_id, &health).await {
569 mark_block_seen(&health).await;
570 let prefix = timestamp_prefix();
571 println!(
572 "{} Block {} added (height={} era={})",
573 prefix,
574 block_hash,
575 block.height(),
576 block.era_id().value()
577 );
578 }
579 }
580 SseEvent::TransactionAccepted(transaction) => {
581 if node_id == 1 {
582 let prefix = timestamp_prefix();
583 println!("{} Transaction {} accepted", prefix, transaction.hash());
584 }
585 }
586 SseEvent::TransactionProcessed {
587 transaction_hash,
588 execution_result,
589 messages,
590 ..
591 } => {
592 if node_id == 1 {
593 let tx_hash = transaction_hash.to_string();
594 let prefix = timestamp_prefix();
595 log_transaction_processed(
596 &prefix,
597 &tx_hash,
598 &execution_result,
599 &messages,
600 );
601 }
602 }
603 _ => {}
604 },
605 Err(_) => {
606 stream_failed = true;
607 break;
608 }
609 }
610 }
611
612 if stream_failed && !sleep_backoff(&mut backoff).await {
613 return;
614 }
615 }
616}
617
618async fn record_api_version(node_id: u32, version: String, health: &Arc<Mutex<SseHealth>>) {
619 let (summary, details, sse_spinner) = {
620 let mut state = health.lock().await;
621 if !state.expected_nodes.contains(&node_id) {
622 return;
623 }
624 state.versions.insert(node_id, version);
625 if state.announced || state.versions.len() != state.expected_nodes.len() {
626 return;
627 }
628
629 let summary = version_summary(&state.versions);
630 let details = state.details.clone();
631 let sse_spinner = state.sse_spinner.take();
632 if state.block_spinner.is_none() {
633 state.block_spinner = Some(start_spinner(BLOCK_WAIT_MESSAGE));
634 }
635 state.announced = true;
636 state.block_seen = false;
637 (summary, details, sse_spinner)
638 };
639
640 if let Some(mut spinner) = sse_spinner {
641 spinner.stop_with_message("SSE connection established.".to_string());
642 }
643 println!("Network is healthy ({})", summary);
644 println!("{}", details);
645}
646
647async fn mark_block_seen(health: &Arc<Mutex<SseHealth>>) {
648 let block_spinner = {
649 let mut state = health.lock().await;
650 if state.block_seen {
651 return;
652 }
653 state.block_seen = true;
654 state.block_spinner.take()
655 };
656
657 if let Some(mut spinner) = block_spinner {
658 spinner.stop_with_message(BLOCK_WAIT_MESSAGE.to_string());
659 }
660}
661
662async fn record_last_block_height(state: &Arc<Mutex<State>>, height: u64) -> Result<()> {
663 let mut state = state.lock().await;
664 if state.last_block_height == Some(height) {
665 return Ok(());
666 }
667 state.last_block_height = Some(height);
668 state.touch().await?;
669 Ok(())
670}
671
672fn version_summary(versions: &HashMap<u32, String>) -> String {
673 let mut unique: Vec<String> = versions.values().cloned().collect();
674 unique.sort();
675 unique.dedup();
676 if unique.len() == 1 {
677 format!("version {}", unique[0])
678 } else {
679 format!("versions {}", unique.join(", "))
680 }
681}
682
683async fn sleep_backoff(backoff: &mut ExponentialBackoff) -> bool {
684 if let Some(delay) = backoff.next_backoff() {
685 tokio::time::sleep(delay).await;
686 return true;
687 }
688 false
689}
690
691fn log_transaction_processed(
692 prefix: &str,
693 transaction_hash: &str,
694 execution_result: &ExecutionResult,
695 messages: &[casper_types::contract_messages::Message],
696) {
697 let consumed = execution_result.consumed();
698 let consumed_cspr = format_cspr_u512(&consumed);
699 if let Some(error) = execution_result.error_message() {
700 println!(
701 "{} Transaction {} processed failed ({}) gas={} gas_cspr={}",
702 prefix, transaction_hash, error, consumed, consumed_cspr
703 );
704 } else {
705 println!(
706 "{} Transaction {} processed succeeded gas={} gas_cspr={}",
707 prefix, transaction_hash, consumed, consumed_cspr
708 );
709 }
710
711 for message in messages {
712 let entity = message.entity_addr().to_formatted_string();
713 let topic = message.topic_name();
714 let payload = format_message_payload(message.payload());
715 println!("{} 📨 {} {}: {}", prefix, entity, topic, payload);
716 }
717}
718
719fn timestamp_prefix() -> String {
720 time::OffsetDateTime::now_utc()
721 .format(&time::format_description::well_known::Rfc3339)
722 .unwrap_or_else(|_| "unknown-time".to_string())
723}
724
725fn format_message_payload(payload: &MessagePayload) -> String {
726 match payload {
727 MessagePayload::Bytes(bytes) => format!("0x{}", encode_hex(bytes.as_ref())),
728 MessagePayload::String(value) => format!("{:?}", value),
729 }
730}
731
732fn encode_hex(bytes: &[u8]) -> String {
733 let mut out = String::with_capacity(bytes.len() * 2);
734 for byte in bytes {
735 use std::fmt::Write;
736 let _ = write!(&mut out, "{:02x}", byte);
737 }
738 out
739}
740
741fn format_cspr_u512(motes: &U512) -> String {
742 let motes_str = motes.to_string();
743 let digits = motes_str.len();
744 if digits <= 9 {
745 let frac = format!("{:0>9}", motes_str);
746 let frac = frac.trim_end_matches('0');
747 if frac.is_empty() {
748 return "0".to_string();
749 }
750 return format!("0.{}", frac);
751 }
752
753 let split = digits - 9;
754 let (whole, frac) = motes_str.split_at(split);
755 let frac = frac.trim_end_matches('0');
756 if frac.is_empty() {
757 return whole.to_string();
758 }
759 format!("{}.{}", whole, frac)
760}
761
762async fn update_exited_process(
763 state: &mut State,
764 id: &str,
765 code: Option<i32>,
766 signal: Option<i32>,
767) -> Result<()> {
768 for record in &mut state.processes {
769 if record.id == id {
770 record.last_status = ProcessStatus::Exited;
771 record.exit_code = code;
772 record.exit_signal = signal;
773 record.stopped_at = Some(time::OffsetDateTime::now_utc());
774 break;
775 }
776 }
777 state.touch().await?;
778 Ok(())
779}
780
781fn log_exit(id: &str, pid: Option<u32>, code: Option<i32>, signal: Option<i32>) {
782 if let Some(pid) = pid {
783 if let Some(signal) = signal {
784 println!(
785 "process {} (pid {}) exited due to signal {}",
786 id, pid, signal
787 );
788 } else if let Some(code) = code {
789 println!("process {} (pid {}) exited with code {}", id, pid, code);
790 } else {
791 println!("process {} (pid {}) exited", id, pid);
792 }
793 } else if let Some(signal) = signal {
794 println!("process {} exited due to signal {}", id, signal);
795 } else if let Some(code) = code {
796 println!("process {} exited with code {}", id, code);
797 } else {
798 println!("process {} exited", id);
799 }
800}
801
802async fn print_start_banner(layout: &AssetsLayout, processes: &[RunningProcess]) {
803 let total_nodes = layout.count_nodes().await.unwrap_or(0);
804 let target = format!("all nodes ({})", total_nodes);
805 let sidecars = processes
806 .iter()
807 .filter(|proc| matches!(proc.record.kind, crate::state::ProcessKind::Sidecar))
808 .count();
809 println!(
810 "started {} process(es) for {} (sidecars: {})",
811 processes.len(),
812 target,
813 sidecars
814 );
815}
816
817fn looks_like_url(path: &Path) -> bool {
818 let value = path.to_string_lossy();
819 value.starts_with("http://") || value.starts_with("https://")
820}
821
822async fn run_assets(args: AssetsArgs) -> Result<()> {
823 match args.command {
824 AssetsCommand::Add(add) => run_assets_add(add).await,
825 AssetsCommand::Pull(pull) => run_assets_pull(pull).await,
826 AssetsCommand::List => run_assets_list().await,
827 }
828}
829
830async fn run_assets_add(args: AssetsAddArgs) -> Result<()> {
831 if looks_like_url(&args.path) {
832 return Err(anyhow!(
833 "assets URL is not supported yet; provide a local .tar.gz path"
834 ));
835 }
836 assets::install_assets_bundle(&args.path).await?;
837 println!(
838 "assets installed into {}",
839 assets::assets_bundle_root()?.display()
840 );
841 Ok(())
842}
843
844async fn run_assets_pull(args: AssetsPullArgs) -> Result<()> {
845 assets::pull_assets_bundles(args.target.as_deref(), args.force).await?;
846 Ok(())
847}
848
849async fn run_assets_list() -> Result<()> {
850 let mut versions = assets::list_bundle_versions().await?;
851 if versions.is_empty() {
852 println!("no assets found");
853 return Ok(());
854 }
855 versions.sort_by(|a, b| b.cmp(a));
856 for version in versions {
857 println!("{}", version);
858 }
859 Ok(())
860}
861
862async fn resolve_protocol_version(candidate: &Option<String>) -> Result<String> {
863 if let Some(raw) = candidate {
864 let version = assets::parse_protocol_version(raw)?;
865 if !assets::has_bundle_version(&version).await? {
866 let argv0 = std::env::args()
867 .next()
868 .unwrap_or_else(|| "casper-devnet".to_string());
869 let pull_cmd = format!("{} assets pull", argv0);
870 let add_cmd = format!("{} assets add <path-to-assets.tar.gz>", argv0);
871 return Err(anyhow!(
872 "assets for version {} not found; run `{}` or `{}`",
873 version,
874 pull_cmd,
875 add_cmd
876 ));
877 }
878 return Ok(version.to_string());
879 }
880 match assets::most_recent_bundle_version().await {
881 Ok(version) => Ok(version.to_string()),
882 Err(_) => {
883 let argv0 = std::env::args()
884 .next()
885 .unwrap_or_else(|| "casper-devnet".to_string());
886 let pull_cmd = format!("{} assets pull", argv0);
887 let add_cmd = format!("{} assets add <path-to-assets.tar.gz>", argv0);
888 Err(anyhow!(
889 "no assets found; run `{}` or `{}`",
890 pull_cmd,
891 add_cmd
892 ))
893 }
894 }
895}
896
897#[cfg(test)]
898mod tests {
899 use super::{encode_hex, format_cspr_u512, format_message_payload, shorten_home_path};
900 use casper_types::contract_messages::MessagePayload;
901 use casper_types::U512;
902 use directories::BaseDirs;
903
904 #[test]
905 fn format_cspr_u512_handles_whole_and_fractional() {
906 assert_eq!(format_cspr_u512(&U512::zero()), "0");
907 assert_eq!(format_cspr_u512(&U512::from(1u64)), "0.000000001");
908 assert_eq!(format_cspr_u512(&U512::from(1_000_000_000u64)), "1");
909 assert_eq!(
910 format_cspr_u512(&U512::from(1_000_000_001u64)),
911 "1.000000001"
912 );
913 assert_eq!(
914 format_cspr_u512(&U512::from_dec_str("123000000000").unwrap()),
915 "123"
916 );
917 assert_eq!(
918 format_cspr_u512(&U512::from_dec_str("123000000456").unwrap()),
919 "123.000000456"
920 );
921 }
922
923 #[test]
924 fn format_message_payload_renders_string_with_quotes() {
925 let payload = MessagePayload::String("hello".to_string());
926 assert_eq!(format_message_payload(&payload), "\"hello\"");
927 }
928
929 #[test]
930 fn encode_hex_renders_lowercase() {
931 assert_eq!(encode_hex(&[0x00, 0xAB, 0x0f]), "00ab0f");
932 }
933
934 #[test]
935 fn shorten_home_path_replaces_home_prefix() {
936 let Some(base_dirs) = BaseDirs::new() else {
937 return;
938 };
939 let home = base_dirs.home_dir();
940 let shortened = shorten_home_path(&home.to_string_lossy());
941 assert_eq!(shortened, "~");
942
943 let nested = home.join("devnet/logs/stdout.log");
944 let shortened_nested = shorten_home_path(&nested.to_string_lossy());
945 assert!(shortened_nested.starts_with("~"));
946 assert!(shortened_nested.contains("devnet"));
947 }
948
949 #[test]
950 fn shorten_home_path_keeps_relative_paths() {
951 let input = "relative/path";
952 assert_eq!(shorten_home_path(input), input);
953 }
954}