Skip to main content

canic_host/icp/
mod.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            add_project_root_override_arg(&mut command, cwd);
204        }
205        command
206    }
207
208    /// Build a base ICP CLI command rooted at one workspace directory.
209    #[must_use]
210    pub fn command_in(&self, cwd: &Path) -> Command {
211        let mut command = Command::new(&self.executable);
212        command.current_dir(cwd);
213        add_project_root_override_arg(&mut command, cwd);
214        command
215    }
216
217    /// Build an `icp canister ...` command with optional environment args applied.
218    #[must_use]
219    pub fn canister_command(&self) -> Command {
220        let mut command = self.command();
221        command.arg("canister");
222        command
223    }
224
225    /// Resolve the installed ICP CLI version.
226    pub fn version(&self) -> Result<String, IcpCommandError> {
227        let mut command = self.command();
228        command.arg("--version");
229        run_output(&mut command)
230    }
231
232    /// Start the local ICP replica.
233    pub fn local_replica_start(
234        &self,
235        background: bool,
236        debug: bool,
237    ) -> Result<String, IcpCommandError> {
238        let mut command = self.local_replica_command("start");
239        run_local_replica_start_command(&mut command, background, debug)
240    }
241
242    /// Start the local ICP replica from one ICP project root.
243    pub fn local_replica_start_in(
244        &self,
245        cwd: &Path,
246        background: bool,
247        debug: bool,
248    ) -> Result<String, IcpCommandError> {
249        let mut command = self.local_replica_command_in("start", cwd);
250        run_local_replica_start_command(&mut command, background, debug)
251    }
252
253    /// Return local ICP replica status.
254    pub fn local_replica_status(&self, debug: bool) -> Result<String, IcpCommandError> {
255        let mut command = self.local_replica_command("status");
256        add_debug_arg(&mut command, debug);
257        run_output_with_stderr(&mut command)
258    }
259
260    /// Return local ICP replica status from one ICP project root.
261    pub fn local_replica_status_in(
262        &self,
263        cwd: &Path,
264        debug: bool,
265    ) -> Result<String, IcpCommandError> {
266        let mut command = self.local_replica_command_in("status", cwd);
267        add_debug_arg(&mut command, debug);
268        run_output_with_stderr(&mut command)
269    }
270
271    /// Return local ICP replica status as the ICP CLI JSON payload.
272    pub fn local_replica_status_json(
273        &self,
274        debug: bool,
275    ) -> Result<serde_json::Value, IcpCommandError> {
276        let mut command = self.local_replica_command("status");
277        add_debug_arg(&mut command, debug);
278        command.arg("--json");
279        run_json(&mut command)
280    }
281
282    /// Return local ICP replica status JSON from one ICP project root.
283    pub fn local_replica_status_json_in(
284        &self,
285        cwd: &Path,
286        debug: bool,
287    ) -> Result<serde_json::Value, IcpCommandError> {
288        let mut command = self.local_replica_command_in("status", cwd);
289        add_debug_arg(&mut command, debug);
290        command.arg("--json");
291        run_json(&mut command)
292    }
293
294    /// Return whether this project owns a running local ICP replica.
295    pub fn local_replica_project_running(&self, debug: bool) -> Result<bool, IcpCommandError> {
296        let mut command = self.local_replica_command("status");
297        add_debug_arg(&mut command, debug);
298        run_success(&mut command)
299    }
300
301    /// Return whether one ICP project root owns a running local ICP replica.
302    pub fn local_replica_project_running_in(
303        &self,
304        cwd: &Path,
305        debug: bool,
306    ) -> Result<bool, IcpCommandError> {
307        let mut command = self.local_replica_command_in("status", cwd);
308        add_debug_arg(&mut command, debug);
309        run_success(&mut command)
310    }
311
312    /// Return whether the local ICP replica responds to ping.
313    pub fn local_replica_ping(&self, debug: bool) -> Result<bool, IcpCommandError> {
314        let mut command = self.local_replica_command("ping");
315        add_debug_arg(&mut command, debug);
316        run_success(&mut command)
317    }
318
319    /// Stop the local ICP replica.
320    pub fn local_replica_stop(&self, debug: bool) -> Result<String, IcpCommandError> {
321        let mut command = self.local_replica_command("stop");
322        add_debug_arg(&mut command, debug);
323        run_output_with_stderr(&mut command)
324    }
325
326    /// Stop the local ICP replica from one ICP project root.
327    pub fn local_replica_stop_in(
328        &self,
329        cwd: &Path,
330        debug: bool,
331    ) -> Result<String, IcpCommandError> {
332        let mut command = self.local_replica_command_in("stop", cwd);
333        add_debug_arg(&mut command, debug);
334        run_output_with_stderr(&mut command)
335    }
336
337    /// Render a local replica start command.
338    #[must_use]
339    pub fn local_replica_start_display(&self, background: bool, debug: bool) -> String {
340        let mut command = self.local_replica_command("start");
341        add_debug_arg(&mut command, debug);
342        if background {
343            command.arg("--background");
344        }
345        command_display(&command)
346    }
347
348    /// Render a local replica status command.
349    #[must_use]
350    pub fn local_replica_status_display(&self, debug: bool) -> String {
351        let mut command = self.local_replica_command("status");
352        add_debug_arg(&mut command, debug);
353        command_display(&command)
354    }
355
356    /// Render a local replica stop command.
357    #[must_use]
358    pub fn local_replica_stop_display(&self, debug: bool) -> String {
359        let mut command = self.local_replica_command("stop");
360        add_debug_arg(&mut command, debug);
361        command_display(&command)
362    }
363
364    fn local_replica_command(&self, action: &str) -> Command {
365        let mut command = self.command();
366        command.args(["network", action]);
367        self.add_local_network_target(&mut command);
368        command
369    }
370
371    fn local_replica_command_in(&self, action: &str, cwd: &Path) -> Command {
372        let mut command = self.command_in(cwd);
373        command.args(["network", action]);
374        self.add_local_network_target(&mut command);
375        command
376    }
377
378    /// Call one canister method with optional JSON output.
379    pub fn canister_call_output(
380        &self,
381        canister: &str,
382        method: &str,
383        output: Option<&str>,
384    ) -> Result<String, IcpCommandError> {
385        let mut command = self.canister_command();
386        command.args(["call", canister, method]);
387        command.arg("()");
388        if let Some(output) = output {
389            add_output_arg(&mut command, output);
390        }
391        self.add_target_args(&mut command);
392        run_output(&mut command)
393    }
394
395    /// Call one canister method with an explicit Candid argument and optional JSON output.
396    pub fn canister_call_arg_output(
397        &self,
398        canister: &str,
399        method: &str,
400        arg: &str,
401        output: Option<&str>,
402    ) -> Result<String, IcpCommandError> {
403        let mut command = self.canister_command();
404        command.args(["call", canister, method]);
405        command.arg(arg);
406        if let Some(output) = output {
407            add_output_arg(&mut command, output);
408        }
409        self.add_target_args(&mut command);
410        run_output(&mut command)
411    }
412
413    /// Query one canister method with no arguments and optional JSON output.
414    pub fn canister_query_output(
415        &self,
416        canister: &str,
417        method: &str,
418        output: Option<&str>,
419    ) -> Result<String, IcpCommandError> {
420        let mut command = self.canister_command();
421        command.args(["call", canister, method]);
422        command.arg("()");
423        command.arg("--query");
424        if let Some(output) = output {
425            add_output_arg(&mut command, output);
426        }
427        self.add_target_args(&mut command);
428        run_output(&mut command)
429    }
430
431    /// Query one canister method with an explicit Candid argument and optional JSON output.
432    pub fn canister_query_arg_output(
433        &self,
434        canister: &str,
435        method: &str,
436        arg: &str,
437        output: Option<&str>,
438    ) -> Result<String, IcpCommandError> {
439        let mut command = self.canister_command();
440        command.args(["call", canister, method]);
441        command.arg(arg);
442        command.arg("--query");
443        if let Some(output) = output {
444            add_output_arg(&mut command, output);
445        }
446        self.add_target_args(&mut command);
447        run_output(&mut command)
448    }
449
450    /// Read one canister metadata section.
451    pub fn canister_metadata_output(
452        &self,
453        canister: &str,
454        metadata_name: &str,
455    ) -> Result<String, IcpCommandError> {
456        let mut command = self.canister_command();
457        command.args(["metadata", canister, metadata_name]);
458        self.add_target_args(&mut command);
459        run_output(&mut command)
460    }
461
462    /// Return one canister status report.
463    pub fn canister_status(&self, canister: &str) -> Result<String, IcpCommandError> {
464        let mut command = self.canister_command();
465        command.args(["status", canister]);
466        self.add_target_args(&mut command);
467        run_output(&mut command)
468    }
469
470    /// Top up one canister with cycles.
471    pub fn canister_top_up_output(
472        &self,
473        canister: &str,
474        amount_cycles: u128,
475    ) -> Result<String, IcpCommandError> {
476        let mut command = self.canister_command();
477        command.args(["top-up", "--amount"]);
478        command.arg(amount_cycles.to_string());
479        command.arg(canister);
480        self.add_target_args(&mut command);
481        run_output_with_stderr(&mut command)
482    }
483
484    /// Return one canister status report from ICP CLI JSON output.
485    pub fn canister_status_report(
486        &self,
487        canister: &str,
488    ) -> Result<IcpCanisterStatusReport, IcpCommandError> {
489        let mut command = self.canister_command();
490        command.args(["status", canister]);
491        command.arg("--json");
492        self.add_target_args(&mut command);
493        run_json(&mut command)
494    }
495
496    /// Create one canister snapshot and return the ICP CLI JSON receipt.
497    pub fn snapshot_create_receipt(
498        &self,
499        canister: &str,
500    ) -> Result<IcpSnapshotCreateReceipt, IcpCommandError> {
501        let mut command = self.canister_command();
502        command.args(["snapshot", "create", canister]);
503        command.arg("--json");
504        self.add_target_args(&mut command);
505        run_json(&mut command)
506    }
507
508    /// Create one canister snapshot and resolve the resulting snapshot id.
509    pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
510        Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
511    }
512
513    /// Stop one canister.
514    pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
515        let mut command = self.canister_command();
516        command.args(["stop", canister]);
517        self.add_target_args(&mut command);
518        run_status(&mut command)
519    }
520
521    /// Start one canister.
522    pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
523        let mut command = self.canister_command();
524        command.args(["start", canister]);
525        self.add_target_args(&mut command);
526        run_status(&mut command)
527    }
528
529    /// Download one canister snapshot into an artifact directory.
530    pub fn snapshot_download(
531        &self,
532        canister: &str,
533        snapshot_id: &str,
534        artifact_path: &Path,
535    ) -> Result<(), IcpCommandError> {
536        let mut command = self.canister_command();
537        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
538        command.arg(artifact_path);
539        self.add_target_args(&mut command);
540        run_status(&mut command)
541    }
542
543    /// Upload one snapshot artifact and return the uploaded snapshot id.
544    pub fn snapshot_upload(
545        &self,
546        canister: &str,
547        artifact_path: &Path,
548    ) -> Result<String, IcpCommandError> {
549        Ok(self
550            .snapshot_upload_receipt(canister, artifact_path)?
551            .snapshot_id)
552    }
553
554    /// Upload one snapshot artifact and return the ICP CLI JSON receipt.
555    pub fn snapshot_upload_receipt(
556        &self,
557        canister: &str,
558        artifact_path: &Path,
559    ) -> Result<IcpSnapshotUploadReceipt, IcpCommandError> {
560        let mut command = self.canister_command();
561        command.args(["snapshot", "upload", canister, "--input"]);
562        command.arg(artifact_path);
563        command.arg("--resume");
564        command.arg("--json");
565        self.add_target_args(&mut command);
566        run_json(&mut command)
567    }
568
569    /// Restore one uploaded snapshot onto a canister.
570    pub fn snapshot_restore(
571        &self,
572        canister: &str,
573        snapshot_id: &str,
574    ) -> Result<(), IcpCommandError> {
575        let mut command = self.canister_command();
576        command.args(["snapshot", "restore", canister, snapshot_id]);
577        self.add_target_args(&mut command);
578        run_status(&mut command)
579    }
580
581    /// Render a dry-run snapshot-create command.
582    #[must_use]
583    pub fn snapshot_create_display(&self, canister: &str) -> String {
584        let mut command = self.canister_command();
585        command.args(["snapshot", "create", canister]);
586        command.arg("--json");
587        self.add_target_args(&mut command);
588        command_display(&command)
589    }
590
591    /// Render a dry-run snapshot-download command.
592    #[must_use]
593    pub fn snapshot_download_display(
594        &self,
595        canister: &str,
596        snapshot_id: &str,
597        artifact_path: &Path,
598    ) -> String {
599        let mut command = self.canister_command();
600        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
601        command.arg(artifact_path);
602        self.add_target_args(&mut command);
603        command_display(&command)
604    }
605
606    /// Render a dry-run snapshot-upload command.
607    #[must_use]
608    pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
609        let mut command = self.canister_command();
610        command.args(["snapshot", "upload", canister, "--input"]);
611        command.arg(artifact_path);
612        command.arg("--resume");
613        command.arg("--json");
614        self.add_target_args(&mut command);
615        command_display(&command)
616    }
617
618    /// Render a dry-run snapshot-restore command.
619    #[must_use]
620    pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
621        let mut command = self.canister_command();
622        command.args(["snapshot", "restore", canister, snapshot_id]);
623        self.add_target_args(&mut command);
624        command_display(&command)
625    }
626
627    /// Render a dry-run stop command.
628    #[must_use]
629    pub fn stop_canister_display(&self, canister: &str) -> String {
630        let mut command = self.canister_command();
631        command.args(["stop", canister]);
632        self.add_target_args(&mut command);
633        command_display(&command)
634    }
635
636    /// Render a dry-run start command.
637    #[must_use]
638    pub fn start_canister_display(&self, canister: &str) -> String {
639        let mut command = self.canister_command();
640        command.args(["start", canister]);
641        self.add_target_args(&mut command);
642        command_display(&command)
643    }
644
645    /// Render a dry-run top-up command.
646    #[must_use]
647    pub fn canister_top_up_display(&self, canister: &str, amount_cycles: u128) -> String {
648        let mut command = self.canister_command();
649        command.args(["top-up", "--amount"]);
650        command.arg(amount_cycles.to_string());
651        command.arg(canister);
652        self.add_target_args(&mut command);
653        command_display(&command)
654    }
655
656    /// Render a dry-run no-argument query call.
657    #[must_use]
658    pub fn canister_query_output_display(
659        &self,
660        canister: &str,
661        method: &str,
662        output: Option<&str>,
663    ) -> String {
664        let mut command = self.canister_command();
665        command.args(["call", canister, method]);
666        command.arg("()");
667        command.arg("--query");
668        if let Some(output) = output {
669            add_output_arg(&mut command, output);
670        }
671        self.add_target_args(&mut command);
672        command_display(&command)
673    }
674
675    /// Render a dry-run update call with an explicit Candid argument.
676    #[must_use]
677    pub fn canister_call_arg_output_display(
678        &self,
679        canister: &str,
680        method: &str,
681        arg: &str,
682        output: Option<&str>,
683    ) -> String {
684        let mut command = self.canister_command();
685        command.args(["call", canister, method]);
686        command.arg(arg);
687        if let Some(output) = output {
688            add_output_arg(&mut command, output);
689        }
690        self.add_target_args(&mut command);
691        command_display(&command)
692    }
693
694    fn add_target_args(&self, command: &mut Command) {
695        add_target_args(command, self.environment(), self.network());
696    }
697
698    fn add_local_network_target(&self, command: &mut Command) {
699        if let Some(environment) = self.environment() {
700            command.args(["-e", environment]);
701        } else if let Some(network) = self.network() {
702            command.arg(network);
703        } else {
704            command.arg(LOCAL_NETWORK);
705        }
706    }
707}
708
709/// Build a base `icp` command with the default executable.
710#[must_use]
711pub fn default_command() -> Command {
712    IcpCli::new("icp", None, None).command()
713}
714
715/// Build a base `icp` command rooted at one workspace directory.
716#[must_use]
717pub fn default_command_in(cwd: &Path) -> Command {
718    IcpCli::new("icp", None, None).command_in(cwd)
719}
720
721/// Add optional ICP CLI target arguments, preferring named environments.
722pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
723    if let Some(environment) = environment {
724        if environment == LOCAL_NETWORK
725            && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
726        {
727            command.env_remove("ICP_ENVIRONMENT");
728            command.arg("-n").arg(url);
729            if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
730                command.arg("-k").arg(root_key);
731            }
732            return;
733        }
734        command.args(["-e", environment]);
735    } else if let Some(network) = network {
736        command.args(["-n", network]);
737    }
738}
739
740/// Add ICP CLI output formatting, handling JSON as its own flag.
741pub fn add_output_arg(command: &mut Command, output: &str) {
742    if output == "json" {
743        command.arg("--json");
744    } else {
745        command.args(["--output", output]);
746    }
747}
748
749/// Add ICP CLI debug logging when requested.
750pub fn add_debug_arg(command: &mut Command, debug: bool) {
751    if debug {
752        command.arg("--debug");
753    }
754}
755
756fn add_project_root_override_arg(command: &mut Command, cwd: &Path) {
757    command.arg("--project-root-override").arg(cwd);
758}
759
760fn run_local_replica_start_command(
761    command: &mut Command,
762    background: bool,
763    debug: bool,
764) -> Result<String, IcpCommandError> {
765    add_debug_arg(command, debug);
766    if background {
767        command.arg("--background");
768        return run_output_with_stderr(command);
769    }
770    run_status_inherit(command)?;
771    Ok(String::new())
772}
773
774/// Execute a command and capture trimmed stdout.
775pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
776    let display = command_display(command);
777    let output = command.output()?;
778    if output.status.success() {
779        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
780    } else {
781        Err(IcpCommandError::Failed {
782            command: display,
783            stderr: command_stderr(&output),
784        })
785    }
786}
787
788/// Execute a command and capture stdout plus stderr on success.
789pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
790    let display = command_display(command);
791    let output = command.output()?;
792    if output.status.success() {
793        let mut text = String::from_utf8_lossy(&output.stdout).to_string();
794        text.push_str(&String::from_utf8_lossy(&output.stderr));
795        Ok(text.trim().to_string())
796    } else {
797        Err(IcpCommandError::Failed {
798            command: display,
799            stderr: command_stderr(&output),
800        })
801    }
802}
803
804/// Execute a command and parse successful stdout as JSON.
805pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
806where
807    T: serde::de::DeserializeOwned,
808{
809    let display = command_display(command);
810    let output = command.output()?;
811    if output.status.success() {
812        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
813        serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
814            command: display,
815            output: stdout,
816            source,
817        })
818    } else {
819        Err(IcpCommandError::Failed {
820            command: display,
821            stderr: command_stderr(&output),
822        })
823    }
824}
825
826/// Execute a command and require a successful status.
827pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
828    let display = command_display(command);
829    let output = command.output()?;
830    if output.status.success() {
831        Ok(())
832    } else {
833        Err(IcpCommandError::Failed {
834            command: display,
835            stderr: command_stderr(&output),
836        })
837    }
838}
839
840/// Execute a command with inherited terminal I/O and require a successful status.
841pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
842    let display = command_display(command);
843    let mut child = command
844        .stdout(Stdio::inherit())
845        .stderr(Stdio::piped())
846        .spawn()?;
847    let stderr_handle = child
848        .stderr
849        .take()
850        .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
851    let status = child.wait()?;
852    let stderr = match stderr_handle {
853        Some(handle) => match handle.join() {
854            Ok(result) => result?,
855            Err(_) => Vec::new(),
856        },
857        None => Vec::new(),
858    };
859    if status.success() {
860        Ok(())
861    } else {
862        let stderr = if stderr.is_empty() {
863            format!("command exited with status {}", exit_status_label(status))
864        } else {
865            String::from_utf8_lossy(&stderr).to_string()
866        };
867        Err(IcpCommandError::Failed {
868            command: display,
869            stderr,
870        })
871    }
872}
873
874fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
875    let mut captured = Vec::new();
876    let mut buffer = [0_u8; 8192];
877    let mut terminal = io::stderr().lock();
878    loop {
879        let read = stderr.read(&mut buffer)?;
880        if read == 0 {
881            break;
882        }
883        terminal.write_all(&buffer[..read])?;
884        captured.extend_from_slice(&buffer[..read]);
885    }
886    terminal.flush()?;
887    Ok(captured)
888}
889
890/// Execute a command and return whether it exits successfully.
891pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
892    Ok(command.output()?.status.success())
893}
894
895/// Execute a rendered ICP CLI command and return raw process output.
896pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
897    let output = Command::new(program).args(args).output()?;
898    Ok(IcpRawOutput {
899        success: output.status.success(),
900        status: exit_status_label(output.status),
901        stdout: output.stdout,
902        stderr: output.stderr,
903    })
904}
905
906/// Render a command for diagnostics and dry-run previews.
907#[must_use]
908pub fn command_display(command: &Command) -> String {
909    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
910    parts.extend(
911        command
912            .get_args()
913            .map(|arg| arg.to_string_lossy().to_string()),
914    );
915    parts.join(" ")
916}
917
918/// Parse a likely snapshot id from `icp canister snapshot create` output.
919#[must_use]
920pub fn parse_snapshot_id(output: &str) -> Option<String> {
921    let trimmed = output.trim();
922    if is_snapshot_id_token(trimmed) {
923        return Some(trimmed.to_string());
924    }
925
926    output
927        .lines()
928        .flat_map(|line| {
929            line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
930        })
931        .find(|part| is_snapshot_id_token(part))
932        .map(str::to_string)
933}
934
935// ICP snapshot ids are rendered as even-length hexadecimal blobs.
936fn is_snapshot_id_token(value: &str) -> bool {
937    !value.is_empty()
938        && value.len().is_multiple_of(2)
939        && value.chars().all(|c| c.is_ascii_hexdigit())
940}
941
942// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
943fn command_stderr(output: &std::process::Output) -> String {
944    let stderr = String::from_utf8_lossy(&output.stderr);
945    if stderr.trim().is_empty() {
946        String::from_utf8_lossy(&output.stdout).to_string()
947    } else {
948        stderr.to_string()
949    }
950}
951
952// Render process exit status without relying on platform-specific internals.
953fn exit_status_label(status: std::process::ExitStatus) -> String {
954    status
955        .code()
956        .map_or_else(|| "signal".to_string(), |code| code.to_string())
957}
958
959#[cfg(test)]
960mod tests;