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#[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#[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 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 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 fn from(err: std::io::Error) -> Self {
116 Self::Io(err)
117 }
118}
119
120#[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#[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#[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
158pub struct IcpSnapshotUploadReceipt {
159 pub snapshot_id: String,
160}
161
162#[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#[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 #[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 #[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 #[must_use]
221 pub fn environment(&self) -> Option<&str> {
222 self.environment.as_deref()
223 }
224
225 #[must_use]
227 pub fn network(&self) -> Option<&str> {
228 self.network.as_deref()
229 }
230
231 #[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 #[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 #[must_use]
253 pub fn canister_command(&self) -> Command {
254 let mut command = self.command();
255 command.arg("canister");
256 command
257 }
258
259 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 pub fn compatible_version(&self) -> Result<String, IcpCommandError> {
268 compatible_version_output(&self.executable, self.cwd.as_deref())
269 }
270
271 pub fn ensure_compatible(&self) -> Result<(), IcpCommandError> {
273 self.compatible_version().map(|_| ())
274 }
275
276 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
554 Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
555 }
556
557 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[must_use]
755pub fn default_command() -> Command {
756 IcpCli::new("icp", None, None).command()
757}
758
759#[must_use]
761pub fn default_command_in(cwd: &Path) -> Command {
762 IcpCli::new("icp", None, None).command_in(cwd)
763}
764
765pub 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
784pub 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
793pub fn add_debug_arg(command: &mut Command, debug: bool) {
795 if debug {
796 command.arg("--debug");
797 }
798}
799
800pub 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
824pub 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
843pub 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
860pub 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
883pub 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
898pub 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
949pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
951 ensure_command_compatible(command)?;
952 Ok(command.output()?.status.success())
953}
954
955pub 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#[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#[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#[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#[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
1083fn 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
1090fn 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
1100fn 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;