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.2";
15pub const ICP_CLI_SUPPORTED_VERSION_RANGE: &str = ">=0.3.2, <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        self.canister_call_output_with_candid(canister, method, output, None)
430    }
431
432    /// Call one canister method with optional local Candid and JSON output.
433    pub fn canister_call_output_with_candid(
434        &self,
435        canister: &str,
436        method: &str,
437        output: Option<&str>,
438        candid_path: Option<&Path>,
439    ) -> Result<String, IcpCommandError> {
440        let mut command = self.canister_command();
441        command.args(["call", canister, method]);
442        command.arg("()");
443        add_candid_arg(&mut command, candid_path);
444        if let Some(output) = output {
445            add_output_arg(&mut command, output);
446        }
447        self.add_target_args(&mut command);
448        run_output(&mut command)
449    }
450
451    /// Call one canister method with an explicit Candid argument and optional JSON output.
452    pub fn canister_call_arg_output(
453        &self,
454        canister: &str,
455        method: &str,
456        arg: &str,
457        output: Option<&str>,
458    ) -> Result<String, IcpCommandError> {
459        self.canister_call_arg_output_with_candid(canister, method, arg, output, None)
460    }
461
462    /// Call one canister method with an explicit Candid argument, optional local Candid, and optional JSON output.
463    pub fn canister_call_arg_output_with_candid(
464        &self,
465        canister: &str,
466        method: &str,
467        arg: &str,
468        output: Option<&str>,
469        candid_path: Option<&Path>,
470    ) -> Result<String, IcpCommandError> {
471        let mut command = self.canister_command();
472        command.args(["call", canister, method]);
473        command.arg(arg);
474        add_candid_arg(&mut command, candid_path);
475        if let Some(output) = output {
476            add_output_arg(&mut command, output);
477        }
478        self.add_target_args(&mut command);
479        run_output(&mut command)
480    }
481
482    /// Query one canister method with no arguments and optional JSON output.
483    pub fn canister_query_output(
484        &self,
485        canister: &str,
486        method: &str,
487        output: Option<&str>,
488    ) -> Result<String, IcpCommandError> {
489        self.canister_query_output_with_candid(canister, method, output, None)
490    }
491
492    /// Query one canister method with no arguments, optional local Candid, and optional JSON output.
493    pub fn canister_query_output_with_candid(
494        &self,
495        canister: &str,
496        method: &str,
497        output: Option<&str>,
498        candid_path: Option<&Path>,
499    ) -> Result<String, IcpCommandError> {
500        let mut command = self.canister_command();
501        command.args(["call", canister, method]);
502        command.arg("()");
503        command.arg("--query");
504        add_candid_arg(&mut command, candid_path);
505        if let Some(output) = output {
506            add_output_arg(&mut command, output);
507        }
508        self.add_target_args(&mut command);
509        run_output(&mut command)
510    }
511
512    /// Query one canister method with an explicit Candid argument and optional JSON output.
513    pub fn canister_query_arg_output(
514        &self,
515        canister: &str,
516        method: &str,
517        arg: &str,
518        output: Option<&str>,
519    ) -> Result<String, IcpCommandError> {
520        self.canister_query_arg_output_with_candid(canister, method, arg, output, None)
521    }
522
523    /// Query one canister method with an explicit Candid argument, optional local Candid, and optional JSON output.
524    pub fn canister_query_arg_output_with_candid(
525        &self,
526        canister: &str,
527        method: &str,
528        arg: &str,
529        output: Option<&str>,
530        candid_path: Option<&Path>,
531    ) -> Result<String, IcpCommandError> {
532        let mut command = self.canister_command();
533        command.args(["call", canister, method]);
534        command.arg(arg);
535        command.arg("--query");
536        add_candid_arg(&mut command, candid_path);
537        if let Some(output) = output {
538            add_output_arg(&mut command, output);
539        }
540        self.add_target_args(&mut command);
541        run_output(&mut command)
542    }
543
544    /// Read one canister metadata section.
545    pub fn canister_metadata_output(
546        &self,
547        canister: &str,
548        metadata_name: &str,
549    ) -> Result<String, IcpCommandError> {
550        let mut command = self.canister_command();
551        command.args(["metadata", canister, metadata_name]);
552        self.add_target_args(&mut command);
553        run_output(&mut command)
554    }
555
556    /// Return one canister status report.
557    pub fn canister_status(&self, canister: &str) -> Result<String, IcpCommandError> {
558        let mut command = self.canister_command();
559        command.args(["status", canister]);
560        self.add_target_args(&mut command);
561        run_output(&mut command)
562    }
563
564    /// Top up one canister with cycles.
565    pub fn canister_top_up_output(
566        &self,
567        canister: &str,
568        amount_cycles: u128,
569    ) -> Result<String, IcpCommandError> {
570        let mut command = self.canister_command();
571        command.args(["top-up", "--amount"]);
572        command.arg(amount_cycles.to_string());
573        command.arg(canister);
574        self.add_target_args(&mut command);
575        run_output_with_stderr(&mut command)
576    }
577
578    /// Return one canister status report from ICP CLI JSON output.
579    pub fn canister_status_report(
580        &self,
581        canister: &str,
582    ) -> Result<IcpCanisterStatusReport, IcpCommandError> {
583        let mut command = self.canister_command();
584        command.args(["status", canister]);
585        command.arg("--json");
586        self.add_target_args(&mut command);
587        run_json(&mut command)
588    }
589
590    /// Create one canister snapshot and return the ICP CLI JSON receipt.
591    pub fn snapshot_create_receipt(
592        &self,
593        canister: &str,
594    ) -> Result<IcpSnapshotCreateReceipt, IcpCommandError> {
595        let mut command = self.canister_command();
596        command.args(["snapshot", "create", canister]);
597        command.arg("--json");
598        self.add_target_args(&mut command);
599        run_json(&mut command)
600    }
601
602    /// Create one canister snapshot and resolve the resulting snapshot id.
603    pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
604        Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
605    }
606
607    /// Stop one canister.
608    pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
609        let mut command = self.canister_command();
610        command.args(["stop", canister]);
611        self.add_target_args(&mut command);
612        run_status(&mut command)
613    }
614
615    /// Start one canister.
616    pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
617        let mut command = self.canister_command();
618        command.args(["start", canister]);
619        self.add_target_args(&mut command);
620        run_status(&mut command)
621    }
622
623    /// Download one canister snapshot into an artifact directory.
624    pub fn snapshot_download(
625        &self,
626        canister: &str,
627        snapshot_id: &str,
628        artifact_path: &Path,
629    ) -> Result<(), IcpCommandError> {
630        let mut command = self.canister_command();
631        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
632        command.arg(artifact_path);
633        self.add_target_args(&mut command);
634        run_status(&mut command)
635    }
636
637    /// Upload one snapshot artifact and return the uploaded snapshot id.
638    pub fn snapshot_upload(
639        &self,
640        canister: &str,
641        artifact_path: &Path,
642    ) -> Result<String, IcpCommandError> {
643        Ok(self
644            .snapshot_upload_receipt(canister, artifact_path)?
645            .snapshot_id)
646    }
647
648    /// Upload one snapshot artifact and return the ICP CLI JSON receipt.
649    pub fn snapshot_upload_receipt(
650        &self,
651        canister: &str,
652        artifact_path: &Path,
653    ) -> Result<IcpSnapshotUploadReceipt, IcpCommandError> {
654        let mut command = self.canister_command();
655        command.args(["snapshot", "upload", canister, "--input"]);
656        command.arg(artifact_path);
657        command.arg("--resume");
658        command.arg("--json");
659        self.add_target_args(&mut command);
660        run_json(&mut command)
661    }
662
663    /// Restore one uploaded snapshot onto a canister.
664    pub fn snapshot_restore(
665        &self,
666        canister: &str,
667        snapshot_id: &str,
668    ) -> Result<(), IcpCommandError> {
669        let mut command = self.canister_command();
670        command.args(["snapshot", "restore", canister, snapshot_id]);
671        self.add_target_args(&mut command);
672        run_status(&mut command)
673    }
674
675    /// Render a dry-run snapshot-create command.
676    #[must_use]
677    pub fn snapshot_create_display(&self, canister: &str) -> String {
678        let mut command = self.canister_command();
679        command.args(["snapshot", "create", canister]);
680        command.arg("--json");
681        self.add_target_args(&mut command);
682        command_display(&command)
683    }
684
685    /// Render a dry-run snapshot-download command.
686    #[must_use]
687    pub fn snapshot_download_display(
688        &self,
689        canister: &str,
690        snapshot_id: &str,
691        artifact_path: &Path,
692    ) -> String {
693        let mut command = self.canister_command();
694        command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
695        command.arg(artifact_path);
696        self.add_target_args(&mut command);
697        command_display(&command)
698    }
699
700    /// Render a dry-run snapshot-upload command.
701    #[must_use]
702    pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
703        let mut command = self.canister_command();
704        command.args(["snapshot", "upload", canister, "--input"]);
705        command.arg(artifact_path);
706        command.arg("--resume");
707        command.arg("--json");
708        self.add_target_args(&mut command);
709        command_display(&command)
710    }
711
712    /// Render a dry-run snapshot-restore command.
713    #[must_use]
714    pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
715        let mut command = self.canister_command();
716        command.args(["snapshot", "restore", canister, snapshot_id]);
717        self.add_target_args(&mut command);
718        command_display(&command)
719    }
720
721    /// Render a dry-run stop command.
722    #[must_use]
723    pub fn stop_canister_display(&self, canister: &str) -> String {
724        let mut command = self.canister_command();
725        command.args(["stop", canister]);
726        self.add_target_args(&mut command);
727        command_display(&command)
728    }
729
730    /// Render a dry-run start command.
731    #[must_use]
732    pub fn start_canister_display(&self, canister: &str) -> String {
733        let mut command = self.canister_command();
734        command.args(["start", canister]);
735        self.add_target_args(&mut command);
736        command_display(&command)
737    }
738
739    /// Render a dry-run top-up command.
740    #[must_use]
741    pub fn canister_top_up_display(&self, canister: &str, amount_cycles: u128) -> String {
742        let mut command = self.canister_command();
743        command.args(["top-up", "--amount"]);
744        command.arg(amount_cycles.to_string());
745        command.arg(canister);
746        self.add_target_args(&mut command);
747        command_display(&command)
748    }
749
750    /// Render a dry-run no-argument query call.
751    #[must_use]
752    pub fn canister_query_output_display(
753        &self,
754        canister: &str,
755        method: &str,
756        output: Option<&str>,
757    ) -> String {
758        self.canister_query_output_display_with_candid(canister, method, output, None)
759    }
760
761    /// Render a dry-run no-argument query call with optional local Candid.
762    #[must_use]
763    pub fn canister_query_output_display_with_candid(
764        &self,
765        canister: &str,
766        method: &str,
767        output: Option<&str>,
768        candid_path: Option<&Path>,
769    ) -> String {
770        let mut command = self.canister_command();
771        command.args(["call", canister, method]);
772        command.arg("()");
773        command.arg("--query");
774        add_candid_arg(&mut command, candid_path);
775        if let Some(output) = output {
776            add_output_arg(&mut command, output);
777        }
778        self.add_target_args(&mut command);
779        command_display(&command)
780    }
781
782    /// Render a dry-run update call with an explicit Candid argument.
783    #[must_use]
784    pub fn canister_call_arg_output_display(
785        &self,
786        canister: &str,
787        method: &str,
788        arg: &str,
789        output: Option<&str>,
790    ) -> String {
791        self.canister_call_arg_output_display_with_candid(canister, method, arg, output, None)
792    }
793
794    /// Render a dry-run update call with an explicit Candid argument and optional local Candid.
795    #[must_use]
796    pub fn canister_call_arg_output_display_with_candid(
797        &self,
798        canister: &str,
799        method: &str,
800        arg: &str,
801        output: Option<&str>,
802        candid_path: Option<&Path>,
803    ) -> String {
804        let mut command = self.canister_command();
805        command.args(["call", canister, method]);
806        command.arg(arg);
807        add_candid_arg(&mut command, candid_path);
808        if let Some(output) = output {
809            add_output_arg(&mut command, output);
810        }
811        self.add_target_args(&mut command);
812        command_display(&command)
813    }
814
815    fn add_target_args(&self, command: &mut Command) {
816        add_target_args(command, self.environment(), self.network());
817    }
818
819    fn add_local_network_target(&self, command: &mut Command) {
820        if let Some(environment) = self.environment() {
821            command.args(["-e", environment]);
822        } else if let Some(network) = self.network() {
823            command.arg(network);
824        } else {
825            command.arg(LOCAL_NETWORK);
826        }
827    }
828}
829
830/// Build a base `icp` command with the default executable.
831#[must_use]
832pub fn default_command() -> Command {
833    IcpCli::new("icp", None, None).command()
834}
835
836/// Build a base `icp` command rooted at one workspace directory.
837#[must_use]
838pub fn default_command_in(cwd: &Path) -> Command {
839    IcpCli::new("icp", None, None).command_in(cwd)
840}
841
842/// Add optional ICP CLI target arguments, preferring named environments.
843pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
844    if let Some(environment) = environment {
845        if environment == LOCAL_NETWORK
846            && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
847        {
848            command.env_remove("ICP_ENVIRONMENT");
849            command.arg("-n").arg(url);
850            if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
851                command.arg("-k").arg(root_key);
852            }
853            return;
854        }
855        command.args(["-e", environment]);
856    } else if let Some(network) = network {
857        command.args(["-n", network]);
858    }
859}
860
861/// Add ICP CLI output formatting, handling JSON as its own flag.
862pub fn add_output_arg(command: &mut Command, output: &str) {
863    if output == "json" {
864        command.arg("--json");
865    } else {
866        command.args(["--output", output]);
867    }
868}
869
870/// Add an ICP CLI local Candid interface path when one is available.
871pub fn add_candid_arg(command: &mut Command, candid_path: Option<&Path>) {
872    if let Some(candid_path) = candid_path {
873        command.arg("--candid").arg(candid_path);
874    }
875}
876
877/// Return Canic's local ICP CLI Candid sidecar path for one role.
878#[must_use]
879pub fn local_canister_candid_path(icp_root: &Path, environment: &str, role: &str) -> PathBuf {
880    icp_root
881        .join(".icp")
882        .join(environment)
883        .join("canisters")
884        .join(role)
885        .join(format!("{role}.did"))
886}
887
888/// Return the local Candid sidecar path only when it exists on disk.
889#[must_use]
890pub fn existing_local_canister_candid_path(
891    icp_root: &Path,
892    environment: &str,
893    role: &str,
894) -> Option<PathBuf> {
895    let path = local_canister_candid_path(icp_root, environment, role);
896    path.is_file().then_some(path)
897}
898
899/// Add ICP CLI debug logging when requested.
900pub fn add_debug_arg(command: &mut Command, debug: bool) {
901    if debug {
902        command.arg("--debug");
903    }
904}
905
906/// Ensure a command points at a supported ICP CLI executable before spawning it.
907pub fn ensure_command_compatible(command: &Command) -> Result<(), IcpCommandError> {
908    let executable = command.get_program().to_string_lossy();
909    compatible_version_output(executable.as_ref(), command.get_current_dir()).map(|_| ())
910}
911
912fn add_project_root_override_arg(command: &mut Command, cwd: &Path) {
913    command.arg("--project-root-override").arg(cwd);
914}
915
916fn run_local_replica_start_command(
917    command: &mut Command,
918    background: bool,
919    debug: bool,
920) -> Result<String, IcpCommandError> {
921    add_debug_arg(command, debug);
922    if background {
923        command.arg("--background");
924        return run_output_with_stderr(command);
925    }
926    run_status_inherit(command)?;
927    Ok(String::new())
928}
929
930/// Execute a command and capture trimmed stdout.
931pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
932    ensure_command_compatible(command)?;
933    run_output_unchecked(command)
934}
935
936fn run_output_unchecked(command: &mut Command) -> Result<String, IcpCommandError> {
937    let display = command_display(command);
938    let output = command.output()?;
939    if output.status.success() {
940        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
941    } else {
942        Err(IcpCommandError::Failed {
943            command: display,
944            stderr: command_stderr(&output),
945        })
946    }
947}
948
949/// Execute a command and capture stdout plus stderr on success.
950pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
951    ensure_command_compatible(command)?;
952    let display = command_display(command);
953    let output = command.output()?;
954    if output.status.success() {
955        let mut text = String::from_utf8_lossy(&output.stdout).to_string();
956        text.push_str(&String::from_utf8_lossy(&output.stderr));
957        Ok(text.trim().to_string())
958    } else {
959        Err(IcpCommandError::Failed {
960            command: display,
961            stderr: command_stderr(&output),
962        })
963    }
964}
965
966/// Execute a command and parse successful stdout as JSON.
967pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
968where
969    T: serde::de::DeserializeOwned,
970{
971    ensure_command_compatible(command)?;
972    let display = command_display(command);
973    let output = command.output()?;
974    if output.status.success() {
975        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
976        serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
977            command: display,
978            output: stdout,
979            source,
980        })
981    } else {
982        Err(IcpCommandError::Failed {
983            command: display,
984            stderr: command_stderr(&output),
985        })
986    }
987}
988
989/// Execute a command and require a successful status.
990pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
991    ensure_command_compatible(command)?;
992    let display = command_display(command);
993    let output = command.output()?;
994    if output.status.success() {
995        Ok(())
996    } else {
997        Err(IcpCommandError::Failed {
998            command: display,
999            stderr: command_stderr(&output),
1000        })
1001    }
1002}
1003
1004/// Execute a command with inherited terminal I/O and require a successful status.
1005pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
1006    ensure_command_compatible(command)?;
1007    let display = command_display(command);
1008    let mut child = command
1009        .stdout(Stdio::inherit())
1010        .stderr(Stdio::piped())
1011        .spawn()?;
1012    let stderr_handle = child
1013        .stderr
1014        .take()
1015        .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
1016    let status = child.wait()?;
1017    let stderr = match stderr_handle {
1018        Some(handle) => match handle.join() {
1019            Ok(result) => result?,
1020            Err(_) => Vec::new(),
1021        },
1022        None => Vec::new(),
1023    };
1024    if status.success() {
1025        Ok(())
1026    } else {
1027        let stderr = if stderr.is_empty() {
1028            format!("command exited with status {}", exit_status_label(status))
1029        } else {
1030            String::from_utf8_lossy(&stderr).to_string()
1031        };
1032        Err(IcpCommandError::Failed {
1033            command: display,
1034            stderr,
1035        })
1036    }
1037}
1038
1039fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
1040    let mut captured = Vec::new();
1041    let mut buffer = [0_u8; 8192];
1042    let mut terminal = io::stderr().lock();
1043    loop {
1044        let read = stderr.read(&mut buffer)?;
1045        if read == 0 {
1046            break;
1047        }
1048        terminal.write_all(&buffer[..read])?;
1049        captured.extend_from_slice(&buffer[..read]);
1050    }
1051    terminal.flush()?;
1052    Ok(captured)
1053}
1054
1055/// Execute a command and return whether it exits successfully.
1056pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
1057    ensure_command_compatible(command)?;
1058    Ok(command.output()?.status.success())
1059}
1060
1061/// Execute a rendered ICP CLI command and return raw process output.
1062pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
1063    if is_icp_program(program) {
1064        compatible_version_output(program, None)
1065            .map_err(|err| io::Error::other(err.to_string()))?;
1066    }
1067    let output = Command::new(program).args(args).output()?;
1068    Ok(IcpRawOutput {
1069        success: output.status.success(),
1070        status: exit_status_label(output.status),
1071        stdout: output.stdout,
1072        stderr: output.stderr,
1073    })
1074}
1075
1076fn is_icp_program(program: &str) -> bool {
1077    Path::new(program)
1078        .file_name()
1079        .is_some_and(|file_name| file_name == "icp")
1080}
1081
1082/// Render a command for diagnostics and dry-run previews.
1083#[must_use]
1084pub fn command_display(command: &Command) -> String {
1085    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
1086    parts.extend(
1087        command
1088            .get_args()
1089            .map(|arg| arg.to_string_lossy().to_string()),
1090    );
1091    parts.join(" ")
1092}
1093
1094/// Parse a likely snapshot id from `icp canister snapshot create` output.
1095#[must_use]
1096pub fn parse_snapshot_id(output: &str) -> Option<String> {
1097    let trimmed = output.trim();
1098    if is_snapshot_id_token(trimmed) {
1099        return Some(trimmed.to_string());
1100    }
1101
1102    output
1103        .lines()
1104        .flat_map(|line| {
1105            line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
1106        })
1107        .find(|part| is_snapshot_id_token(part))
1108        .map(str::to_string)
1109}
1110
1111/// Parse an ICP CLI semantic version from `icp --version` output.
1112#[must_use]
1113pub fn parse_icp_cli_version(output: &str) -> Option<IcpCliVersion> {
1114    output
1115        .split_whitespace()
1116        .find_map(parse_icp_cli_version_token)
1117}
1118
1119/// Return whether an ICP CLI version is supported by this Canic release.
1120#[must_use]
1121pub const fn is_supported_icp_cli_version(version: IcpCliVersion) -> bool {
1122    version.major == 0 && version.minor == 3 && version.patch >= 2
1123}
1124
1125fn compatible_version_output(
1126    executable: &str,
1127    cwd: Option<&Path>,
1128) -> Result<String, IcpCommandError> {
1129    let output = icp_version_output(executable, cwd)?;
1130    if let Some(version) = parse_icp_cli_version(&output)
1131        && is_supported_icp_cli_version(version)
1132    {
1133        return Ok(output);
1134    }
1135    Err(IcpCommandError::IncompatibleCliVersion {
1136        executable: executable.to_string(),
1137        found: output,
1138    })
1139}
1140
1141fn icp_version_output(executable: &str, cwd: Option<&Path>) -> Result<String, IcpCommandError> {
1142    let mut command = Command::new(executable);
1143    if let Some(cwd) = cwd {
1144        command.current_dir(cwd);
1145    }
1146    command.arg("--version");
1147    let display = command_display(&command);
1148    let output = command.output().map_err(|err| {
1149        if err.kind() == io::ErrorKind::NotFound {
1150            IcpCommandError::MissingCli {
1151                executable: executable.to_string(),
1152            }
1153        } else {
1154            IcpCommandError::Io(err)
1155        }
1156    })?;
1157    if output.status.success() {
1158        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1159    } else {
1160        Err(IcpCommandError::Failed {
1161            command: display,
1162            stderr: command_stderr(&output),
1163        })
1164    }
1165}
1166
1167fn parse_icp_cli_version_token(token: &str) -> Option<IcpCliVersion> {
1168    let token = token
1169        .trim_matches(|c: char| matches!(c, ',' | ';' | ')' | '('))
1170        .trim_start_matches('v');
1171    let mut parts = token.split('.');
1172    let major = parts.next()?.parse::<u64>().ok()?;
1173    let minor = parts.next()?.parse::<u64>().ok()?;
1174    let patch_token = parts.next()?;
1175    let patch_digits = patch_token
1176        .chars()
1177        .take_while(char::is_ascii_digit)
1178        .collect::<String>();
1179    if patch_digits.is_empty() || parts.next().is_some() {
1180        return None;
1181    }
1182    Some(IcpCliVersion {
1183        major,
1184        minor,
1185        patch: patch_digits.parse::<u64>().ok()?,
1186    })
1187}
1188
1189// ICP snapshot ids are rendered as even-length hexadecimal blobs.
1190fn is_snapshot_id_token(value: &str) -> bool {
1191    !value.is_empty()
1192        && value.len().is_multiple_of(2)
1193        && value.chars().all(|c| c.is_ascii_hexdigit())
1194}
1195
1196// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
1197fn command_stderr(output: &std::process::Output) -> String {
1198    let stderr = String::from_utf8_lossy(&output.stderr);
1199    if stderr.trim().is_empty() {
1200        String::from_utf8_lossy(&output.stdout).to_string()
1201    } else {
1202        stderr.to_string()
1203    }
1204}
1205
1206// Render process exit status without relying on platform-specific internals.
1207fn exit_status_label(status: std::process::ExitStatus) -> String {
1208    status
1209        .code()
1210        .map_or_else(|| "signal".to_string(), |code| code.to_string())
1211}
1212
1213#[cfg(test)]
1214mod tests;