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    fn add_target_args(&self, command: &mut Command) {
672        add_target_args(command, self.environment(), self.network());
673    }
674}
675
676/// Build a base `icp` command with the default executable.
677#[must_use]
678pub fn default_command() -> Command {
679    IcpCli::new("icp", None, None).command()
680}
681
682/// Build a base `icp` command rooted at one workspace directory.
683#[must_use]
684pub fn default_command_in(cwd: &Path) -> Command {
685    IcpCli::new("icp", None, None).command_in(cwd)
686}
687
688/// Add optional ICP CLI target arguments, preferring named environments.
689pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
690    if let Some(environment) = environment {
691        if environment == LOCAL_NETWORK
692            && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
693        {
694            command.env_remove("ICP_ENVIRONMENT");
695            command.arg("-n").arg(url);
696            if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
697                command.arg("-k").arg(root_key);
698            }
699            return;
700        }
701        command.args(["-e", environment]);
702    } else if let Some(network) = network {
703        command.args(["-n", network]);
704    }
705}
706
707/// Add ICP CLI output formatting, handling JSON as its own flag.
708pub fn add_output_arg(command: &mut Command, output: &str) {
709    if output == "json" {
710        command.arg("--json");
711    } else {
712        command.args(["--output", output]);
713    }
714}
715
716/// Add ICP CLI debug logging when requested.
717pub fn add_debug_arg(command: &mut Command, debug: bool) {
718    if debug {
719        command.arg("--debug");
720    }
721}
722
723fn run_local_replica_start_command(
724    command: &mut Command,
725    background: bool,
726    debug: bool,
727) -> Result<String, IcpCommandError> {
728    add_debug_arg(command, debug);
729    if background {
730        command.arg("--background");
731        return run_output_with_stderr(command);
732    }
733    run_status_inherit(command)?;
734    Ok(String::new())
735}
736
737/// Execute a command and capture trimmed stdout.
738pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
739    let display = command_display(command);
740    let output = command.output()?;
741    if output.status.success() {
742        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
743    } else {
744        Err(IcpCommandError::Failed {
745            command: display,
746            stderr: command_stderr(&output),
747        })
748    }
749}
750
751/// Execute a command and capture stdout plus stderr on success.
752pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
753    let display = command_display(command);
754    let output = command.output()?;
755    if output.status.success() {
756        let mut text = String::from_utf8_lossy(&output.stdout).to_string();
757        text.push_str(&String::from_utf8_lossy(&output.stderr));
758        Ok(text.trim().to_string())
759    } else {
760        Err(IcpCommandError::Failed {
761            command: display,
762            stderr: command_stderr(&output),
763        })
764    }
765}
766
767/// Execute a command and parse successful stdout as JSON.
768pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
769where
770    T: serde::de::DeserializeOwned,
771{
772    let display = command_display(command);
773    let output = command.output()?;
774    if output.status.success() {
775        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
776        serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
777            command: display,
778            output: stdout,
779            source,
780        })
781    } else {
782        Err(IcpCommandError::Failed {
783            command: display,
784            stderr: command_stderr(&output),
785        })
786    }
787}
788
789/// Execute a command and require a successful status.
790pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
791    let display = command_display(command);
792    let output = command.output()?;
793    if output.status.success() {
794        Ok(())
795    } else {
796        Err(IcpCommandError::Failed {
797            command: display,
798            stderr: command_stderr(&output),
799        })
800    }
801}
802
803/// Execute a command with inherited terminal I/O and require a successful status.
804pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
805    let display = command_display(command);
806    let mut child = command
807        .stdout(Stdio::inherit())
808        .stderr(Stdio::piped())
809        .spawn()?;
810    let stderr_handle = child
811        .stderr
812        .take()
813        .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
814    let status = child.wait()?;
815    let stderr = match stderr_handle {
816        Some(handle) => match handle.join() {
817            Ok(result) => result?,
818            Err(_) => Vec::new(),
819        },
820        None => Vec::new(),
821    };
822    if status.success() {
823        Ok(())
824    } else {
825        let stderr = if stderr.is_empty() {
826            format!("command exited with status {}", exit_status_label(status))
827        } else {
828            String::from_utf8_lossy(&stderr).to_string()
829        };
830        Err(IcpCommandError::Failed {
831            command: display,
832            stderr,
833        })
834    }
835}
836
837fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
838    let mut captured = Vec::new();
839    let mut buffer = [0_u8; 8192];
840    let mut terminal = io::stderr().lock();
841    loop {
842        let read = stderr.read(&mut buffer)?;
843        if read == 0 {
844            break;
845        }
846        terminal.write_all(&buffer[..read])?;
847        captured.extend_from_slice(&buffer[..read]);
848    }
849    terminal.flush()?;
850    Ok(captured)
851}
852
853/// Execute a command and return whether it exits successfully.
854pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
855    Ok(command.output()?.status.success())
856}
857
858/// Execute a rendered ICP CLI command and return raw process output.
859pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
860    let output = Command::new(program).args(args).output()?;
861    Ok(IcpRawOutput {
862        success: output.status.success(),
863        status: exit_status_label(output.status),
864        stdout: output.stdout,
865        stderr: output.stderr,
866    })
867}
868
869/// Render a command for diagnostics and dry-run previews.
870#[must_use]
871pub fn command_display(command: &Command) -> String {
872    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
873    parts.extend(
874        command
875            .get_args()
876            .map(|arg| arg.to_string_lossy().to_string()),
877    );
878    parts.join(" ")
879}
880
881/// Parse a likely snapshot id from `icp canister snapshot create` output.
882#[must_use]
883pub fn parse_snapshot_id(output: &str) -> Option<String> {
884    let trimmed = output.trim();
885    if is_snapshot_id_token(trimmed) {
886        return Some(trimmed.to_string());
887    }
888
889    output
890        .lines()
891        .flat_map(|line| {
892            line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
893        })
894        .find(|part| is_snapshot_id_token(part))
895        .map(str::to_string)
896}
897
898// ICP snapshot ids are rendered as even-length hexadecimal blobs.
899fn is_snapshot_id_token(value: &str) -> bool {
900    !value.is_empty()
901        && value.len().is_multiple_of(2)
902        && value.chars().all(|c| c.is_ascii_hexdigit())
903}
904
905// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
906fn command_stderr(output: &std::process::Output) -> String {
907    let stderr = String::from_utf8_lossy(&output.stderr);
908    if stderr.trim().is_empty() {
909        String::from_utf8_lossy(&output.stdout).to_string()
910    } else {
911        stderr.to_string()
912    }
913}
914
915// Render process exit status without relying on platform-specific internals.
916fn exit_status_label(status: std::process::ExitStatus) -> String {
917    status
918        .code()
919        .map_or_else(|| "signal".to_string(), |code| code.to_string())
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925
926    // Keep generated commands tied to ICP CLI environments when one is selected.
927    #[test]
928    fn renders_environment_target() {
929        let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
930
931        assert_eq!(
932            icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
933            "icp canister snapshot download root snap-1 --output backups/root -e staging"
934        );
935    }
936
937    // Keep direct network targeting available for local and ad hoc command contexts.
938    #[test]
939    fn renders_network_target() {
940        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
941
942        assert_eq!(
943            icp.snapshot_create_display("aaaaa-aa"),
944            "icp canister snapshot create aaaaa-aa --json -n ic"
945        );
946    }
947
948    // Keep local replica lifecycle commands explicit and project-scoped.
949    #[test]
950    fn renders_local_replica_commands() {
951        let icp = IcpCli::new("icp", None, None);
952
953        assert_eq!(
954            icp.local_replica_start_display(true, false),
955            "icp network start local --background"
956        );
957        assert_eq!(
958            icp.local_replica_start_display(false, false),
959            "icp network start local"
960        );
961        assert_eq!(
962            icp.local_replica_start_display(false, true),
963            "icp network start local --debug"
964        );
965        assert_eq!(
966            icp.local_replica_status_display(false),
967            "icp network status local"
968        );
969        assert_eq!(
970            icp.local_replica_status_display(true),
971            "icp network status local --debug"
972        );
973        assert_eq!(
974            icp.local_replica_stop_display(false),
975            "icp network stop local"
976        );
977        assert_eq!(
978            icp.local_replica_stop_display(true),
979            "icp network stop local --debug"
980        );
981    }
982
983    // Ensure restore planning uses the ICP CLI upload/restore flow.
984    #[test]
985    fn renders_snapshot_restore_flow() {
986        let icp = IcpCli::new("icp", Some("prod".to_string()), None);
987
988        assert_eq!(
989            icp.snapshot_upload_display("root", Path::new("artifact")),
990            "icp canister snapshot upload root --input artifact --resume --json -e prod"
991        );
992        assert_eq!(
993            icp.snapshot_restore_display("root", "uploaded-1"),
994            "icp canister snapshot restore root uploaded-1 -e prod"
995        );
996    }
997
998    // Ensure query helpers do not accidentally issue update calls for read-only endpoint probes.
999    #[test]
1000    fn renders_no_argument_query_call() {
1001        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1002
1003        assert_eq!(
1004            icp.canister_query_output_display("root", "canic_ready", Some("json")),
1005            "icp canister call root canic_ready () --query --json -n ic"
1006        );
1007    }
1008
1009    // Ensure manual top-ups use the ICP CLI top-up command and selected network.
1010    #[test]
1011    fn renders_canister_top_up() {
1012        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1013
1014        assert_eq!(
1015            icp.canister_top_up_display("aaaaa-aa", 4_000_000_000_000),
1016            "icp canister top-up --amount 4000000000000 aaaaa-aa -n ic"
1017        );
1018    }
1019
1020    // Ensure snapshot ids can be extracted from common create output.
1021    #[test]
1022    fn parses_snapshot_id_from_output() {
1023        let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
1024
1025        assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1026    }
1027
1028    // Ensure table units are not mistaken for snapshot ids.
1029    #[test]
1030    fn parses_snapshot_id_from_table_output() {
1031        let output = "\
1032ID         SIZE       CREATED_AT
10330a0b0c0d   1.37 MiB   2026-05-10T17:04:19Z
1034";
1035
1036        let snapshot_id = parse_snapshot_id(output);
1037
1038        assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1039    }
1040
1041    // Ensure current ICP CLI snapshot JSON receipts parse into the typed host shape.
1042    #[test]
1043    fn parses_snapshot_create_receipt_json() {
1044        let receipt = serde_json::from_str::<IcpSnapshotCreateReceipt>(
1045            r#"{
1046  "snapshot_id": "0000000000000000ffffffffffc000020101",
1047  "taken_at_timestamp": 1778709681897818005,
1048  "total_size_bytes": 272586987
1049}"#,
1050        )
1051        .expect("parse snapshot receipt");
1052
1053        assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1054        assert_eq!(receipt.total_size_bytes, Some(272_586_987));
1055    }
1056
1057    // Ensure current ICP CLI snapshot upload JSON receipts parse into the typed host shape.
1058    #[test]
1059    fn parses_snapshot_upload_receipt_json() {
1060        let receipt = serde_json::from_str::<IcpSnapshotUploadReceipt>(
1061            r#"{
1062  "snapshot_id": "0000000000000000ffffffffffc000020101"
1063}"#,
1064        )
1065        .expect("parse snapshot upload receipt");
1066
1067        assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1068    }
1069
1070    // Ensure current ICP CLI status JSON parses into the typed host shape.
1071    #[test]
1072    fn parses_canister_status_report_json() {
1073        let report = serde_json::from_str::<IcpCanisterStatusReport>(
1074            r#"{
1075  "id": "t63gs-up777-77776-aaaba-cai",
1076  "name": "motoko-ex",
1077  "status": "Running",
1078  "settings": {
1079    "controllers": ["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"],
1080    "compute_allocation": "0"
1081  },
1082  "module_hash": "0x66ce5ddcd06f1135c1a04792a2f1b7c3d9e229b977a8fc9762c71ecc5314c9eb",
1083  "cycles": "1_497_896_187_059"
1084}"#,
1085        )
1086        .expect("parse status report");
1087
1088        assert_eq!(report.status, "Running");
1089        assert_eq!(
1090            report.settings.expect("settings").controllers.as_slice(),
1091            &["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"]
1092        );
1093    }
1094}