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/// IcpSnapshotUploadReceipt
121///
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124pub struct IcpSnapshotUploadReceipt {
125    pub snapshot_id: String,
126}
127
128///
129/// IcpCanisterStatusReport
130///
131
132#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
133pub struct IcpCanisterStatusReport {
134    pub id: String,
135    pub name: Option<String>,
136    pub status: String,
137    pub settings: Option<IcpCanisterStatusSettings>,
138    pub module_hash: Option<String>,
139    pub memory_size: Option<String>,
140    pub cycles: Option<String>,
141    pub reserved_cycles: Option<String>,
142    pub idle_cycles_burned_per_day: Option<String>,
143}
144
145///
146/// IcpCanisterStatusSettings
147///
148
149#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
150pub struct IcpCanisterStatusSettings {
151    #[serde(default)]
152    pub controllers: Vec<String>,
153    pub compute_allocation: Option<String>,
154    pub memory_allocation: Option<String>,
155    pub freezing_threshold: Option<String>,
156    pub reserved_cycles_limit: Option<String>,
157    pub wasm_memory_limit: Option<String>,
158    pub wasm_memory_threshold: Option<String>,
159    pub log_memory_limit: Option<String>,
160}
161
162impl IcpCli {
163    /// Build an ICP CLI command context from an executable path and optional target.
164    #[must_use]
165    pub fn new(
166        executable: impl Into<String>,
167        environment: Option<String>,
168        network: Option<String>,
169    ) -> Self {
170        Self {
171            executable: executable.into(),
172            environment,
173            network,
174            cwd: None,
175        }
176    }
177
178    /// Return a copy of this ICP CLI context rooted at one project directory.
179    #[must_use]
180    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
181        self.cwd = Some(cwd.into());
182        self
183    }
184
185    /// Return the optional ICP environment name carried by this command context.
186    #[must_use]
187    pub fn environment(&self) -> Option<&str> {
188        self.environment.as_deref()
189    }
190
191    /// Return the optional direct network name carried by this command context.
192    #[must_use]
193    pub fn network(&self) -> Option<&str> {
194        self.network.as_deref()
195    }
196
197    /// Build a base ICP CLI command from this context.
198    #[must_use]
199    pub fn command(&self) -> Command {
200        let mut command = Command::new(&self.executable);
201        if let Some(cwd) = &self.cwd {
202            command.current_dir(cwd);
203        }
204        command
205    }
206
207    /// Build a base ICP CLI command rooted at one workspace directory.
208    #[must_use]
209    pub fn command_in(&self, cwd: &Path) -> Command {
210        let mut command = self.command();
211        command.current_dir(cwd);
212        command
213    }
214
215    /// Build an `icp canister ...` command with optional environment args applied.
216    #[must_use]
217    pub fn canister_command(&self) -> Command {
218        let mut command = self.command();
219        command.arg("canister");
220        command
221    }
222
223    /// Resolve the installed ICP CLI version.
224    pub fn version(&self) -> Result<String, IcpCommandError> {
225        let mut command = self.command();
226        command.arg("--version");
227        run_output(&mut command)
228    }
229
230    /// Start the local ICP replica.
231    pub fn local_replica_start(
232        &self,
233        background: bool,
234        debug: bool,
235    ) -> Result<String, IcpCommandError> {
236        let mut command = self.local_replica_command("start");
237        run_local_replica_start_command(&mut command, background, debug)
238    }
239
240    /// Start the local ICP replica from one ICP project root.
241    pub fn local_replica_start_in(
242        &self,
243        cwd: &Path,
244        background: bool,
245        debug: bool,
246    ) -> Result<String, IcpCommandError> {
247        let mut command = self.local_replica_command_in("start", cwd);
248        run_local_replica_start_command(&mut command, background, debug)
249    }
250
251    /// Return local ICP replica status.
252    pub fn local_replica_status(&self, debug: bool) -> Result<String, IcpCommandError> {
253        let mut command = self.local_replica_command("status");
254        add_debug_arg(&mut command, debug);
255        run_output_with_stderr(&mut command)
256    }
257
258    /// Return local ICP replica status from one ICP project root.
259    pub fn local_replica_status_in(
260        &self,
261        cwd: &Path,
262        debug: bool,
263    ) -> Result<String, IcpCommandError> {
264        let mut command = self.local_replica_command_in("status", cwd);
265        add_debug_arg(&mut command, debug);
266        run_output_with_stderr(&mut command)
267    }
268
269    /// Return local ICP replica status as the ICP CLI JSON payload.
270    pub fn local_replica_status_json(
271        &self,
272        debug: bool,
273    ) -> Result<serde_json::Value, IcpCommandError> {
274        let mut command = self.local_replica_command("status");
275        add_debug_arg(&mut command, debug);
276        command.arg("--json");
277        run_json(&mut command)
278    }
279
280    /// Return local ICP replica status JSON from one ICP project root.
281    pub fn local_replica_status_json_in(
282        &self,
283        cwd: &Path,
284        debug: bool,
285    ) -> Result<serde_json::Value, IcpCommandError> {
286        let mut command = self.local_replica_command_in("status", cwd);
287        add_debug_arg(&mut command, debug);
288        command.arg("--json");
289        run_json(&mut command)
290    }
291
292    /// Return whether this project owns a running local ICP replica.
293    pub fn local_replica_project_running(&self, debug: bool) -> Result<bool, IcpCommandError> {
294        let mut command = self.local_replica_command("status");
295        add_debug_arg(&mut command, debug);
296        run_success(&mut command)
297    }
298
299    /// Return whether one ICP project root owns a running local ICP replica.
300    pub fn local_replica_project_running_in(
301        &self,
302        cwd: &Path,
303        debug: bool,
304    ) -> Result<bool, IcpCommandError> {
305        let mut command = self.local_replica_command_in("status", cwd);
306        add_debug_arg(&mut command, debug);
307        run_success(&mut command)
308    }
309
310    /// Return whether the local ICP replica responds to ping.
311    pub fn local_replica_ping(&self, debug: bool) -> Result<bool, IcpCommandError> {
312        let mut command = self.local_replica_command("ping");
313        add_debug_arg(&mut command, debug);
314        run_success(&mut command)
315    }
316
317    /// Stop the local ICP replica.
318    pub fn local_replica_stop(&self, debug: bool) -> Result<String, IcpCommandError> {
319        let mut command = self.local_replica_command("stop");
320        add_debug_arg(&mut command, debug);
321        run_output_with_stderr(&mut command)
322    }
323
324    /// Stop the local ICP replica from one ICP project root.
325    pub fn local_replica_stop_in(
326        &self,
327        cwd: &Path,
328        debug: bool,
329    ) -> Result<String, IcpCommandError> {
330        let mut command = self.local_replica_command_in("stop", cwd);
331        add_debug_arg(&mut command, debug);
332        run_output_with_stderr(&mut command)
333    }
334
335    /// Render a local replica start command.
336    #[must_use]
337    pub fn local_replica_start_display(&self, background: bool, debug: bool) -> String {
338        let mut command = self.local_replica_command("start");
339        add_debug_arg(&mut command, debug);
340        if background {
341            command.arg("--background");
342        }
343        command_display(&command)
344    }
345
346    /// Render a local replica status command.
347    #[must_use]
348    pub fn local_replica_status_display(&self, debug: bool) -> String {
349        let mut command = self.local_replica_command("status");
350        add_debug_arg(&mut command, debug);
351        command_display(&command)
352    }
353
354    /// Render a local replica stop command.
355    #[must_use]
356    pub fn local_replica_stop_display(&self, debug: bool) -> String {
357        let mut command = self.local_replica_command("stop");
358        add_debug_arg(&mut command, debug);
359        command_display(&command)
360    }
361
362    fn local_replica_command(&self, action: &str) -> Command {
363        let mut command = self.command();
364        command.args(["network", action, LOCAL_NETWORK]);
365        command
366    }
367
368    fn local_replica_command_in(&self, action: &str, cwd: &Path) -> Command {
369        let mut command = self.command_in(cwd);
370        command.args(["network", action, LOCAL_NETWORK]);
371        command
372    }
373
374    /// Call one canister method with optional JSON output.
375    pub fn canister_call_output(
376        &self,
377        canister: &str,
378        method: &str,
379        output: Option<&str>,
380    ) -> Result<String, IcpCommandError> {
381        let mut command = self.canister_command();
382        command.args(["call", canister, method]);
383        command.arg("()");
384        if let Some(output) = output {
385            add_output_arg(&mut command, output);
386        }
387        self.add_target_args(&mut command);
388        run_output(&mut command)
389    }
390
391    /// Call one canister method with an explicit Candid argument and optional JSON output.
392    pub fn canister_call_arg_output(
393        &self,
394        canister: &str,
395        method: &str,
396        arg: &str,
397        output: Option<&str>,
398    ) -> Result<String, IcpCommandError> {
399        let mut command = self.canister_command();
400        command.args(["call", canister, method]);
401        command.arg(arg);
402        if let Some(output) = output {
403            add_output_arg(&mut command, output);
404        }
405        self.add_target_args(&mut command);
406        run_output(&mut command)
407    }
408
409    /// Query one canister method with no arguments and optional JSON output.
410    pub fn canister_query_output(
411        &self,
412        canister: &str,
413        method: &str,
414        output: Option<&str>,
415    ) -> Result<String, IcpCommandError> {
416        let mut command = self.canister_command();
417        command.args(["call", canister, method]);
418        command.arg("()");
419        command.arg("--query");
420        if let Some(output) = output {
421            add_output_arg(&mut command, output);
422        }
423        self.add_target_args(&mut command);
424        run_output(&mut command)
425    }
426
427    /// Query one canister method with an explicit Candid argument and optional JSON output.
428    pub fn canister_query_arg_output(
429        &self,
430        canister: &str,
431        method: &str,
432        arg: &str,
433        output: Option<&str>,
434    ) -> Result<String, IcpCommandError> {
435        let mut command = self.canister_command();
436        command.args(["call", canister, method]);
437        command.arg(arg);
438        command.arg("--query");
439        if let Some(output) = output {
440            add_output_arg(&mut command, output);
441        }
442        self.add_target_args(&mut command);
443        run_output(&mut command)
444    }
445
446    /// Read one canister metadata section.
447    pub fn canister_metadata_output(
448        &self,
449        canister: &str,
450        metadata_name: &str,
451    ) -> Result<String, IcpCommandError> {
452        let mut command = self.canister_command();
453        command.args(["metadata", canister, metadata_name]);
454        self.add_target_args(&mut command);
455        run_output(&mut command)
456    }
457
458    /// Return one canister status report.
459    pub fn canister_status(&self, canister: &str) -> Result<String, IcpCommandError> {
460        let mut command = self.canister_command();
461        command.args(["status", canister]);
462        self.add_target_args(&mut command);
463        run_output(&mut command)
464    }
465
466    /// Top up one canister with cycles.
467    pub fn canister_top_up_output(
468        &self,
469        canister: &str,
470        amount_cycles: u128,
471    ) -> Result<String, IcpCommandError> {
472        let mut command = self.canister_command();
473        command.args(["top-up", "--amount"]);
474        command.arg(amount_cycles.to_string());
475        command.arg(canister);
476        self.add_target_args(&mut command);
477        run_output_with_stderr(&mut command)
478    }
479
480    /// Return one canister status report from ICP CLI JSON output.
481    pub fn canister_status_report(
482        &self,
483        canister: &str,
484    ) -> Result<IcpCanisterStatusReport, IcpCommandError> {
485        let mut command = self.canister_command();
486        command.args(["status", canister]);
487        command.arg("--json");
488        self.add_target_args(&mut command);
489        run_json(&mut command)
490    }
491
492    /// Create one canister snapshot and return the ICP CLI JSON receipt.
493    pub fn snapshot_create_receipt(
494        &self,
495        canister: &str,
496    ) -> Result<IcpSnapshotCreateReceipt, IcpCommandError> {
497        let mut command = self.canister_command();
498        command.args(["snapshot", "create", canister]);
499        command.arg("--json");
500        self.add_target_args(&mut command);
501        run_json(&mut command)
502    }
503
504    /// Create one canister snapshot and resolve the resulting snapshot id.
505    pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
506        Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
507    }
508
509    /// Stop one canister.
510    pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
511        let mut command = self.canister_command();
512        command.args(["stop", canister]);
513        self.add_target_args(&mut command);
514        run_status(&mut command)
515    }
516
517    /// Start one canister.
518    pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
519        let mut command = self.canister_command();
520        command.args(["start", canister]);
521        self.add_target_args(&mut command);
522        run_status(&mut command)
523    }
524
525    /// Download one canister snapshot into an artifact directory.
526    pub fn snapshot_download(
527        &self,
528        canister: &str,
529        snapshot_id: &str,
530        artifact_path: &Path,
531    ) -> Result<(), IcpCommandError> {
532        let mut command = self.canister_command();
533        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
534        command.arg(artifact_path);
535        self.add_target_args(&mut command);
536        run_status(&mut command)
537    }
538
539    /// Upload one snapshot artifact and return the uploaded snapshot id.
540    pub fn snapshot_upload(
541        &self,
542        canister: &str,
543        artifact_path: &Path,
544    ) -> Result<String, IcpCommandError> {
545        Ok(self
546            .snapshot_upload_receipt(canister, artifact_path)?
547            .snapshot_id)
548    }
549
550    /// Upload one snapshot artifact and return the ICP CLI JSON receipt.
551    pub fn snapshot_upload_receipt(
552        &self,
553        canister: &str,
554        artifact_path: &Path,
555    ) -> Result<IcpSnapshotUploadReceipt, IcpCommandError> {
556        let mut command = self.canister_command();
557        command.args(["snapshot", "upload", canister, "--input"]);
558        command.arg(artifact_path);
559        command.arg("--resume");
560        command.arg("--json");
561        self.add_target_args(&mut command);
562        run_json(&mut command)
563    }
564
565    /// Restore one uploaded snapshot onto a canister.
566    pub fn snapshot_restore(
567        &self,
568        canister: &str,
569        snapshot_id: &str,
570    ) -> Result<(), IcpCommandError> {
571        let mut command = self.canister_command();
572        command.args(["snapshot", "restore", canister, snapshot_id]);
573        self.add_target_args(&mut command);
574        run_status(&mut command)
575    }
576
577    /// Render a dry-run snapshot-create command.
578    #[must_use]
579    pub fn snapshot_create_display(&self, canister: &str) -> String {
580        let mut command = self.canister_command();
581        command.args(["snapshot", "create", canister]);
582        command.arg("--json");
583        self.add_target_args(&mut command);
584        command_display(&command)
585    }
586
587    /// Render a dry-run snapshot-download command.
588    #[must_use]
589    pub fn snapshot_download_display(
590        &self,
591        canister: &str,
592        snapshot_id: &str,
593        artifact_path: &Path,
594    ) -> String {
595        let mut command = self.canister_command();
596        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
597        command.arg(artifact_path);
598        self.add_target_args(&mut command);
599        command_display(&command)
600    }
601
602    /// Render a dry-run snapshot-upload command.
603    #[must_use]
604    pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
605        let mut command = self.canister_command();
606        command.args(["snapshot", "upload", canister, "--input"]);
607        command.arg(artifact_path);
608        command.arg("--resume");
609        command.arg("--json");
610        self.add_target_args(&mut command);
611        command_display(&command)
612    }
613
614    /// Render a dry-run snapshot-restore command.
615    #[must_use]
616    pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
617        let mut command = self.canister_command();
618        command.args(["snapshot", "restore", canister, snapshot_id]);
619        self.add_target_args(&mut command);
620        command_display(&command)
621    }
622
623    /// Render a dry-run stop command.
624    #[must_use]
625    pub fn stop_canister_display(&self, canister: &str) -> String {
626        let mut command = self.canister_command();
627        command.args(["stop", canister]);
628        self.add_target_args(&mut command);
629        command_display(&command)
630    }
631
632    /// Render a dry-run start command.
633    #[must_use]
634    pub fn start_canister_display(&self, canister: &str) -> String {
635        let mut command = self.canister_command();
636        command.args(["start", canister]);
637        self.add_target_args(&mut command);
638        command_display(&command)
639    }
640
641    /// Render a dry-run top-up command.
642    #[must_use]
643    pub fn canister_top_up_display(&self, canister: &str, amount_cycles: u128) -> String {
644        let mut command = self.canister_command();
645        command.args(["top-up", "--amount"]);
646        command.arg(amount_cycles.to_string());
647        command.arg(canister);
648        self.add_target_args(&mut command);
649        command_display(&command)
650    }
651
652    /// Render a dry-run no-argument query call.
653    #[must_use]
654    pub fn canister_query_output_display(
655        &self,
656        canister: &str,
657        method: &str,
658        output: Option<&str>,
659    ) -> String {
660        let mut command = self.canister_command();
661        command.args(["call", canister, method]);
662        command.arg("()");
663        command.arg("--query");
664        if let Some(output) = output {
665            add_output_arg(&mut command, output);
666        }
667        self.add_target_args(&mut command);
668        command_display(&command)
669    }
670
671    /// Render a dry-run update call with an explicit Candid argument.
672    #[must_use]
673    pub fn canister_call_arg_output_display(
674        &self,
675        canister: &str,
676        method: &str,
677        arg: &str,
678        output: Option<&str>,
679    ) -> String {
680        let mut command = self.canister_command();
681        command.args(["call", canister, method]);
682        command.arg(arg);
683        if let Some(output) = output {
684            add_output_arg(&mut command, output);
685        }
686        self.add_target_args(&mut command);
687        command_display(&command)
688    }
689
690    fn add_target_args(&self, command: &mut Command) {
691        add_target_args(command, self.environment(), self.network());
692    }
693}
694
695/// Build a base `icp` command with the default executable.
696#[must_use]
697pub fn default_command() -> Command {
698    IcpCli::new("icp", None, None).command()
699}
700
701/// Build a base `icp` command rooted at one workspace directory.
702#[must_use]
703pub fn default_command_in(cwd: &Path) -> Command {
704    IcpCli::new("icp", None, None).command_in(cwd)
705}
706
707/// Add optional ICP CLI target arguments, preferring named environments.
708pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
709    if let Some(environment) = environment {
710        if environment == LOCAL_NETWORK
711            && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
712        {
713            command.env_remove("ICP_ENVIRONMENT");
714            command.arg("-n").arg(url);
715            if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
716                command.arg("-k").arg(root_key);
717            }
718            return;
719        }
720        command.args(["-e", environment]);
721    } else if let Some(network) = network {
722        command.args(["-n", network]);
723    }
724}
725
726/// Add ICP CLI output formatting, handling JSON as its own flag.
727pub fn add_output_arg(command: &mut Command, output: &str) {
728    if output == "json" {
729        command.arg("--json");
730    } else {
731        command.args(["--output", output]);
732    }
733}
734
735/// Add ICP CLI debug logging when requested.
736pub fn add_debug_arg(command: &mut Command, debug: bool) {
737    if debug {
738        command.arg("--debug");
739    }
740}
741
742fn run_local_replica_start_command(
743    command: &mut Command,
744    background: bool,
745    debug: bool,
746) -> Result<String, IcpCommandError> {
747    add_debug_arg(command, debug);
748    if background {
749        command.arg("--background");
750        return run_output_with_stderr(command);
751    }
752    run_status_inherit(command)?;
753    Ok(String::new())
754}
755
756/// Execute a command and capture trimmed stdout.
757pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
758    let display = command_display(command);
759    let output = command.output()?;
760    if output.status.success() {
761        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
762    } else {
763        Err(IcpCommandError::Failed {
764            command: display,
765            stderr: command_stderr(&output),
766        })
767    }
768}
769
770/// Execute a command and capture stdout plus stderr on success.
771pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
772    let display = command_display(command);
773    let output = command.output()?;
774    if output.status.success() {
775        let mut text = String::from_utf8_lossy(&output.stdout).to_string();
776        text.push_str(&String::from_utf8_lossy(&output.stderr));
777        Ok(text.trim().to_string())
778    } else {
779        Err(IcpCommandError::Failed {
780            command: display,
781            stderr: command_stderr(&output),
782        })
783    }
784}
785
786/// Execute a command and parse successful stdout as JSON.
787pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
788where
789    T: serde::de::DeserializeOwned,
790{
791    let display = command_display(command);
792    let output = command.output()?;
793    if output.status.success() {
794        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
795        serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
796            command: display,
797            output: stdout,
798            source,
799        })
800    } else {
801        Err(IcpCommandError::Failed {
802            command: display,
803            stderr: command_stderr(&output),
804        })
805    }
806}
807
808/// Execute a command and require a successful status.
809pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
810    let display = command_display(command);
811    let output = command.output()?;
812    if output.status.success() {
813        Ok(())
814    } else {
815        Err(IcpCommandError::Failed {
816            command: display,
817            stderr: command_stderr(&output),
818        })
819    }
820}
821
822/// Execute a command with inherited terminal I/O and require a successful status.
823pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
824    let display = command_display(command);
825    let mut child = command
826        .stdout(Stdio::inherit())
827        .stderr(Stdio::piped())
828        .spawn()?;
829    let stderr_handle = child
830        .stderr
831        .take()
832        .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
833    let status = child.wait()?;
834    let stderr = match stderr_handle {
835        Some(handle) => match handle.join() {
836            Ok(result) => result?,
837            Err(_) => Vec::new(),
838        },
839        None => Vec::new(),
840    };
841    if status.success() {
842        Ok(())
843    } else {
844        let stderr = if stderr.is_empty() {
845            format!("command exited with status {}", exit_status_label(status))
846        } else {
847            String::from_utf8_lossy(&stderr).to_string()
848        };
849        Err(IcpCommandError::Failed {
850            command: display,
851            stderr,
852        })
853    }
854}
855
856fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
857    let mut captured = Vec::new();
858    let mut buffer = [0_u8; 8192];
859    let mut terminal = io::stderr().lock();
860    loop {
861        let read = stderr.read(&mut buffer)?;
862        if read == 0 {
863            break;
864        }
865        terminal.write_all(&buffer[..read])?;
866        captured.extend_from_slice(&buffer[..read]);
867    }
868    terminal.flush()?;
869    Ok(captured)
870}
871
872/// Execute a command and return whether it exits successfully.
873pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
874    Ok(command.output()?.status.success())
875}
876
877/// Execute a rendered ICP CLI command and return raw process output.
878pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
879    let output = Command::new(program).args(args).output()?;
880    Ok(IcpRawOutput {
881        success: output.status.success(),
882        status: exit_status_label(output.status),
883        stdout: output.stdout,
884        stderr: output.stderr,
885    })
886}
887
888/// Render a command for diagnostics and dry-run previews.
889#[must_use]
890pub fn command_display(command: &Command) -> String {
891    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
892    parts.extend(
893        command
894            .get_args()
895            .map(|arg| arg.to_string_lossy().to_string()),
896    );
897    parts.join(" ")
898}
899
900/// Parse a likely snapshot id from `icp canister snapshot create` output.
901#[must_use]
902pub fn parse_snapshot_id(output: &str) -> Option<String> {
903    let trimmed = output.trim();
904    if is_snapshot_id_token(trimmed) {
905        return Some(trimmed.to_string());
906    }
907
908    output
909        .lines()
910        .flat_map(|line| {
911            line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
912        })
913        .find(|part| is_snapshot_id_token(part))
914        .map(str::to_string)
915}
916
917// ICP snapshot ids are rendered as even-length hexadecimal blobs.
918fn is_snapshot_id_token(value: &str) -> bool {
919    !value.is_empty()
920        && value.len().is_multiple_of(2)
921        && value.chars().all(|c| c.is_ascii_hexdigit())
922}
923
924// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
925fn command_stderr(output: &std::process::Output) -> String {
926    let stderr = String::from_utf8_lossy(&output.stderr);
927    if stderr.trim().is_empty() {
928        String::from_utf8_lossy(&output.stdout).to_string()
929    } else {
930        stderr.to_string()
931    }
932}
933
934// Render process exit status without relying on platform-specific internals.
935fn exit_status_label(status: std::process::ExitStatus) -> String {
936    status
937        .code()
938        .map_or_else(|| "signal".to_string(), |code| code.to_string())
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944
945    // Keep generated commands tied to ICP CLI environments when one is selected.
946    #[test]
947    fn renders_environment_target() {
948        let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
949
950        assert_eq!(
951            icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
952            "icp canister snapshot download root snap-1 --output backups/root -e staging"
953        );
954    }
955
956    // Keep direct network targeting available for local and ad hoc command contexts.
957    #[test]
958    fn renders_network_target() {
959        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
960
961        assert_eq!(
962            icp.snapshot_create_display("aaaaa-aa"),
963            "icp canister snapshot create aaaaa-aa --json -n ic"
964        );
965    }
966
967    // Keep local replica lifecycle commands explicit and project-scoped.
968    #[test]
969    fn renders_local_replica_commands() {
970        let icp = IcpCli::new("icp", None, None);
971
972        assert_eq!(
973            icp.local_replica_start_display(true, false),
974            "icp network start local --background"
975        );
976        assert_eq!(
977            icp.local_replica_start_display(false, false),
978            "icp network start local"
979        );
980        assert_eq!(
981            icp.local_replica_start_display(false, true),
982            "icp network start local --debug"
983        );
984        assert_eq!(
985            icp.local_replica_status_display(false),
986            "icp network status local"
987        );
988        assert_eq!(
989            icp.local_replica_status_display(true),
990            "icp network status local --debug"
991        );
992        assert_eq!(
993            icp.local_replica_stop_display(false),
994            "icp network stop local"
995        );
996        assert_eq!(
997            icp.local_replica_stop_display(true),
998            "icp network stop local --debug"
999        );
1000    }
1001
1002    // Ensure restore planning uses the ICP CLI upload/restore flow.
1003    #[test]
1004    fn renders_snapshot_restore_flow() {
1005        let icp = IcpCli::new("icp", Some("prod".to_string()), None);
1006
1007        assert_eq!(
1008            icp.snapshot_upload_display("root", Path::new("artifact")),
1009            "icp canister snapshot upload root --input artifact --resume --json -e prod"
1010        );
1011        assert_eq!(
1012            icp.snapshot_restore_display("root", "uploaded-1"),
1013            "icp canister snapshot restore root uploaded-1 -e prod"
1014        );
1015    }
1016
1017    // Ensure query helpers do not accidentally issue update calls for read-only endpoint probes.
1018    #[test]
1019    fn renders_no_argument_query_call() {
1020        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1021
1022        assert_eq!(
1023            icp.canister_query_output_display("root", "canic_ready", Some("json")),
1024            "icp canister call root canic_ready () --query --json -n ic"
1025        );
1026    }
1027
1028    // Ensure update-call previews preserve the explicit Candid argument.
1029    #[test]
1030    fn renders_argument_update_call() {
1031        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1032
1033        assert_eq!(
1034            icp.canister_call_arg_output_display(
1035                "root",
1036                "canic_icp_refill",
1037                "(record { dry_run = true })",
1038                Some("json")
1039            ),
1040            "icp canister call root canic_icp_refill (record { dry_run = true }) --json -n ic"
1041        );
1042    }
1043
1044    // Ensure manual top-ups use the ICP CLI top-up command and selected network.
1045    #[test]
1046    fn renders_canister_top_up() {
1047        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1048
1049        assert_eq!(
1050            icp.canister_top_up_display("aaaaa-aa", 4_000_000_000_000),
1051            "icp canister top-up --amount 4000000000000 aaaaa-aa -n ic"
1052        );
1053    }
1054
1055    // Ensure snapshot ids can be extracted from common create output.
1056    #[test]
1057    fn parses_snapshot_id_from_output() {
1058        let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
1059
1060        assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1061    }
1062
1063    // Ensure table units are not mistaken for snapshot ids.
1064    #[test]
1065    fn parses_snapshot_id_from_table_output() {
1066        let output = "\
1067ID         SIZE       CREATED_AT
10680a0b0c0d   1.37 MiB   2026-05-10T17:04:19Z
1069";
1070
1071        let snapshot_id = parse_snapshot_id(output);
1072
1073        assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1074    }
1075
1076    // Ensure current ICP CLI snapshot JSON receipts parse into the typed host shape.
1077    #[test]
1078    fn parses_snapshot_create_receipt_json() {
1079        let receipt = serde_json::from_str::<IcpSnapshotCreateReceipt>(
1080            r#"{
1081  "snapshot_id": "0000000000000000ffffffffffc000020101",
1082  "taken_at_timestamp": 1778709681897818005,
1083  "total_size_bytes": 272586987
1084}"#,
1085        )
1086        .expect("parse snapshot receipt");
1087
1088        assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1089        assert_eq!(receipt.total_size_bytes, Some(272_586_987));
1090    }
1091
1092    // Ensure current ICP CLI snapshot upload JSON receipts parse into the typed host shape.
1093    #[test]
1094    fn parses_snapshot_upload_receipt_json() {
1095        let receipt = serde_json::from_str::<IcpSnapshotUploadReceipt>(
1096            r#"{
1097  "snapshot_id": "0000000000000000ffffffffffc000020101"
1098}"#,
1099        )
1100        .expect("parse snapshot upload receipt");
1101
1102        assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1103    }
1104
1105    // Ensure current ICP CLI status JSON parses into the typed host shape.
1106    #[test]
1107    fn parses_canister_status_report_json() {
1108        let report = serde_json::from_str::<IcpCanisterStatusReport>(
1109            r#"{
1110  "id": "t63gs-up777-77776-aaaba-cai",
1111  "name": "motoko-ex",
1112  "status": "Running",
1113  "settings": {
1114    "controllers": ["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"],
1115    "compute_allocation": "0"
1116  },
1117  "module_hash": "0x66ce5ddcd06f1135c1a04792a2f1b7c3d9e229b977a8fc9762c71ecc5314c9eb",
1118  "cycles": "1_497_896_187_059"
1119}"#,
1120        )
1121        .expect("parse status report");
1122
1123        assert_eq!(report.status, "Running");
1124        assert_eq!(
1125            report.settings.expect("settings").controllers.as_slice(),
1126            &["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"]
1127        );
1128    }
1129}