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