casper_devnet/
cli.rs

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/// CLI entrypoint for the devnet launcher.
25#[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/// Top-level CLI subcommands.
37#[derive(Subcommand)]
38enum Command {
39    /// Setup assets (if needed) and start the devnet.
40    Start(StartArgs),
41    /// Manage assets bundles.
42    Assets(AssetsArgs),
43}
44
45/// Arguments for `nctl start`.
46#[derive(Args, Clone)]
47struct StartArgs {
48    /// Network name used in assets paths and configs.
49    #[arg(long, default_value = "casper-dev")]
50    network_name: String,
51
52    /// Override the base path for network runtime assets.
53    #[arg(long, value_name = "PATH")]
54    net_path: Option<PathBuf>,
55
56    /// Protocol version to use from the assets store (e.g. 2.1.1).
57    #[arg(long)]
58    protocol_version: Option<String>,
59
60    /// Number of nodes to create and start.
61    #[arg(long = "node-count", aliases = ["nodes", "validators"], default_value_t = 4)]
62    node_count: u32,
63
64    /// Number of user accounts to generate (defaults to node count).
65    #[arg(long)]
66    users: Option<u32>,
67
68    /// Genesis activation delay in seconds.
69    #[arg(long, default_value_t = 3)]
70    delay: u64,
71
72    /// Log level for child processes (passed as `RUST_LOG`).
73    #[arg(long = "log-level", default_value = "info")]
74    log_level: String,
75
76    /// Log format for node config files.
77    #[arg(long, default_value = "json")]
78    node_log_format: String,
79
80    /// Create assets and exit without starting processes.
81    #[arg(long)]
82    setup_only: bool,
83
84    /// Rebuild assets even if they already exist.
85    #[arg(long)]
86    force_setup: bool,
87
88    /// Deterministic seed for devnet key generation.
89    #[arg(long, default_value = "default")]
90    seed: Arc<str>,
91}
92
93/// Asset management arguments.
94#[derive(Args)]
95struct AssetsArgs {
96    #[command(subcommand)]
97    command: AssetsCommand,
98}
99
100/// Asset management subcommands.
101#[derive(Subcommand)]
102enum AssetsCommand {
103    /// Extract a local assets bundle into the assets store.
104    Add(AssetsAddArgs),
105    /// Download assets bundles from the upstream release.
106    Pull(AssetsPullArgs),
107    /// List available protocol versions in the assets store.
108    List,
109}
110
111/// Arguments for `nctl assets add`.
112#[derive(Args, Clone)]
113struct AssetsAddArgs {
114    /// Path to a local assets bundle (.tar.gz).
115    #[arg(value_name = "PATH")]
116    path: PathBuf,
117}
118
119/// Arguments for `nctl assets pull`.
120#[derive(Args, Clone)]
121struct AssetsPullArgs {
122    /// Target triple to select from release assets.
123    #[arg(long)]
124    target: Option<String>,
125
126    /// Re-download and replace any existing assets.
127    #[arg(long)]
128    force: bool,
129}
130
131/// Parses CLI and runs the selected subcommand.
132pub 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}