1use std::{
2 env,
3 error::Error,
4 fmt,
5 io::{self, Read, Write},
6 path::{Path, PathBuf},
7 process::{Command, Stdio},
8 thread,
9};
10
11use serde::{Deserialize, Serialize};
12
13const LOCAL_NETWORK: &str = "local";
14pub(crate) const CANIC_ICP_LOCAL_NETWORK_URL_ENV: &str = "CANIC_ICP_LOCAL_NETWORK_URL";
15pub(crate) const CANIC_ICP_LOCAL_ROOT_KEY_ENV: &str = "CANIC_ICP_LOCAL_ROOT_KEY";
16
17#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct IcpRawOutput {
23 pub success: bool,
24 pub status: String,
25 pub stdout: Vec<u8>,
26 pub stderr: Vec<u8>,
27}
28
29#[derive(Debug)]
34pub enum IcpCommandError {
35 Io(std::io::Error),
36 Failed {
37 command: String,
38 stderr: String,
39 },
40 Json {
41 command: String,
42 output: String,
43 source: serde_json::Error,
44 },
45 SnapshotIdUnavailable {
46 output: String,
47 },
48}
49
50impl fmt::Display for IcpCommandError {
51 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53 match self {
54 Self::Io(err) => write!(formatter, "{err}"),
55 Self::Failed { command, stderr } => {
56 write!(formatter, "icp command failed: {command}\n{stderr}")
57 }
58 Self::Json {
59 command,
60 output,
61 source,
62 } => {
63 write!(
64 formatter,
65 "could not parse icp json output for {command}: {source}\n{output}"
66 )
67 }
68 Self::SnapshotIdUnavailable { output } => {
69 write!(
70 formatter,
71 "could not parse snapshot id from icp output: {output}"
72 )
73 }
74 }
75 }
76}
77
78impl Error for IcpCommandError {
79 fn source(&self) -> Option<&(dyn Error + 'static)> {
81 match self {
82 Self::Io(err) => Some(err),
83 Self::Json { source, .. } => Some(source),
84 Self::Failed { .. } | Self::SnapshotIdUnavailable { .. } => None,
85 }
86 }
87}
88
89impl From<std::io::Error> for IcpCommandError {
90 fn from(err: std::io::Error) -> Self {
92 Self::Io(err)
93 }
94}
95
96#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct IcpCli {
102 executable: String,
103 environment: Option<String>,
104 network: Option<String>,
105 cwd: Option<PathBuf>,
106}
107
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113pub struct IcpSnapshotCreateReceipt {
114 pub snapshot_id: String,
115 pub taken_at_timestamp: Option<u64>,
116 pub total_size_bytes: Option<u64>,
117}
118
119#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124pub struct IcpSnapshotUploadReceipt {
125 pub snapshot_id: String,
126}
127
128#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
133pub struct IcpCanisterStatusReport {
134 pub id: String,
135 pub name: Option<String>,
136 pub status: String,
137 pub settings: Option<IcpCanisterStatusSettings>,
138 pub module_hash: Option<String>,
139 pub memory_size: Option<String>,
140 pub cycles: Option<String>,
141 pub reserved_cycles: Option<String>,
142 pub idle_cycles_burned_per_day: Option<String>,
143}
144
145#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
150pub struct IcpCanisterStatusSettings {
151 #[serde(default)]
152 pub controllers: Vec<String>,
153 pub compute_allocation: Option<String>,
154 pub memory_allocation: Option<String>,
155 pub freezing_threshold: Option<String>,
156 pub reserved_cycles_limit: Option<String>,
157 pub wasm_memory_limit: Option<String>,
158 pub wasm_memory_threshold: Option<String>,
159 pub log_memory_limit: Option<String>,
160}
161
162impl IcpCli {
163 #[must_use]
165 pub fn new(
166 executable: impl Into<String>,
167 environment: Option<String>,
168 network: Option<String>,
169 ) -> Self {
170 Self {
171 executable: executable.into(),
172 environment,
173 network,
174 cwd: None,
175 }
176 }
177
178 #[must_use]
180 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
181 self.cwd = Some(cwd.into());
182 self
183 }
184
185 #[must_use]
187 pub fn environment(&self) -> Option<&str> {
188 self.environment.as_deref()
189 }
190
191 #[must_use]
193 pub fn network(&self) -> Option<&str> {
194 self.network.as_deref()
195 }
196
197 #[must_use]
199 pub fn command(&self) -> Command {
200 let mut command = Command::new(&self.executable);
201 if let Some(cwd) = &self.cwd {
202 command.current_dir(cwd);
203 }
204 command
205 }
206
207 #[must_use]
209 pub fn command_in(&self, cwd: &Path) -> Command {
210 let mut command = self.command();
211 command.current_dir(cwd);
212 command
213 }
214
215 #[must_use]
217 pub fn canister_command(&self) -> Command {
218 let mut command = self.command();
219 command.arg("canister");
220 command
221 }
222
223 pub fn version(&self) -> Result<String, IcpCommandError> {
225 let mut command = self.command();
226 command.arg("--version");
227 run_output(&mut command)
228 }
229
230 pub fn local_replica_start(
232 &self,
233 background: bool,
234 debug: bool,
235 ) -> Result<String, IcpCommandError> {
236 let mut command = self.local_replica_command("start");
237 run_local_replica_start_command(&mut command, background, debug)
238 }
239
240 pub fn local_replica_start_in(
242 &self,
243 cwd: &Path,
244 background: bool,
245 debug: bool,
246 ) -> Result<String, IcpCommandError> {
247 let mut command = self.local_replica_command_in("start", cwd);
248 run_local_replica_start_command(&mut command, background, debug)
249 }
250
251 pub fn local_replica_status(&self, debug: bool) -> Result<String, IcpCommandError> {
253 let mut command = self.local_replica_command("status");
254 add_debug_arg(&mut command, debug);
255 run_output_with_stderr(&mut command)
256 }
257
258 pub fn local_replica_status_in(
260 &self,
261 cwd: &Path,
262 debug: bool,
263 ) -> Result<String, IcpCommandError> {
264 let mut command = self.local_replica_command_in("status", cwd);
265 add_debug_arg(&mut command, debug);
266 run_output_with_stderr(&mut command)
267 }
268
269 pub fn local_replica_status_json(
271 &self,
272 debug: bool,
273 ) -> Result<serde_json::Value, IcpCommandError> {
274 let mut command = self.local_replica_command("status");
275 add_debug_arg(&mut command, debug);
276 command.arg("--json");
277 run_json(&mut command)
278 }
279
280 pub fn local_replica_status_json_in(
282 &self,
283 cwd: &Path,
284 debug: bool,
285 ) -> Result<serde_json::Value, IcpCommandError> {
286 let mut command = self.local_replica_command_in("status", cwd);
287 add_debug_arg(&mut command, debug);
288 command.arg("--json");
289 run_json(&mut command)
290 }
291
292 pub fn local_replica_project_running(&self, debug: bool) -> Result<bool, IcpCommandError> {
294 let mut command = self.local_replica_command("status");
295 add_debug_arg(&mut command, debug);
296 run_success(&mut command)
297 }
298
299 pub fn local_replica_project_running_in(
301 &self,
302 cwd: &Path,
303 debug: bool,
304 ) -> Result<bool, IcpCommandError> {
305 let mut command = self.local_replica_command_in("status", cwd);
306 add_debug_arg(&mut command, debug);
307 run_success(&mut command)
308 }
309
310 pub fn local_replica_ping(&self, debug: bool) -> Result<bool, IcpCommandError> {
312 let mut command = self.local_replica_command("ping");
313 add_debug_arg(&mut command, debug);
314 run_success(&mut command)
315 }
316
317 pub fn local_replica_stop(&self, debug: bool) -> Result<String, IcpCommandError> {
319 let mut command = self.local_replica_command("stop");
320 add_debug_arg(&mut command, debug);
321 run_output_with_stderr(&mut command)
322 }
323
324 pub fn local_replica_stop_in(
326 &self,
327 cwd: &Path,
328 debug: bool,
329 ) -> Result<String, IcpCommandError> {
330 let mut command = self.local_replica_command_in("stop", cwd);
331 add_debug_arg(&mut command, debug);
332 run_output_with_stderr(&mut command)
333 }
334
335 #[must_use]
337 pub fn local_replica_start_display(&self, background: bool, debug: bool) -> String {
338 let mut command = self.local_replica_command("start");
339 add_debug_arg(&mut command, debug);
340 if background {
341 command.arg("--background");
342 }
343 command_display(&command)
344 }
345
346 #[must_use]
348 pub fn local_replica_status_display(&self, debug: bool) -> String {
349 let mut command = self.local_replica_command("status");
350 add_debug_arg(&mut command, debug);
351 command_display(&command)
352 }
353
354 #[must_use]
356 pub fn local_replica_stop_display(&self, debug: bool) -> String {
357 let mut command = self.local_replica_command("stop");
358 add_debug_arg(&mut command, debug);
359 command_display(&command)
360 }
361
362 fn local_replica_command(&self, action: &str) -> Command {
363 let mut command = self.command();
364 command.args(["network", action, LOCAL_NETWORK]);
365 command
366 }
367
368 fn local_replica_command_in(&self, action: &str, cwd: &Path) -> Command {
369 let mut command = self.command_in(cwd);
370 command.args(["network", action, LOCAL_NETWORK]);
371 command
372 }
373
374 pub fn canister_call_output(
376 &self,
377 canister: &str,
378 method: &str,
379 output: Option<&str>,
380 ) -> Result<String, IcpCommandError> {
381 let mut command = self.canister_command();
382 command.args(["call", canister, method]);
383 command.arg("()");
384 if let Some(output) = output {
385 add_output_arg(&mut command, output);
386 }
387 self.add_target_args(&mut command);
388 run_output(&mut command)
389 }
390
391 pub fn canister_call_arg_output(
393 &self,
394 canister: &str,
395 method: &str,
396 arg: &str,
397 output: Option<&str>,
398 ) -> Result<String, IcpCommandError> {
399 let mut command = self.canister_command();
400 command.args(["call", canister, method]);
401 command.arg(arg);
402 if let Some(output) = output {
403 add_output_arg(&mut command, output);
404 }
405 self.add_target_args(&mut command);
406 run_output(&mut command)
407 }
408
409 pub fn canister_query_output(
411 &self,
412 canister: &str,
413 method: &str,
414 output: Option<&str>,
415 ) -> Result<String, IcpCommandError> {
416 let mut command = self.canister_command();
417 command.args(["call", canister, method]);
418 command.arg("()");
419 command.arg("--query");
420 if let Some(output) = output {
421 add_output_arg(&mut command, output);
422 }
423 self.add_target_args(&mut command);
424 run_output(&mut command)
425 }
426
427 pub fn canister_query_arg_output(
429 &self,
430 canister: &str,
431 method: &str,
432 arg: &str,
433 output: Option<&str>,
434 ) -> Result<String, IcpCommandError> {
435 let mut command = self.canister_command();
436 command.args(["call", canister, method]);
437 command.arg(arg);
438 command.arg("--query");
439 if let Some(output) = output {
440 add_output_arg(&mut command, output);
441 }
442 self.add_target_args(&mut command);
443 run_output(&mut command)
444 }
445
446 pub fn canister_metadata_output(
448 &self,
449 canister: &str,
450 metadata_name: &str,
451 ) -> Result<String, IcpCommandError> {
452 let mut command = self.canister_command();
453 command.args(["metadata", canister, metadata_name]);
454 self.add_target_args(&mut command);
455 run_output(&mut command)
456 }
457
458 pub fn canister_status(&self, canister: &str) -> Result<String, IcpCommandError> {
460 let mut command = self.canister_command();
461 command.args(["status", canister]);
462 self.add_target_args(&mut command);
463 run_output(&mut command)
464 }
465
466 pub fn canister_top_up_output(
468 &self,
469 canister: &str,
470 amount_cycles: u128,
471 ) -> Result<String, IcpCommandError> {
472 let mut command = self.canister_command();
473 command.args(["top-up", "--amount"]);
474 command.arg(amount_cycles.to_string());
475 command.arg(canister);
476 self.add_target_args(&mut command);
477 run_output_with_stderr(&mut command)
478 }
479
480 pub fn canister_status_report(
482 &self,
483 canister: &str,
484 ) -> Result<IcpCanisterStatusReport, IcpCommandError> {
485 let mut command = self.canister_command();
486 command.args(["status", canister]);
487 command.arg("--json");
488 self.add_target_args(&mut command);
489 run_json(&mut command)
490 }
491
492 pub fn snapshot_create_receipt(
494 &self,
495 canister: &str,
496 ) -> Result<IcpSnapshotCreateReceipt, IcpCommandError> {
497 let mut command = self.canister_command();
498 command.args(["snapshot", "create", canister]);
499 command.arg("--json");
500 self.add_target_args(&mut command);
501 run_json(&mut command)
502 }
503
504 pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
506 Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
507 }
508
509 pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
511 let mut command = self.canister_command();
512 command.args(["stop", canister]);
513 self.add_target_args(&mut command);
514 run_status(&mut command)
515 }
516
517 pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
519 let mut command = self.canister_command();
520 command.args(["start", canister]);
521 self.add_target_args(&mut command);
522 run_status(&mut command)
523 }
524
525 pub fn snapshot_download(
527 &self,
528 canister: &str,
529 snapshot_id: &str,
530 artifact_path: &Path,
531 ) -> Result<(), IcpCommandError> {
532 let mut command = self.canister_command();
533 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
534 command.arg(artifact_path);
535 self.add_target_args(&mut command);
536 run_status(&mut command)
537 }
538
539 pub fn snapshot_upload(
541 &self,
542 canister: &str,
543 artifact_path: &Path,
544 ) -> Result<String, IcpCommandError> {
545 Ok(self
546 .snapshot_upload_receipt(canister, artifact_path)?
547 .snapshot_id)
548 }
549
550 pub fn snapshot_upload_receipt(
552 &self,
553 canister: &str,
554 artifact_path: &Path,
555 ) -> Result<IcpSnapshotUploadReceipt, IcpCommandError> {
556 let mut command = self.canister_command();
557 command.args(["snapshot", "upload", canister, "--input"]);
558 command.arg(artifact_path);
559 command.arg("--resume");
560 command.arg("--json");
561 self.add_target_args(&mut command);
562 run_json(&mut command)
563 }
564
565 pub fn snapshot_restore(
567 &self,
568 canister: &str,
569 snapshot_id: &str,
570 ) -> Result<(), IcpCommandError> {
571 let mut command = self.canister_command();
572 command.args(["snapshot", "restore", canister, snapshot_id]);
573 self.add_target_args(&mut command);
574 run_status(&mut command)
575 }
576
577 #[must_use]
579 pub fn snapshot_create_display(&self, canister: &str) -> String {
580 let mut command = self.canister_command();
581 command.args(["snapshot", "create", canister]);
582 command.arg("--json");
583 self.add_target_args(&mut command);
584 command_display(&command)
585 }
586
587 #[must_use]
589 pub fn snapshot_download_display(
590 &self,
591 canister: &str,
592 snapshot_id: &str,
593 artifact_path: &Path,
594 ) -> String {
595 let mut command = self.canister_command();
596 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
597 command.arg(artifact_path);
598 self.add_target_args(&mut command);
599 command_display(&command)
600 }
601
602 #[must_use]
604 pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
605 let mut command = self.canister_command();
606 command.args(["snapshot", "upload", canister, "--input"]);
607 command.arg(artifact_path);
608 command.arg("--resume");
609 command.arg("--json");
610 self.add_target_args(&mut command);
611 command_display(&command)
612 }
613
614 #[must_use]
616 pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
617 let mut command = self.canister_command();
618 command.args(["snapshot", "restore", canister, snapshot_id]);
619 self.add_target_args(&mut command);
620 command_display(&command)
621 }
622
623 #[must_use]
625 pub fn stop_canister_display(&self, canister: &str) -> String {
626 let mut command = self.canister_command();
627 command.args(["stop", canister]);
628 self.add_target_args(&mut command);
629 command_display(&command)
630 }
631
632 #[must_use]
634 pub fn start_canister_display(&self, canister: &str) -> String {
635 let mut command = self.canister_command();
636 command.args(["start", canister]);
637 self.add_target_args(&mut command);
638 command_display(&command)
639 }
640
641 #[must_use]
643 pub fn canister_top_up_display(&self, canister: &str, amount_cycles: u128) -> String {
644 let mut command = self.canister_command();
645 command.args(["top-up", "--amount"]);
646 command.arg(amount_cycles.to_string());
647 command.arg(canister);
648 self.add_target_args(&mut command);
649 command_display(&command)
650 }
651
652 #[must_use]
654 pub fn canister_query_output_display(
655 &self,
656 canister: &str,
657 method: &str,
658 output: Option<&str>,
659 ) -> String {
660 let mut command = self.canister_command();
661 command.args(["call", canister, method]);
662 command.arg("()");
663 command.arg("--query");
664 if let Some(output) = output {
665 add_output_arg(&mut command, output);
666 }
667 self.add_target_args(&mut command);
668 command_display(&command)
669 }
670
671 #[must_use]
673 pub fn canister_call_arg_output_display(
674 &self,
675 canister: &str,
676 method: &str,
677 arg: &str,
678 output: Option<&str>,
679 ) -> String {
680 let mut command = self.canister_command();
681 command.args(["call", canister, method]);
682 command.arg(arg);
683 if let Some(output) = output {
684 add_output_arg(&mut command, output);
685 }
686 self.add_target_args(&mut command);
687 command_display(&command)
688 }
689
690 fn add_target_args(&self, command: &mut Command) {
691 add_target_args(command, self.environment(), self.network());
692 }
693}
694
695#[must_use]
697pub fn default_command() -> Command {
698 IcpCli::new("icp", None, None).command()
699}
700
701#[must_use]
703pub fn default_command_in(cwd: &Path) -> Command {
704 IcpCli::new("icp", None, None).command_in(cwd)
705}
706
707pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
709 if let Some(environment) = environment {
710 if environment == LOCAL_NETWORK
711 && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
712 {
713 command.env_remove("ICP_ENVIRONMENT");
714 command.arg("-n").arg(url);
715 if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
716 command.arg("-k").arg(root_key);
717 }
718 return;
719 }
720 command.args(["-e", environment]);
721 } else if let Some(network) = network {
722 command.args(["-n", network]);
723 }
724}
725
726pub fn add_output_arg(command: &mut Command, output: &str) {
728 if output == "json" {
729 command.arg("--json");
730 } else {
731 command.args(["--output", output]);
732 }
733}
734
735pub fn add_debug_arg(command: &mut Command, debug: bool) {
737 if debug {
738 command.arg("--debug");
739 }
740}
741
742fn run_local_replica_start_command(
743 command: &mut Command,
744 background: bool,
745 debug: bool,
746) -> Result<String, IcpCommandError> {
747 add_debug_arg(command, debug);
748 if background {
749 command.arg("--background");
750 return run_output_with_stderr(command);
751 }
752 run_status_inherit(command)?;
753 Ok(String::new())
754}
755
756pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
758 let display = command_display(command);
759 let output = command.output()?;
760 if output.status.success() {
761 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
762 } else {
763 Err(IcpCommandError::Failed {
764 command: display,
765 stderr: command_stderr(&output),
766 })
767 }
768}
769
770pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
772 let display = command_display(command);
773 let output = command.output()?;
774 if output.status.success() {
775 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
776 text.push_str(&String::from_utf8_lossy(&output.stderr));
777 Ok(text.trim().to_string())
778 } else {
779 Err(IcpCommandError::Failed {
780 command: display,
781 stderr: command_stderr(&output),
782 })
783 }
784}
785
786pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
788where
789 T: serde::de::DeserializeOwned,
790{
791 let display = command_display(command);
792 let output = command.output()?;
793 if output.status.success() {
794 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
795 serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
796 command: display,
797 output: stdout,
798 source,
799 })
800 } else {
801 Err(IcpCommandError::Failed {
802 command: display,
803 stderr: command_stderr(&output),
804 })
805 }
806}
807
808pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
810 let display = command_display(command);
811 let output = command.output()?;
812 if output.status.success() {
813 Ok(())
814 } else {
815 Err(IcpCommandError::Failed {
816 command: display,
817 stderr: command_stderr(&output),
818 })
819 }
820}
821
822pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
824 let display = command_display(command);
825 let mut child = command
826 .stdout(Stdio::inherit())
827 .stderr(Stdio::piped())
828 .spawn()?;
829 let stderr_handle = child
830 .stderr
831 .take()
832 .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
833 let status = child.wait()?;
834 let stderr = match stderr_handle {
835 Some(handle) => match handle.join() {
836 Ok(result) => result?,
837 Err(_) => Vec::new(),
838 },
839 None => Vec::new(),
840 };
841 if status.success() {
842 Ok(())
843 } else {
844 let stderr = if stderr.is_empty() {
845 format!("command exited with status {}", exit_status_label(status))
846 } else {
847 String::from_utf8_lossy(&stderr).to_string()
848 };
849 Err(IcpCommandError::Failed {
850 command: display,
851 stderr,
852 })
853 }
854}
855
856fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
857 let mut captured = Vec::new();
858 let mut buffer = [0_u8; 8192];
859 let mut terminal = io::stderr().lock();
860 loop {
861 let read = stderr.read(&mut buffer)?;
862 if read == 0 {
863 break;
864 }
865 terminal.write_all(&buffer[..read])?;
866 captured.extend_from_slice(&buffer[..read]);
867 }
868 terminal.flush()?;
869 Ok(captured)
870}
871
872pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
874 Ok(command.output()?.status.success())
875}
876
877pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
879 let output = Command::new(program).args(args).output()?;
880 Ok(IcpRawOutput {
881 success: output.status.success(),
882 status: exit_status_label(output.status),
883 stdout: output.stdout,
884 stderr: output.stderr,
885 })
886}
887
888#[must_use]
890pub fn command_display(command: &Command) -> String {
891 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
892 parts.extend(
893 command
894 .get_args()
895 .map(|arg| arg.to_string_lossy().to_string()),
896 );
897 parts.join(" ")
898}
899
900#[must_use]
902pub fn parse_snapshot_id(output: &str) -> Option<String> {
903 let trimmed = output.trim();
904 if is_snapshot_id_token(trimmed) {
905 return Some(trimmed.to_string());
906 }
907
908 output
909 .lines()
910 .flat_map(|line| {
911 line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
912 })
913 .find(|part| is_snapshot_id_token(part))
914 .map(str::to_string)
915}
916
917fn is_snapshot_id_token(value: &str) -> bool {
919 !value.is_empty()
920 && value.len().is_multiple_of(2)
921 && value.chars().all(|c| c.is_ascii_hexdigit())
922}
923
924fn command_stderr(output: &std::process::Output) -> String {
926 let stderr = String::from_utf8_lossy(&output.stderr);
927 if stderr.trim().is_empty() {
928 String::from_utf8_lossy(&output.stdout).to_string()
929 } else {
930 stderr.to_string()
931 }
932}
933
934fn exit_status_label(status: std::process::ExitStatus) -> String {
936 status
937 .code()
938 .map_or_else(|| "signal".to_string(), |code| code.to_string())
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944
945 #[test]
947 fn renders_environment_target() {
948 let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
949
950 assert_eq!(
951 icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
952 "icp canister snapshot download root snap-1 --output backups/root -e staging"
953 );
954 }
955
956 #[test]
958 fn renders_network_target() {
959 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
960
961 assert_eq!(
962 icp.snapshot_create_display("aaaaa-aa"),
963 "icp canister snapshot create aaaaa-aa --json -n ic"
964 );
965 }
966
967 #[test]
969 fn renders_local_replica_commands() {
970 let icp = IcpCli::new("icp", None, None);
971
972 assert_eq!(
973 icp.local_replica_start_display(true, false),
974 "icp network start local --background"
975 );
976 assert_eq!(
977 icp.local_replica_start_display(false, false),
978 "icp network start local"
979 );
980 assert_eq!(
981 icp.local_replica_start_display(false, true),
982 "icp network start local --debug"
983 );
984 assert_eq!(
985 icp.local_replica_status_display(false),
986 "icp network status local"
987 );
988 assert_eq!(
989 icp.local_replica_status_display(true),
990 "icp network status local --debug"
991 );
992 assert_eq!(
993 icp.local_replica_stop_display(false),
994 "icp network stop local"
995 );
996 assert_eq!(
997 icp.local_replica_stop_display(true),
998 "icp network stop local --debug"
999 );
1000 }
1001
1002 #[test]
1004 fn renders_snapshot_restore_flow() {
1005 let icp = IcpCli::new("icp", Some("prod".to_string()), None);
1006
1007 assert_eq!(
1008 icp.snapshot_upload_display("root", Path::new("artifact")),
1009 "icp canister snapshot upload root --input artifact --resume --json -e prod"
1010 );
1011 assert_eq!(
1012 icp.snapshot_restore_display("root", "uploaded-1"),
1013 "icp canister snapshot restore root uploaded-1 -e prod"
1014 );
1015 }
1016
1017 #[test]
1019 fn renders_no_argument_query_call() {
1020 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1021
1022 assert_eq!(
1023 icp.canister_query_output_display("root", "canic_ready", Some("json")),
1024 "icp canister call root canic_ready () --query --json -n ic"
1025 );
1026 }
1027
1028 #[test]
1030 fn renders_argument_update_call() {
1031 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1032
1033 assert_eq!(
1034 icp.canister_call_arg_output_display(
1035 "root",
1036 "canic_icp_refill",
1037 "(record { dry_run = true })",
1038 Some("json")
1039 ),
1040 "icp canister call root canic_icp_refill (record { dry_run = true }) --json -n ic"
1041 );
1042 }
1043
1044 #[test]
1046 fn renders_canister_top_up() {
1047 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1048
1049 assert_eq!(
1050 icp.canister_top_up_display("aaaaa-aa", 4_000_000_000_000),
1051 "icp canister top-up --amount 4000000000000 aaaaa-aa -n ic"
1052 );
1053 }
1054
1055 #[test]
1057 fn parses_snapshot_id_from_output() {
1058 let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
1059
1060 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1061 }
1062
1063 #[test]
1065 fn parses_snapshot_id_from_table_output() {
1066 let output = "\
1067ID SIZE CREATED_AT
10680a0b0c0d 1.37 MiB 2026-05-10T17:04:19Z
1069";
1070
1071 let snapshot_id = parse_snapshot_id(output);
1072
1073 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1074 }
1075
1076 #[test]
1078 fn parses_snapshot_create_receipt_json() {
1079 let receipt = serde_json::from_str::<IcpSnapshotCreateReceipt>(
1080 r#"{
1081 "snapshot_id": "0000000000000000ffffffffffc000020101",
1082 "taken_at_timestamp": 1778709681897818005,
1083 "total_size_bytes": 272586987
1084}"#,
1085 )
1086 .expect("parse snapshot receipt");
1087
1088 assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1089 assert_eq!(receipt.total_size_bytes, Some(272_586_987));
1090 }
1091
1092 #[test]
1094 fn parses_snapshot_upload_receipt_json() {
1095 let receipt = serde_json::from_str::<IcpSnapshotUploadReceipt>(
1096 r#"{
1097 "snapshot_id": "0000000000000000ffffffffffc000020101"
1098}"#,
1099 )
1100 .expect("parse snapshot upload receipt");
1101
1102 assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1103 }
1104
1105 #[test]
1107 fn parses_canister_status_report_json() {
1108 let report = serde_json::from_str::<IcpCanisterStatusReport>(
1109 r#"{
1110 "id": "t63gs-up777-77776-aaaba-cai",
1111 "name": "motoko-ex",
1112 "status": "Running",
1113 "settings": {
1114 "controllers": ["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"],
1115 "compute_allocation": "0"
1116 },
1117 "module_hash": "0x66ce5ddcd06f1135c1a04792a2f1b7c3d9e229b977a8fc9762c71ecc5314c9eb",
1118 "cycles": "1_497_896_187_059"
1119}"#,
1120 )
1121 .expect("parse status report");
1122
1123 assert_eq!(report.status, "Running");
1124 assert_eq!(
1125 report.settings.expect("settings").controllers.as_slice(),
1126 &["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"]
1127 );
1128 }
1129}