Skip to main content

canic_host/
icp.rs

1use std::{
2    env,
3    error::Error,
4    fmt,
5    io::{self, Read, Write},
6    path::{Path, PathBuf},
7    process::{Command, Stdio},
8    thread,
9};
10
11use serde::{Deserialize, Serialize};
12
13const LOCAL_NETWORK: &str = "local";
14pub(crate) const CANIC_ICP_LOCAL_NETWORK_URL_ENV: &str = "CANIC_ICP_LOCAL_NETWORK_URL";
15pub(crate) const CANIC_ICP_LOCAL_ROOT_KEY_ENV: &str = "CANIC_ICP_LOCAL_ROOT_KEY";
16
17///
18/// IcpRawOutput
19///
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct IcpRawOutput {
23    pub success: bool,
24    pub status: String,
25    pub stdout: Vec<u8>,
26    pub stderr: Vec<u8>,
27}
28
29///
30/// IcpCommandError
31///
32
33#[derive(Debug)]
34pub enum IcpCommandError {
35    Io(std::io::Error),
36    Failed {
37        command: String,
38        stderr: String,
39    },
40    Json {
41        command: String,
42        output: String,
43        source: serde_json::Error,
44    },
45    SnapshotIdUnavailable {
46        output: String,
47    },
48}
49
50impl fmt::Display for IcpCommandError {
51    // Render ICP CLI command failures with the command line and captured diagnostics.
52    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Io(err) => write!(formatter, "{err}"),
55            Self::Failed { command, stderr } => {
56                write!(formatter, "icp command failed: {command}\n{stderr}")
57            }
58            Self::Json {
59                command,
60                output,
61                source,
62            } => {
63                write!(
64                    formatter,
65                    "could not parse icp json output for {command}: {source}\n{output}"
66                )
67            }
68            Self::SnapshotIdUnavailable { output } => {
69                write!(
70                    formatter,
71                    "could not parse snapshot id from icp output: {output}"
72                )
73            }
74        }
75    }
76}
77
78impl Error for IcpCommandError {
79    // Preserve the underlying I/O error as the source when command execution fails locally.
80    fn source(&self) -> Option<&(dyn Error + 'static)> {
81        match self {
82            Self::Io(err) => Some(err),
83            Self::Json { source, .. } => Some(source),
84            Self::Failed { .. } | Self::SnapshotIdUnavailable { .. } => None,
85        }
86    }
87}
88
89impl From<std::io::Error> for IcpCommandError {
90    // Convert process-spawn failures into the shared ICP CLI command error type.
91    fn from(err: std::io::Error) -> Self {
92        Self::Io(err)
93    }
94}
95
96///
97/// IcpCli
98///
99
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct IcpCli {
102    executable: String,
103    environment: Option<String>,
104    network: Option<String>,
105    cwd: Option<PathBuf>,
106}
107
108///
109/// IcpSnapshotCreateReceipt
110///
111
112#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113pub struct IcpSnapshotCreateReceipt {
114    pub snapshot_id: String,
115    pub taken_at_timestamp: Option<u64>,
116    pub total_size_bytes: Option<u64>,
117}
118
119///
120/// IcpCanisterStatusReport
121///
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124pub struct IcpCanisterStatusReport {
125    pub id: String,
126    pub name: Option<String>,
127    pub status: String,
128    pub settings: Option<IcpCanisterStatusSettings>,
129    pub module_hash: Option<String>,
130    pub memory_size: Option<String>,
131    pub cycles: Option<String>,
132    pub reserved_cycles: Option<String>,
133    pub idle_cycles_burned_per_day: Option<String>,
134}
135
136///
137/// IcpCanisterStatusSettings
138///
139
140#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
141pub struct IcpCanisterStatusSettings {
142    #[serde(default)]
143    pub controllers: Vec<String>,
144    pub compute_allocation: Option<String>,
145    pub memory_allocation: Option<String>,
146    pub freezing_threshold: Option<String>,
147    pub reserved_cycles_limit: Option<String>,
148    pub wasm_memory_limit: Option<String>,
149    pub wasm_memory_threshold: Option<String>,
150    pub log_memory_limit: Option<String>,
151}
152
153impl IcpCli {
154    /// Build an ICP CLI command context from an executable path and optional target.
155    #[must_use]
156    pub fn new(
157        executable: impl Into<String>,
158        environment: Option<String>,
159        network: Option<String>,
160    ) -> Self {
161        Self {
162            executable: executable.into(),
163            environment,
164            network,
165            cwd: None,
166        }
167    }
168
169    /// Return a copy of this ICP CLI context rooted at one project directory.
170    #[must_use]
171    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
172        self.cwd = Some(cwd.into());
173        self
174    }
175
176    /// Return the optional ICP environment name carried by this command context.
177    #[must_use]
178    pub fn environment(&self) -> Option<&str> {
179        self.environment.as_deref()
180    }
181
182    /// Return the optional direct network name carried by this command context.
183    #[must_use]
184    pub fn network(&self) -> Option<&str> {
185        self.network.as_deref()
186    }
187
188    /// Build a base ICP CLI command from this context.
189    #[must_use]
190    pub fn command(&self) -> Command {
191        let mut command = Command::new(&self.executable);
192        if let Some(cwd) = &self.cwd {
193            command.current_dir(cwd);
194        }
195        command
196    }
197
198    /// Build a base ICP CLI command rooted at one workspace directory.
199    #[must_use]
200    pub fn command_in(&self, cwd: &Path) -> Command {
201        let mut command = self.command();
202        command.current_dir(cwd);
203        command
204    }
205
206    /// Build an `icp canister ...` command with optional environment args applied.
207    #[must_use]
208    pub fn canister_command(&self) -> Command {
209        let mut command = self.command();
210        command.arg("canister");
211        command
212    }
213
214    /// Resolve the installed ICP CLI version.
215    pub fn version(&self) -> Result<String, IcpCommandError> {
216        let mut command = self.command();
217        command.arg("--version");
218        run_output(&mut command)
219    }
220
221    /// Start the local ICP replica.
222    pub fn local_replica_start(
223        &self,
224        background: bool,
225        debug: bool,
226    ) -> Result<String, IcpCommandError> {
227        let mut command = self.local_replica_command("start");
228        run_local_replica_start_command(&mut command, background, debug)
229    }
230
231    /// Start the local ICP replica from one ICP project root.
232    pub fn local_replica_start_in(
233        &self,
234        cwd: &Path,
235        background: bool,
236        debug: bool,
237    ) -> Result<String, IcpCommandError> {
238        let mut command = self.local_replica_command_in("start", cwd);
239        run_local_replica_start_command(&mut command, background, debug)
240    }
241
242    /// Return local ICP replica status.
243    pub fn local_replica_status(&self, debug: bool) -> Result<String, IcpCommandError> {
244        let mut command = self.local_replica_command("status");
245        add_debug_arg(&mut command, debug);
246        run_output_with_stderr(&mut command)
247    }
248
249    /// Return local ICP replica status from one ICP project root.
250    pub fn local_replica_status_in(
251        &self,
252        cwd: &Path,
253        debug: bool,
254    ) -> Result<String, IcpCommandError> {
255        let mut command = self.local_replica_command_in("status", cwd);
256        add_debug_arg(&mut command, debug);
257        run_output_with_stderr(&mut command)
258    }
259
260    /// Return local ICP replica status as the ICP CLI JSON payload.
261    pub fn local_replica_status_json(
262        &self,
263        debug: bool,
264    ) -> Result<serde_json::Value, IcpCommandError> {
265        let mut command = self.local_replica_command("status");
266        add_debug_arg(&mut command, debug);
267        command.arg("--json");
268        run_json(&mut command)
269    }
270
271    /// Return local ICP replica status JSON from one ICP project root.
272    pub fn local_replica_status_json_in(
273        &self,
274        cwd: &Path,
275        debug: bool,
276    ) -> Result<serde_json::Value, IcpCommandError> {
277        let mut command = self.local_replica_command_in("status", cwd);
278        add_debug_arg(&mut command, debug);
279        command.arg("--json");
280        run_json(&mut command)
281    }
282
283    /// Return whether this project owns a running local ICP replica.
284    pub fn local_replica_project_running(&self, debug: bool) -> Result<bool, IcpCommandError> {
285        let mut command = self.local_replica_command("status");
286        add_debug_arg(&mut command, debug);
287        run_success(&mut command)
288    }
289
290    /// Return whether one ICP project root owns a running local ICP replica.
291    pub fn local_replica_project_running_in(
292        &self,
293        cwd: &Path,
294        debug: bool,
295    ) -> Result<bool, IcpCommandError> {
296        let mut command = self.local_replica_command_in("status", cwd);
297        add_debug_arg(&mut command, debug);
298        run_success(&mut command)
299    }
300
301    /// Return whether the local ICP replica responds to ping.
302    pub fn local_replica_ping(&self, debug: bool) -> Result<bool, IcpCommandError> {
303        let mut command = self.local_replica_command("ping");
304        add_debug_arg(&mut command, debug);
305        run_success(&mut command)
306    }
307
308    /// Stop the local ICP replica.
309    pub fn local_replica_stop(&self, debug: bool) -> Result<String, IcpCommandError> {
310        let mut command = self.local_replica_command("stop");
311        add_debug_arg(&mut command, debug);
312        run_output_with_stderr(&mut command)
313    }
314
315    /// Stop the local ICP replica from one ICP project root.
316    pub fn local_replica_stop_in(
317        &self,
318        cwd: &Path,
319        debug: bool,
320    ) -> Result<String, IcpCommandError> {
321        let mut command = self.local_replica_command_in("stop", cwd);
322        add_debug_arg(&mut command, debug);
323        run_output_with_stderr(&mut command)
324    }
325
326    /// Render a local replica start command.
327    #[must_use]
328    pub fn local_replica_start_display(&self, background: bool, debug: bool) -> String {
329        let mut command = self.local_replica_command("start");
330        add_debug_arg(&mut command, debug);
331        if background {
332            command.arg("--background");
333        }
334        command_display(&command)
335    }
336
337    /// Render a local replica status command.
338    #[must_use]
339    pub fn local_replica_status_display(&self, debug: bool) -> String {
340        let mut command = self.local_replica_command("status");
341        add_debug_arg(&mut command, debug);
342        command_display(&command)
343    }
344
345    /// Render a local replica stop command.
346    #[must_use]
347    pub fn local_replica_stop_display(&self, debug: bool) -> String {
348        let mut command = self.local_replica_command("stop");
349        add_debug_arg(&mut command, debug);
350        command_display(&command)
351    }
352
353    fn local_replica_command(&self, action: &str) -> Command {
354        let mut command = self.command();
355        command.args(["network", action, LOCAL_NETWORK]);
356        command
357    }
358
359    fn local_replica_command_in(&self, action: &str, cwd: &Path) -> Command {
360        let mut command = self.command_in(cwd);
361        command.args(["network", action, LOCAL_NETWORK]);
362        command
363    }
364
365    /// Call one canister method with optional JSON output.
366    pub fn canister_call_output(
367        &self,
368        canister: &str,
369        method: &str,
370        output: Option<&str>,
371    ) -> Result<String, IcpCommandError> {
372        let mut command = self.canister_command();
373        command.args(["call", canister, method]);
374        command.arg("()");
375        if let Some(output) = output {
376            add_output_arg(&mut command, output);
377        }
378        self.add_target_args(&mut command);
379        run_output(&mut command)
380    }
381
382    /// Call one canister method with an explicit Candid argument and optional JSON output.
383    pub fn canister_call_arg_output(
384        &self,
385        canister: &str,
386        method: &str,
387        arg: &str,
388        output: Option<&str>,
389    ) -> Result<String, IcpCommandError> {
390        let mut command = self.canister_command();
391        command.args(["call", canister, method]);
392        command.arg(arg);
393        if let Some(output) = output {
394            add_output_arg(&mut command, output);
395        }
396        self.add_target_args(&mut command);
397        run_output(&mut command)
398    }
399
400    /// Query one canister method with an explicit Candid argument and optional JSON output.
401    pub fn canister_query_arg_output(
402        &self,
403        canister: &str,
404        method: &str,
405        arg: &str,
406        output: Option<&str>,
407    ) -> Result<String, IcpCommandError> {
408        let mut command = self.canister_command();
409        command.args(["call", canister, method]);
410        command.arg(arg);
411        command.arg("--query");
412        if let Some(output) = output {
413            add_output_arg(&mut command, output);
414        }
415        self.add_target_args(&mut command);
416        run_output(&mut command)
417    }
418
419    /// Read one canister metadata section.
420    pub fn canister_metadata_output(
421        &self,
422        canister: &str,
423        metadata_name: &str,
424    ) -> Result<String, IcpCommandError> {
425        let mut command = self.canister_command();
426        command.args(["metadata", canister, metadata_name]);
427        self.add_target_args(&mut command);
428        run_output(&mut command)
429    }
430
431    /// Return one canister status report.
432    pub fn canister_status(&self, canister: &str) -> Result<String, IcpCommandError> {
433        let mut command = self.canister_command();
434        command.args(["status", canister]);
435        self.add_target_args(&mut command);
436        run_output(&mut command)
437    }
438
439    /// Return one canister status report from ICP CLI JSON output.
440    pub fn canister_status_report(
441        &self,
442        canister: &str,
443    ) -> Result<IcpCanisterStatusReport, IcpCommandError> {
444        let mut command = self.canister_command();
445        command.args(["status", canister]);
446        command.arg("--json");
447        self.add_target_args(&mut command);
448        run_json(&mut command)
449    }
450
451    /// Create one canister snapshot and return the ICP CLI JSON receipt.
452    pub fn snapshot_create_receipt(
453        &self,
454        canister: &str,
455    ) -> Result<IcpSnapshotCreateReceipt, IcpCommandError> {
456        let mut command = self.canister_command();
457        command.args(["snapshot", "create", canister]);
458        command.arg("--json");
459        self.add_target_args(&mut command);
460        run_json(&mut command)
461    }
462
463    /// Create one canister snapshot and resolve the resulting snapshot id.
464    pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
465        Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
466    }
467
468    /// Stop one canister.
469    pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
470        let mut command = self.canister_command();
471        command.args(["stop", canister]);
472        self.add_target_args(&mut command);
473        run_status(&mut command)
474    }
475
476    /// Start one canister.
477    pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
478        let mut command = self.canister_command();
479        command.args(["start", canister]);
480        self.add_target_args(&mut command);
481        run_status(&mut command)
482    }
483
484    /// Download one canister snapshot into an artifact directory.
485    pub fn snapshot_download(
486        &self,
487        canister: &str,
488        snapshot_id: &str,
489        artifact_path: &Path,
490    ) -> Result<(), IcpCommandError> {
491        let mut command = self.canister_command();
492        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
493        command.arg(artifact_path);
494        self.add_target_args(&mut command);
495        run_status(&mut command)
496    }
497
498    /// Upload one snapshot artifact and return the uploaded snapshot id.
499    pub fn snapshot_upload(
500        &self,
501        canister: &str,
502        artifact_path: &Path,
503    ) -> Result<String, IcpCommandError> {
504        let mut command = self.canister_command();
505        command.args(["snapshot", "upload", canister, "--input"]);
506        command.arg(artifact_path);
507        command.arg("--resume");
508        self.add_target_args(&mut command);
509        run_output_with_stderr(&mut command)
510    }
511
512    /// Restore one uploaded snapshot onto a canister.
513    pub fn snapshot_restore(
514        &self,
515        canister: &str,
516        snapshot_id: &str,
517    ) -> Result<(), IcpCommandError> {
518        let mut command = self.canister_command();
519        command.args(["snapshot", "restore", canister, snapshot_id]);
520        self.add_target_args(&mut command);
521        run_status(&mut command)
522    }
523
524    /// Render a dry-run snapshot-create command.
525    #[must_use]
526    pub fn snapshot_create_display(&self, canister: &str) -> String {
527        let mut command = self.canister_command();
528        command.args(["snapshot", "create", canister]);
529        command.arg("--json");
530        self.add_target_args(&mut command);
531        command_display(&command)
532    }
533
534    /// Render a dry-run snapshot-download command.
535    #[must_use]
536    pub fn snapshot_download_display(
537        &self,
538        canister: &str,
539        snapshot_id: &str,
540        artifact_path: &Path,
541    ) -> String {
542        let mut command = self.canister_command();
543        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
544        command.arg(artifact_path);
545        self.add_target_args(&mut command);
546        command_display(&command)
547    }
548
549    /// Render a dry-run snapshot-upload command.
550    #[must_use]
551    pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
552        let mut command = self.canister_command();
553        command.args(["snapshot", "upload", canister, "--input"]);
554        command.arg(artifact_path);
555        command.arg("--resume");
556        self.add_target_args(&mut command);
557        command_display(&command)
558    }
559
560    /// Render a dry-run snapshot-restore command.
561    #[must_use]
562    pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
563        let mut command = self.canister_command();
564        command.args(["snapshot", "restore", canister, snapshot_id]);
565        self.add_target_args(&mut command);
566        command_display(&command)
567    }
568
569    /// Render a dry-run stop command.
570    #[must_use]
571    pub fn stop_canister_display(&self, canister: &str) -> String {
572        let mut command = self.canister_command();
573        command.args(["stop", canister]);
574        self.add_target_args(&mut command);
575        command_display(&command)
576    }
577
578    /// Render a dry-run start command.
579    #[must_use]
580    pub fn start_canister_display(&self, canister: &str) -> String {
581        let mut command = self.canister_command();
582        command.args(["start", canister]);
583        self.add_target_args(&mut command);
584        command_display(&command)
585    }
586
587    fn add_target_args(&self, command: &mut Command) {
588        add_target_args(command, self.environment(), self.network());
589    }
590}
591
592/// Build a base `icp` command with the default executable.
593#[must_use]
594pub fn default_command() -> Command {
595    IcpCli::new("icp", None, None).command()
596}
597
598/// Build a base `icp` command rooted at one workspace directory.
599#[must_use]
600pub fn default_command_in(cwd: &Path) -> Command {
601    IcpCli::new("icp", None, None).command_in(cwd)
602}
603
604/// Add optional ICP CLI target arguments, preferring named environments.
605pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
606    if let Some(environment) = environment {
607        if environment == LOCAL_NETWORK
608            && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
609        {
610            command.env_remove("ICP_ENVIRONMENT");
611            command.arg("-n").arg(url);
612            if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
613                command.arg("-k").arg(root_key);
614            }
615            return;
616        }
617        command.args(["-e", environment]);
618    } else if let Some(network) = network {
619        command.args(["-n", network]);
620    }
621}
622
623/// Add ICP CLI output formatting, handling JSON as its own flag.
624pub fn add_output_arg(command: &mut Command, output: &str) {
625    if output == "json" {
626        command.arg("--json");
627    } else {
628        command.args(["--output", output]);
629    }
630}
631
632/// Add ICP CLI debug logging when requested.
633pub fn add_debug_arg(command: &mut Command, debug: bool) {
634    if debug {
635        command.arg("--debug");
636    }
637}
638
639fn run_local_replica_start_command(
640    command: &mut Command,
641    background: bool,
642    debug: bool,
643) -> Result<String, IcpCommandError> {
644    add_debug_arg(command, debug);
645    if background {
646        command.arg("--background");
647        return run_output_with_stderr(command);
648    }
649    run_status_inherit(command)?;
650    Ok(String::new())
651}
652
653/// Execute a command and capture trimmed stdout.
654pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
655    let display = command_display(command);
656    let output = command.output()?;
657    if output.status.success() {
658        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
659    } else {
660        Err(IcpCommandError::Failed {
661            command: display,
662            stderr: command_stderr(&output),
663        })
664    }
665}
666
667/// Execute a command and capture stdout plus stderr on success.
668pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
669    let display = command_display(command);
670    let output = command.output()?;
671    if output.status.success() {
672        let mut text = String::from_utf8_lossy(&output.stdout).to_string();
673        text.push_str(&String::from_utf8_lossy(&output.stderr));
674        Ok(text.trim().to_string())
675    } else {
676        Err(IcpCommandError::Failed {
677            command: display,
678            stderr: command_stderr(&output),
679        })
680    }
681}
682
683/// Execute a command and parse successful stdout as JSON.
684pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
685where
686    T: serde::de::DeserializeOwned,
687{
688    let display = command_display(command);
689    let output = command.output()?;
690    if output.status.success() {
691        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
692        serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
693            command: display,
694            output: stdout,
695            source,
696        })
697    } else {
698        Err(IcpCommandError::Failed {
699            command: display,
700            stderr: command_stderr(&output),
701        })
702    }
703}
704
705/// Execute a command and require a successful status.
706pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
707    let display = command_display(command);
708    let output = command.output()?;
709    if output.status.success() {
710        Ok(())
711    } else {
712        Err(IcpCommandError::Failed {
713            command: display,
714            stderr: command_stderr(&output),
715        })
716    }
717}
718
719/// Execute a command with inherited terminal I/O and require a successful status.
720pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
721    let display = command_display(command);
722    let mut child = command
723        .stdout(Stdio::inherit())
724        .stderr(Stdio::piped())
725        .spawn()?;
726    let stderr_handle = child
727        .stderr
728        .take()
729        .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
730    let status = child.wait()?;
731    let stderr = match stderr_handle {
732        Some(handle) => match handle.join() {
733            Ok(result) => result?,
734            Err(_) => Vec::new(),
735        },
736        None => Vec::new(),
737    };
738    if status.success() {
739        Ok(())
740    } else {
741        let stderr = if stderr.is_empty() {
742            format!("command exited with status {}", exit_status_label(status))
743        } else {
744            String::from_utf8_lossy(&stderr).to_string()
745        };
746        Err(IcpCommandError::Failed {
747            command: display,
748            stderr,
749        })
750    }
751}
752
753fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
754    let mut captured = Vec::new();
755    let mut buffer = [0_u8; 8192];
756    let mut terminal = io::stderr().lock();
757    loop {
758        let read = stderr.read(&mut buffer)?;
759        if read == 0 {
760            break;
761        }
762        terminal.write_all(&buffer[..read])?;
763        captured.extend_from_slice(&buffer[..read]);
764    }
765    terminal.flush()?;
766    Ok(captured)
767}
768
769/// Execute a command and return whether it exits successfully.
770pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
771    Ok(command.output()?.status.success())
772}
773
774/// Execute a rendered ICP CLI command and return raw process output.
775pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
776    let output = Command::new(program).args(args).output()?;
777    Ok(IcpRawOutput {
778        success: output.status.success(),
779        status: exit_status_label(output.status),
780        stdout: output.stdout,
781        stderr: output.stderr,
782    })
783}
784
785/// Render a command for diagnostics and dry-run previews.
786#[must_use]
787pub fn command_display(command: &Command) -> String {
788    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
789    parts.extend(
790        command
791            .get_args()
792            .map(|arg| arg.to_string_lossy().to_string()),
793    );
794    parts.join(" ")
795}
796
797/// Parse a likely snapshot id from `icp canister snapshot create` output.
798#[must_use]
799pub fn parse_snapshot_id(output: &str) -> Option<String> {
800    let trimmed = output.trim();
801    if is_snapshot_id_token(trimmed) {
802        return Some(trimmed.to_string());
803    }
804
805    output
806        .lines()
807        .flat_map(|line| {
808            line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
809        })
810        .find(|part| is_snapshot_id_token(part))
811        .map(str::to_string)
812}
813
814// ICP snapshot ids are rendered as even-length hexadecimal blobs.
815fn is_snapshot_id_token(value: &str) -> bool {
816    !value.is_empty()
817        && value.len().is_multiple_of(2)
818        && value.chars().all(|c| c.is_ascii_hexdigit())
819}
820
821// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
822fn command_stderr(output: &std::process::Output) -> String {
823    let stderr = String::from_utf8_lossy(&output.stderr);
824    if stderr.trim().is_empty() {
825        String::from_utf8_lossy(&output.stdout).to_string()
826    } else {
827        stderr.to_string()
828    }
829}
830
831// Render process exit status without relying on platform-specific internals.
832fn exit_status_label(status: std::process::ExitStatus) -> String {
833    status
834        .code()
835        .map_or_else(|| "signal".to_string(), |code| code.to_string())
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841
842    // Keep generated commands tied to ICP CLI environments when one is selected.
843    #[test]
844    fn renders_environment_target() {
845        let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
846
847        assert_eq!(
848            icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
849            "icp canister snapshot download root snap-1 --output backups/root -e staging"
850        );
851    }
852
853    // Keep direct network targeting available for local and ad hoc command contexts.
854    #[test]
855    fn renders_network_target() {
856        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
857
858        assert_eq!(
859            icp.snapshot_create_display("aaaaa-aa"),
860            "icp canister snapshot create aaaaa-aa --json -n ic"
861        );
862    }
863
864    // Keep local replica lifecycle commands explicit and project-scoped.
865    #[test]
866    fn renders_local_replica_commands() {
867        let icp = IcpCli::new("icp", None, None);
868
869        assert_eq!(
870            icp.local_replica_start_display(true, false),
871            "icp network start local --background"
872        );
873        assert_eq!(
874            icp.local_replica_start_display(false, false),
875            "icp network start local"
876        );
877        assert_eq!(
878            icp.local_replica_start_display(false, true),
879            "icp network start local --debug"
880        );
881        assert_eq!(
882            icp.local_replica_status_display(false),
883            "icp network status local"
884        );
885        assert_eq!(
886            icp.local_replica_status_display(true),
887            "icp network status local --debug"
888        );
889        assert_eq!(
890            icp.local_replica_stop_display(false),
891            "icp network stop local"
892        );
893        assert_eq!(
894            icp.local_replica_stop_display(true),
895            "icp network stop local --debug"
896        );
897    }
898
899    // Ensure restore planning uses the ICP CLI upload/restore flow.
900    #[test]
901    fn renders_snapshot_restore_flow() {
902        let icp = IcpCli::new("icp", Some("prod".to_string()), None);
903
904        assert_eq!(
905            icp.snapshot_upload_display("root", Path::new("artifact")),
906            "icp canister snapshot upload root --input artifact --resume -e prod"
907        );
908        assert_eq!(
909            icp.snapshot_restore_display("root", "uploaded-1"),
910            "icp canister snapshot restore root uploaded-1 -e prod"
911        );
912    }
913
914    // Ensure snapshot ids can be extracted from common create output.
915    #[test]
916    fn parses_snapshot_id_from_output() {
917        let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
918
919        assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
920    }
921
922    // Ensure table units are not mistaken for snapshot ids.
923    #[test]
924    fn parses_snapshot_id_from_table_output() {
925        let output = "\
926ID         SIZE       CREATED_AT
9270a0b0c0d   1.37 MiB   2026-05-10T17:04:19Z
928";
929
930        let snapshot_id = parse_snapshot_id(output);
931
932        assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
933    }
934
935    // Ensure current ICP CLI snapshot JSON receipts parse into the typed host shape.
936    #[test]
937    fn parses_snapshot_create_receipt_json() {
938        let receipt = serde_json::from_str::<IcpSnapshotCreateReceipt>(
939            r#"{
940  "snapshot_id": "0000000000000000ffffffffffc000020101",
941  "taken_at_timestamp": 1778709681897818005,
942  "total_size_bytes": 272586987
943}"#,
944        )
945        .expect("parse snapshot receipt");
946
947        assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
948        assert_eq!(receipt.total_size_bytes, Some(272_586_987));
949    }
950
951    // Ensure current ICP CLI status JSON parses into the typed host shape.
952    #[test]
953    fn parses_canister_status_report_json() {
954        let report = serde_json::from_str::<IcpCanisterStatusReport>(
955            r#"{
956  "id": "t63gs-up777-77776-aaaba-cai",
957  "name": "motoko-ex",
958  "status": "Running",
959  "settings": {
960    "controllers": ["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"],
961    "compute_allocation": "0"
962  },
963  "module_hash": "0x66ce5ddcd06f1135c1a04792a2f1b7c3d9e229b977a8fc9762c71ecc5314c9eb",
964  "cycles": "1_497_896_187_059"
965}"#,
966        )
967        .expect("parse status report");
968
969        assert_eq!(report.status, "Running");
970        assert_eq!(
971            report.settings.expect("settings").controllers.as_slice(),
972            &["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"]
973        );
974    }
975}