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    /// Return one canister status report from ICP CLI JSON output.
467    pub fn canister_status_report(
468        &self,
469        canister: &str,
470    ) -> Result<IcpCanisterStatusReport, IcpCommandError> {
471        let mut command = self.canister_command();
472        command.args(["status", canister]);
473        command.arg("--json");
474        self.add_target_args(&mut command);
475        run_json(&mut command)
476    }
477
478    /// Create one canister snapshot and return the ICP CLI JSON receipt.
479    pub fn snapshot_create_receipt(
480        &self,
481        canister: &str,
482    ) -> Result<IcpSnapshotCreateReceipt, IcpCommandError> {
483        let mut command = self.canister_command();
484        command.args(["snapshot", "create", canister]);
485        command.arg("--json");
486        self.add_target_args(&mut command);
487        run_json(&mut command)
488    }
489
490    /// Create one canister snapshot and resolve the resulting snapshot id.
491    pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
492        Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
493    }
494
495    /// Stop one canister.
496    pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
497        let mut command = self.canister_command();
498        command.args(["stop", canister]);
499        self.add_target_args(&mut command);
500        run_status(&mut command)
501    }
502
503    /// Start one canister.
504    pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
505        let mut command = self.canister_command();
506        command.args(["start", canister]);
507        self.add_target_args(&mut command);
508        run_status(&mut command)
509    }
510
511    /// Download one canister snapshot into an artifact directory.
512    pub fn snapshot_download(
513        &self,
514        canister: &str,
515        snapshot_id: &str,
516        artifact_path: &Path,
517    ) -> Result<(), IcpCommandError> {
518        let mut command = self.canister_command();
519        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
520        command.arg(artifact_path);
521        self.add_target_args(&mut command);
522        run_status(&mut command)
523    }
524
525    /// Upload one snapshot artifact and return the uploaded snapshot id.
526    pub fn snapshot_upload(
527        &self,
528        canister: &str,
529        artifact_path: &Path,
530    ) -> Result<String, IcpCommandError> {
531        Ok(self
532            .snapshot_upload_receipt(canister, artifact_path)?
533            .snapshot_id)
534    }
535
536    /// Upload one snapshot artifact and return the ICP CLI JSON receipt.
537    pub fn snapshot_upload_receipt(
538        &self,
539        canister: &str,
540        artifact_path: &Path,
541    ) -> Result<IcpSnapshotUploadReceipt, IcpCommandError> {
542        let mut command = self.canister_command();
543        command.args(["snapshot", "upload", canister, "--input"]);
544        command.arg(artifact_path);
545        command.arg("--resume");
546        command.arg("--json");
547        self.add_target_args(&mut command);
548        run_json(&mut command)
549    }
550
551    /// Restore one uploaded snapshot onto a canister.
552    pub fn snapshot_restore(
553        &self,
554        canister: &str,
555        snapshot_id: &str,
556    ) -> Result<(), IcpCommandError> {
557        let mut command = self.canister_command();
558        command.args(["snapshot", "restore", canister, snapshot_id]);
559        self.add_target_args(&mut command);
560        run_status(&mut command)
561    }
562
563    /// Render a dry-run snapshot-create command.
564    #[must_use]
565    pub fn snapshot_create_display(&self, canister: &str) -> String {
566        let mut command = self.canister_command();
567        command.args(["snapshot", "create", canister]);
568        command.arg("--json");
569        self.add_target_args(&mut command);
570        command_display(&command)
571    }
572
573    /// Render a dry-run snapshot-download command.
574    #[must_use]
575    pub fn snapshot_download_display(
576        &self,
577        canister: &str,
578        snapshot_id: &str,
579        artifact_path: &Path,
580    ) -> String {
581        let mut command = self.canister_command();
582        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
583        command.arg(artifact_path);
584        self.add_target_args(&mut command);
585        command_display(&command)
586    }
587
588    /// Render a dry-run snapshot-upload command.
589    #[must_use]
590    pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
591        let mut command = self.canister_command();
592        command.args(["snapshot", "upload", canister, "--input"]);
593        command.arg(artifact_path);
594        command.arg("--resume");
595        command.arg("--json");
596        self.add_target_args(&mut command);
597        command_display(&command)
598    }
599
600    /// Render a dry-run snapshot-restore command.
601    #[must_use]
602    pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
603        let mut command = self.canister_command();
604        command.args(["snapshot", "restore", canister, snapshot_id]);
605        self.add_target_args(&mut command);
606        command_display(&command)
607    }
608
609    /// Render a dry-run stop command.
610    #[must_use]
611    pub fn stop_canister_display(&self, canister: &str) -> String {
612        let mut command = self.canister_command();
613        command.args(["stop", canister]);
614        self.add_target_args(&mut command);
615        command_display(&command)
616    }
617
618    /// Render a dry-run start command.
619    #[must_use]
620    pub fn start_canister_display(&self, canister: &str) -> String {
621        let mut command = self.canister_command();
622        command.args(["start", canister]);
623        self.add_target_args(&mut command);
624        command_display(&command)
625    }
626
627    /// Render a dry-run no-argument query call.
628    #[must_use]
629    pub fn canister_query_output_display(
630        &self,
631        canister: &str,
632        method: &str,
633        output: Option<&str>,
634    ) -> String {
635        let mut command = self.canister_command();
636        command.args(["call", canister, method]);
637        command.arg("()");
638        command.arg("--query");
639        if let Some(output) = output {
640            add_output_arg(&mut command, output);
641        }
642        self.add_target_args(&mut command);
643        command_display(&command)
644    }
645
646    fn add_target_args(&self, command: &mut Command) {
647        add_target_args(command, self.environment(), self.network());
648    }
649}
650
651/// Build a base `icp` command with the default executable.
652#[must_use]
653pub fn default_command() -> Command {
654    IcpCli::new("icp", None, None).command()
655}
656
657/// Build a base `icp` command rooted at one workspace directory.
658#[must_use]
659pub fn default_command_in(cwd: &Path) -> Command {
660    IcpCli::new("icp", None, None).command_in(cwd)
661}
662
663/// Add optional ICP CLI target arguments, preferring named environments.
664pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
665    if let Some(environment) = environment {
666        if environment == LOCAL_NETWORK
667            && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
668        {
669            command.env_remove("ICP_ENVIRONMENT");
670            command.arg("-n").arg(url);
671            if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
672                command.arg("-k").arg(root_key);
673            }
674            return;
675        }
676        command.args(["-e", environment]);
677    } else if let Some(network) = network {
678        command.args(["-n", network]);
679    }
680}
681
682/// Add ICP CLI output formatting, handling JSON as its own flag.
683pub fn add_output_arg(command: &mut Command, output: &str) {
684    if output == "json" {
685        command.arg("--json");
686    } else {
687        command.args(["--output", output]);
688    }
689}
690
691/// Add ICP CLI debug logging when requested.
692pub fn add_debug_arg(command: &mut Command, debug: bool) {
693    if debug {
694        command.arg("--debug");
695    }
696}
697
698fn run_local_replica_start_command(
699    command: &mut Command,
700    background: bool,
701    debug: bool,
702) -> Result<String, IcpCommandError> {
703    add_debug_arg(command, debug);
704    if background {
705        command.arg("--background");
706        return run_output_with_stderr(command);
707    }
708    run_status_inherit(command)?;
709    Ok(String::new())
710}
711
712/// Execute a command and capture trimmed stdout.
713pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
714    let display = command_display(command);
715    let output = command.output()?;
716    if output.status.success() {
717        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
718    } else {
719        Err(IcpCommandError::Failed {
720            command: display,
721            stderr: command_stderr(&output),
722        })
723    }
724}
725
726/// Execute a command and capture stdout plus stderr on success.
727pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
728    let display = command_display(command);
729    let output = command.output()?;
730    if output.status.success() {
731        let mut text = String::from_utf8_lossy(&output.stdout).to_string();
732        text.push_str(&String::from_utf8_lossy(&output.stderr));
733        Ok(text.trim().to_string())
734    } else {
735        Err(IcpCommandError::Failed {
736            command: display,
737            stderr: command_stderr(&output),
738        })
739    }
740}
741
742/// Execute a command and parse successful stdout as JSON.
743pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
744where
745    T: serde::de::DeserializeOwned,
746{
747    let display = command_display(command);
748    let output = command.output()?;
749    if output.status.success() {
750        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
751        serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
752            command: display,
753            output: stdout,
754            source,
755        })
756    } else {
757        Err(IcpCommandError::Failed {
758            command: display,
759            stderr: command_stderr(&output),
760        })
761    }
762}
763
764/// Execute a command and require a successful status.
765pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
766    let display = command_display(command);
767    let output = command.output()?;
768    if output.status.success() {
769        Ok(())
770    } else {
771        Err(IcpCommandError::Failed {
772            command: display,
773            stderr: command_stderr(&output),
774        })
775    }
776}
777
778/// Execute a command with inherited terminal I/O and require a successful status.
779pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
780    let display = command_display(command);
781    let mut child = command
782        .stdout(Stdio::inherit())
783        .stderr(Stdio::piped())
784        .spawn()?;
785    let stderr_handle = child
786        .stderr
787        .take()
788        .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
789    let status = child.wait()?;
790    let stderr = match stderr_handle {
791        Some(handle) => match handle.join() {
792            Ok(result) => result?,
793            Err(_) => Vec::new(),
794        },
795        None => Vec::new(),
796    };
797    if status.success() {
798        Ok(())
799    } else {
800        let stderr = if stderr.is_empty() {
801            format!("command exited with status {}", exit_status_label(status))
802        } else {
803            String::from_utf8_lossy(&stderr).to_string()
804        };
805        Err(IcpCommandError::Failed {
806            command: display,
807            stderr,
808        })
809    }
810}
811
812fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
813    let mut captured = Vec::new();
814    let mut buffer = [0_u8; 8192];
815    let mut terminal = io::stderr().lock();
816    loop {
817        let read = stderr.read(&mut buffer)?;
818        if read == 0 {
819            break;
820        }
821        terminal.write_all(&buffer[..read])?;
822        captured.extend_from_slice(&buffer[..read]);
823    }
824    terminal.flush()?;
825    Ok(captured)
826}
827
828/// Execute a command and return whether it exits successfully.
829pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
830    Ok(command.output()?.status.success())
831}
832
833/// Execute a rendered ICP CLI command and return raw process output.
834pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
835    let output = Command::new(program).args(args).output()?;
836    Ok(IcpRawOutput {
837        success: output.status.success(),
838        status: exit_status_label(output.status),
839        stdout: output.stdout,
840        stderr: output.stderr,
841    })
842}
843
844/// Render a command for diagnostics and dry-run previews.
845#[must_use]
846pub fn command_display(command: &Command) -> String {
847    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
848    parts.extend(
849        command
850            .get_args()
851            .map(|arg| arg.to_string_lossy().to_string()),
852    );
853    parts.join(" ")
854}
855
856/// Parse a likely snapshot id from `icp canister snapshot create` output.
857#[must_use]
858pub fn parse_snapshot_id(output: &str) -> Option<String> {
859    let trimmed = output.trim();
860    if is_snapshot_id_token(trimmed) {
861        return Some(trimmed.to_string());
862    }
863
864    output
865        .lines()
866        .flat_map(|line| {
867            line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
868        })
869        .find(|part| is_snapshot_id_token(part))
870        .map(str::to_string)
871}
872
873// ICP snapshot ids are rendered as even-length hexadecimal blobs.
874fn is_snapshot_id_token(value: &str) -> bool {
875    !value.is_empty()
876        && value.len().is_multiple_of(2)
877        && value.chars().all(|c| c.is_ascii_hexdigit())
878}
879
880// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
881fn command_stderr(output: &std::process::Output) -> String {
882    let stderr = String::from_utf8_lossy(&output.stderr);
883    if stderr.trim().is_empty() {
884        String::from_utf8_lossy(&output.stdout).to_string()
885    } else {
886        stderr.to_string()
887    }
888}
889
890// Render process exit status without relying on platform-specific internals.
891fn exit_status_label(status: std::process::ExitStatus) -> String {
892    status
893        .code()
894        .map_or_else(|| "signal".to_string(), |code| code.to_string())
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900
901    // Keep generated commands tied to ICP CLI environments when one is selected.
902    #[test]
903    fn renders_environment_target() {
904        let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
905
906        assert_eq!(
907            icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
908            "icp canister snapshot download root snap-1 --output backups/root -e staging"
909        );
910    }
911
912    // Keep direct network targeting available for local and ad hoc command contexts.
913    #[test]
914    fn renders_network_target() {
915        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
916
917        assert_eq!(
918            icp.snapshot_create_display("aaaaa-aa"),
919            "icp canister snapshot create aaaaa-aa --json -n ic"
920        );
921    }
922
923    // Keep local replica lifecycle commands explicit and project-scoped.
924    #[test]
925    fn renders_local_replica_commands() {
926        let icp = IcpCli::new("icp", None, None);
927
928        assert_eq!(
929            icp.local_replica_start_display(true, false),
930            "icp network start local --background"
931        );
932        assert_eq!(
933            icp.local_replica_start_display(false, false),
934            "icp network start local"
935        );
936        assert_eq!(
937            icp.local_replica_start_display(false, true),
938            "icp network start local --debug"
939        );
940        assert_eq!(
941            icp.local_replica_status_display(false),
942            "icp network status local"
943        );
944        assert_eq!(
945            icp.local_replica_status_display(true),
946            "icp network status local --debug"
947        );
948        assert_eq!(
949            icp.local_replica_stop_display(false),
950            "icp network stop local"
951        );
952        assert_eq!(
953            icp.local_replica_stop_display(true),
954            "icp network stop local --debug"
955        );
956    }
957
958    // Ensure restore planning uses the ICP CLI upload/restore flow.
959    #[test]
960    fn renders_snapshot_restore_flow() {
961        let icp = IcpCli::new("icp", Some("prod".to_string()), None);
962
963        assert_eq!(
964            icp.snapshot_upload_display("root", Path::new("artifact")),
965            "icp canister snapshot upload root --input artifact --resume --json -e prod"
966        );
967        assert_eq!(
968            icp.snapshot_restore_display("root", "uploaded-1"),
969            "icp canister snapshot restore root uploaded-1 -e prod"
970        );
971    }
972
973    // Ensure query helpers do not accidentally issue update calls for read-only endpoint probes.
974    #[test]
975    fn renders_no_argument_query_call() {
976        let icp = IcpCli::new("icp", None, Some("ic".to_string()));
977
978        assert_eq!(
979            icp.canister_query_output_display("root", "canic_ready", Some("json")),
980            "icp canister call root canic_ready () --query --json -n ic"
981        );
982    }
983
984    // Ensure snapshot ids can be extracted from common create output.
985    #[test]
986    fn parses_snapshot_id_from_output() {
987        let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
988
989        assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
990    }
991
992    // Ensure table units are not mistaken for snapshot ids.
993    #[test]
994    fn parses_snapshot_id_from_table_output() {
995        let output = "\
996ID         SIZE       CREATED_AT
9970a0b0c0d   1.37 MiB   2026-05-10T17:04:19Z
998";
999
1000        let snapshot_id = parse_snapshot_id(output);
1001
1002        assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1003    }
1004
1005    // Ensure current ICP CLI snapshot JSON receipts parse into the typed host shape.
1006    #[test]
1007    fn parses_snapshot_create_receipt_json() {
1008        let receipt = serde_json::from_str::<IcpSnapshotCreateReceipt>(
1009            r#"{
1010  "snapshot_id": "0000000000000000ffffffffffc000020101",
1011  "taken_at_timestamp": 1778709681897818005,
1012  "total_size_bytes": 272586987
1013}"#,
1014        )
1015        .expect("parse snapshot receipt");
1016
1017        assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1018        assert_eq!(receipt.total_size_bytes, Some(272_586_987));
1019    }
1020
1021    // Ensure current ICP CLI snapshot upload JSON receipts parse into the typed host shape.
1022    #[test]
1023    fn parses_snapshot_upload_receipt_json() {
1024        let receipt = serde_json::from_str::<IcpSnapshotUploadReceipt>(
1025            r#"{
1026  "snapshot_id": "0000000000000000ffffffffffc000020101"
1027}"#,
1028        )
1029        .expect("parse snapshot upload receipt");
1030
1031        assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1032    }
1033
1034    // Ensure current ICP CLI status JSON parses into the typed host shape.
1035    #[test]
1036    fn parses_canister_status_report_json() {
1037        let report = serde_json::from_str::<IcpCanisterStatusReport>(
1038            r#"{
1039  "id": "t63gs-up777-77776-aaaba-cai",
1040  "name": "motoko-ex",
1041  "status": "Running",
1042  "settings": {
1043    "controllers": ["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"],
1044    "compute_allocation": "0"
1045  },
1046  "module_hash": "0x66ce5ddcd06f1135c1a04792a2f1b7c3d9e229b977a8fc9762c71ecc5314c9eb",
1047  "cycles": "1_497_896_187_059"
1048}"#,
1049        )
1050        .expect("parse status report");
1051
1052        assert_eq!(report.status, "Running");
1053        assert_eq!(
1054            report.settings.expect("settings").controllers.as_slice(),
1055            &["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"]
1056        );
1057    }
1058}