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 IcpCanisterStatusReport {
125 pub id: String,
126 pub name: Option<String>,
127 pub status: String,
128 pub settings: Option<IcpCanisterStatusSettings>,
129 pub module_hash: Option<String>,
130 pub memory_size: Option<String>,
131 pub cycles: Option<String>,
132 pub reserved_cycles: Option<String>,
133 pub idle_cycles_burned_per_day: Option<String>,
134}
135
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
141pub struct IcpCanisterStatusSettings {
142 #[serde(default)]
143 pub controllers: Vec<String>,
144 pub compute_allocation: Option<String>,
145 pub memory_allocation: Option<String>,
146 pub freezing_threshold: Option<String>,
147 pub reserved_cycles_limit: Option<String>,
148 pub wasm_memory_limit: Option<String>,
149 pub wasm_memory_threshold: Option<String>,
150 pub log_memory_limit: Option<String>,
151}
152
153impl IcpCli {
154 #[must_use]
156 pub fn new(
157 executable: impl Into<String>,
158 environment: Option<String>,
159 network: Option<String>,
160 ) -> Self {
161 Self {
162 executable: executable.into(),
163 environment,
164 network,
165 cwd: None,
166 }
167 }
168
169 #[must_use]
171 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
172 self.cwd = Some(cwd.into());
173 self
174 }
175
176 #[must_use]
178 pub fn environment(&self) -> Option<&str> {
179 self.environment.as_deref()
180 }
181
182 #[must_use]
184 pub fn network(&self) -> Option<&str> {
185 self.network.as_deref()
186 }
187
188 #[must_use]
190 pub fn command(&self) -> Command {
191 let mut command = Command::new(&self.executable);
192 if let Some(cwd) = &self.cwd {
193 command.current_dir(cwd);
194 }
195 command
196 }
197
198 #[must_use]
200 pub fn command_in(&self, cwd: &Path) -> Command {
201 let mut command = self.command();
202 command.current_dir(cwd);
203 command
204 }
205
206 #[must_use]
208 pub fn canister_command(&self) -> Command {
209 let mut command = self.command();
210 command.arg("canister");
211 command
212 }
213
214 pub fn version(&self) -> Result<String, IcpCommandError> {
216 let mut command = self.command();
217 command.arg("--version");
218 run_output(&mut command)
219 }
220
221 pub fn local_replica_start(
223 &self,
224 background: bool,
225 debug: bool,
226 ) -> Result<String, IcpCommandError> {
227 let mut command = self.local_replica_command("start");
228 run_local_replica_start_command(&mut command, background, debug)
229 }
230
231 pub fn local_replica_start_in(
233 &self,
234 cwd: &Path,
235 background: bool,
236 debug: bool,
237 ) -> Result<String, IcpCommandError> {
238 let mut command = self.local_replica_command_in("start", cwd);
239 run_local_replica_start_command(&mut command, background, debug)
240 }
241
242 pub fn local_replica_status(&self, debug: bool) -> Result<String, IcpCommandError> {
244 let mut command = self.local_replica_command("status");
245 add_debug_arg(&mut command, debug);
246 run_output_with_stderr(&mut command)
247 }
248
249 pub fn local_replica_status_in(
251 &self,
252 cwd: &Path,
253 debug: bool,
254 ) -> Result<String, IcpCommandError> {
255 let mut command = self.local_replica_command_in("status", cwd);
256 add_debug_arg(&mut command, debug);
257 run_output_with_stderr(&mut command)
258 }
259
260 pub fn local_replica_status_json(
262 &self,
263 debug: bool,
264 ) -> Result<serde_json::Value, IcpCommandError> {
265 let mut command = self.local_replica_command("status");
266 add_debug_arg(&mut command, debug);
267 command.arg("--json");
268 run_json(&mut command)
269 }
270
271 pub fn local_replica_status_json_in(
273 &self,
274 cwd: &Path,
275 debug: bool,
276 ) -> Result<serde_json::Value, IcpCommandError> {
277 let mut command = self.local_replica_command_in("status", cwd);
278 add_debug_arg(&mut command, debug);
279 command.arg("--json");
280 run_json(&mut command)
281 }
282
283 pub fn local_replica_project_running(&self, debug: bool) -> Result<bool, IcpCommandError> {
285 let mut command = self.local_replica_command("status");
286 add_debug_arg(&mut command, debug);
287 run_success(&mut command)
288 }
289
290 pub fn local_replica_project_running_in(
292 &self,
293 cwd: &Path,
294 debug: bool,
295 ) -> Result<bool, IcpCommandError> {
296 let mut command = self.local_replica_command_in("status", cwd);
297 add_debug_arg(&mut command, debug);
298 run_success(&mut command)
299 }
300
301 pub fn local_replica_ping(&self, debug: bool) -> Result<bool, IcpCommandError> {
303 let mut command = self.local_replica_command("ping");
304 add_debug_arg(&mut command, debug);
305 run_success(&mut command)
306 }
307
308 pub fn local_replica_stop(&self, debug: bool) -> Result<String, IcpCommandError> {
310 let mut command = self.local_replica_command("stop");
311 add_debug_arg(&mut command, debug);
312 run_output_with_stderr(&mut command)
313 }
314
315 pub fn local_replica_stop_in(
317 &self,
318 cwd: &Path,
319 debug: bool,
320 ) -> Result<String, IcpCommandError> {
321 let mut command = self.local_replica_command_in("stop", cwd);
322 add_debug_arg(&mut command, debug);
323 run_output_with_stderr(&mut command)
324 }
325
326 #[must_use]
328 pub fn local_replica_start_display(&self, background: bool, debug: bool) -> String {
329 let mut command = self.local_replica_command("start");
330 add_debug_arg(&mut command, debug);
331 if background {
332 command.arg("--background");
333 }
334 command_display(&command)
335 }
336
337 #[must_use]
339 pub fn local_replica_status_display(&self, debug: bool) -> String {
340 let mut command = self.local_replica_command("status");
341 add_debug_arg(&mut command, debug);
342 command_display(&command)
343 }
344
345 #[must_use]
347 pub fn local_replica_stop_display(&self, debug: bool) -> String {
348 let mut command = self.local_replica_command("stop");
349 add_debug_arg(&mut command, debug);
350 command_display(&command)
351 }
352
353 fn local_replica_command(&self, action: &str) -> Command {
354 let mut command = self.command();
355 command.args(["network", action, LOCAL_NETWORK]);
356 command
357 }
358
359 fn local_replica_command_in(&self, action: &str, cwd: &Path) -> Command {
360 let mut command = self.command_in(cwd);
361 command.args(["network", action, LOCAL_NETWORK]);
362 command
363 }
364
365 pub fn canister_call_output(
367 &self,
368 canister: &str,
369 method: &str,
370 output: Option<&str>,
371 ) -> Result<String, IcpCommandError> {
372 let mut command = self.canister_command();
373 command.args(["call", canister, method]);
374 command.arg("()");
375 if let Some(output) = output {
376 add_output_arg(&mut command, output);
377 }
378 self.add_target_args(&mut command);
379 run_output(&mut command)
380 }
381
382 pub fn canister_call_arg_output(
384 &self,
385 canister: &str,
386 method: &str,
387 arg: &str,
388 output: Option<&str>,
389 ) -> Result<String, IcpCommandError> {
390 let mut command = self.canister_command();
391 command.args(["call", canister, method]);
392 command.arg(arg);
393 if let Some(output) = output {
394 add_output_arg(&mut command, output);
395 }
396 self.add_target_args(&mut command);
397 run_output(&mut command)
398 }
399
400 pub fn canister_query_arg_output(
402 &self,
403 canister: &str,
404 method: &str,
405 arg: &str,
406 output: Option<&str>,
407 ) -> Result<String, IcpCommandError> {
408 let mut command = self.canister_command();
409 command.args(["call", canister, method]);
410 command.arg(arg);
411 command.arg("--query");
412 if let Some(output) = output {
413 add_output_arg(&mut command, output);
414 }
415 self.add_target_args(&mut command);
416 run_output(&mut command)
417 }
418
419 pub fn canister_metadata_output(
421 &self,
422 canister: &str,
423 metadata_name: &str,
424 ) -> Result<String, IcpCommandError> {
425 let mut command = self.canister_command();
426 command.args(["metadata", canister, metadata_name]);
427 self.add_target_args(&mut command);
428 run_output(&mut command)
429 }
430
431 pub fn canister_status(&self, canister: &str) -> Result<String, IcpCommandError> {
433 let mut command = self.canister_command();
434 command.args(["status", canister]);
435 self.add_target_args(&mut command);
436 run_output(&mut command)
437 }
438
439 pub fn canister_status_report(
441 &self,
442 canister: &str,
443 ) -> Result<IcpCanisterStatusReport, IcpCommandError> {
444 let mut command = self.canister_command();
445 command.args(["status", canister]);
446 command.arg("--json");
447 self.add_target_args(&mut command);
448 run_json(&mut command)
449 }
450
451 pub fn snapshot_create_receipt(
453 &self,
454 canister: &str,
455 ) -> Result<IcpSnapshotCreateReceipt, IcpCommandError> {
456 let mut command = self.canister_command();
457 command.args(["snapshot", "create", canister]);
458 command.arg("--json");
459 self.add_target_args(&mut command);
460 run_json(&mut command)
461 }
462
463 pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
465 Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
466 }
467
468 pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
470 let mut command = self.canister_command();
471 command.args(["stop", canister]);
472 self.add_target_args(&mut command);
473 run_status(&mut command)
474 }
475
476 pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
478 let mut command = self.canister_command();
479 command.args(["start", canister]);
480 self.add_target_args(&mut command);
481 run_status(&mut command)
482 }
483
484 pub fn snapshot_download(
486 &self,
487 canister: &str,
488 snapshot_id: &str,
489 artifact_path: &Path,
490 ) -> Result<(), IcpCommandError> {
491 let mut command = self.canister_command();
492 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
493 command.arg(artifact_path);
494 self.add_target_args(&mut command);
495 run_status(&mut command)
496 }
497
498 pub fn snapshot_upload(
500 &self,
501 canister: &str,
502 artifact_path: &Path,
503 ) -> Result<String, IcpCommandError> {
504 let mut command = self.canister_command();
505 command.args(["snapshot", "upload", canister, "--input"]);
506 command.arg(artifact_path);
507 command.arg("--resume");
508 self.add_target_args(&mut command);
509 run_output_with_stderr(&mut command)
510 }
511
512 pub fn snapshot_restore(
514 &self,
515 canister: &str,
516 snapshot_id: &str,
517 ) -> Result<(), IcpCommandError> {
518 let mut command = self.canister_command();
519 command.args(["snapshot", "restore", canister, snapshot_id]);
520 self.add_target_args(&mut command);
521 run_status(&mut command)
522 }
523
524 #[must_use]
526 pub fn snapshot_create_display(&self, canister: &str) -> String {
527 let mut command = self.canister_command();
528 command.args(["snapshot", "create", canister]);
529 command.arg("--json");
530 self.add_target_args(&mut command);
531 command_display(&command)
532 }
533
534 #[must_use]
536 pub fn snapshot_download_display(
537 &self,
538 canister: &str,
539 snapshot_id: &str,
540 artifact_path: &Path,
541 ) -> String {
542 let mut command = self.canister_command();
543 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
544 command.arg(artifact_path);
545 self.add_target_args(&mut command);
546 command_display(&command)
547 }
548
549 #[must_use]
551 pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
552 let mut command = self.canister_command();
553 command.args(["snapshot", "upload", canister, "--input"]);
554 command.arg(artifact_path);
555 command.arg("--resume");
556 self.add_target_args(&mut command);
557 command_display(&command)
558 }
559
560 #[must_use]
562 pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
563 let mut command = self.canister_command();
564 command.args(["snapshot", "restore", canister, snapshot_id]);
565 self.add_target_args(&mut command);
566 command_display(&command)
567 }
568
569 #[must_use]
571 pub fn stop_canister_display(&self, canister: &str) -> String {
572 let mut command = self.canister_command();
573 command.args(["stop", canister]);
574 self.add_target_args(&mut command);
575 command_display(&command)
576 }
577
578 #[must_use]
580 pub fn start_canister_display(&self, canister: &str) -> String {
581 let mut command = self.canister_command();
582 command.args(["start", canister]);
583 self.add_target_args(&mut command);
584 command_display(&command)
585 }
586
587 fn add_target_args(&self, command: &mut Command) {
588 add_target_args(command, self.environment(), self.network());
589 }
590}
591
592#[must_use]
594pub fn default_command() -> Command {
595 IcpCli::new("icp", None, None).command()
596}
597
598#[must_use]
600pub fn default_command_in(cwd: &Path) -> Command {
601 IcpCli::new("icp", None, None).command_in(cwd)
602}
603
604pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
606 if let Some(environment) = environment {
607 if environment == LOCAL_NETWORK
608 && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
609 {
610 command.env_remove("ICP_ENVIRONMENT");
611 command.arg("-n").arg(url);
612 if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
613 command.arg("-k").arg(root_key);
614 }
615 return;
616 }
617 command.args(["-e", environment]);
618 } else if let Some(network) = network {
619 command.args(["-n", network]);
620 }
621}
622
623pub fn add_output_arg(command: &mut Command, output: &str) {
625 if output == "json" {
626 command.arg("--json");
627 } else {
628 command.args(["--output", output]);
629 }
630}
631
632pub fn add_debug_arg(command: &mut Command, debug: bool) {
634 if debug {
635 command.arg("--debug");
636 }
637}
638
639fn run_local_replica_start_command(
640 command: &mut Command,
641 background: bool,
642 debug: bool,
643) -> Result<String, IcpCommandError> {
644 add_debug_arg(command, debug);
645 if background {
646 command.arg("--background");
647 return run_output_with_stderr(command);
648 }
649 run_status_inherit(command)?;
650 Ok(String::new())
651}
652
653pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
655 let display = command_display(command);
656 let output = command.output()?;
657 if output.status.success() {
658 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
659 } else {
660 Err(IcpCommandError::Failed {
661 command: display,
662 stderr: command_stderr(&output),
663 })
664 }
665}
666
667pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
669 let display = command_display(command);
670 let output = command.output()?;
671 if output.status.success() {
672 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
673 text.push_str(&String::from_utf8_lossy(&output.stderr));
674 Ok(text.trim().to_string())
675 } else {
676 Err(IcpCommandError::Failed {
677 command: display,
678 stderr: command_stderr(&output),
679 })
680 }
681}
682
683pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
685where
686 T: serde::de::DeserializeOwned,
687{
688 let display = command_display(command);
689 let output = command.output()?;
690 if output.status.success() {
691 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
692 serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
693 command: display,
694 output: stdout,
695 source,
696 })
697 } else {
698 Err(IcpCommandError::Failed {
699 command: display,
700 stderr: command_stderr(&output),
701 })
702 }
703}
704
705pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
707 let display = command_display(command);
708 let output = command.output()?;
709 if output.status.success() {
710 Ok(())
711 } else {
712 Err(IcpCommandError::Failed {
713 command: display,
714 stderr: command_stderr(&output),
715 })
716 }
717}
718
719pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
721 let display = command_display(command);
722 let mut child = command
723 .stdout(Stdio::inherit())
724 .stderr(Stdio::piped())
725 .spawn()?;
726 let stderr_handle = child
727 .stderr
728 .take()
729 .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
730 let status = child.wait()?;
731 let stderr = match stderr_handle {
732 Some(handle) => match handle.join() {
733 Ok(result) => result?,
734 Err(_) => Vec::new(),
735 },
736 None => Vec::new(),
737 };
738 if status.success() {
739 Ok(())
740 } else {
741 let stderr = if stderr.is_empty() {
742 format!("command exited with status {}", exit_status_label(status))
743 } else {
744 String::from_utf8_lossy(&stderr).to_string()
745 };
746 Err(IcpCommandError::Failed {
747 command: display,
748 stderr,
749 })
750 }
751}
752
753fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
754 let mut captured = Vec::new();
755 let mut buffer = [0_u8; 8192];
756 let mut terminal = io::stderr().lock();
757 loop {
758 let read = stderr.read(&mut buffer)?;
759 if read == 0 {
760 break;
761 }
762 terminal.write_all(&buffer[..read])?;
763 captured.extend_from_slice(&buffer[..read]);
764 }
765 terminal.flush()?;
766 Ok(captured)
767}
768
769pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
771 Ok(command.output()?.status.success())
772}
773
774pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
776 let output = Command::new(program).args(args).output()?;
777 Ok(IcpRawOutput {
778 success: output.status.success(),
779 status: exit_status_label(output.status),
780 stdout: output.stdout,
781 stderr: output.stderr,
782 })
783}
784
785#[must_use]
787pub fn command_display(command: &Command) -> String {
788 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
789 parts.extend(
790 command
791 .get_args()
792 .map(|arg| arg.to_string_lossy().to_string()),
793 );
794 parts.join(" ")
795}
796
797#[must_use]
799pub fn parse_snapshot_id(output: &str) -> Option<String> {
800 let trimmed = output.trim();
801 if is_snapshot_id_token(trimmed) {
802 return Some(trimmed.to_string());
803 }
804
805 output
806 .lines()
807 .flat_map(|line| {
808 line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
809 })
810 .find(|part| is_snapshot_id_token(part))
811 .map(str::to_string)
812}
813
814fn is_snapshot_id_token(value: &str) -> bool {
816 !value.is_empty()
817 && value.len().is_multiple_of(2)
818 && value.chars().all(|c| c.is_ascii_hexdigit())
819}
820
821fn command_stderr(output: &std::process::Output) -> String {
823 let stderr = String::from_utf8_lossy(&output.stderr);
824 if stderr.trim().is_empty() {
825 String::from_utf8_lossy(&output.stdout).to_string()
826 } else {
827 stderr.to_string()
828 }
829}
830
831fn exit_status_label(status: std::process::ExitStatus) -> String {
833 status
834 .code()
835 .map_or_else(|| "signal".to_string(), |code| code.to_string())
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 #[test]
844 fn renders_environment_target() {
845 let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
846
847 assert_eq!(
848 icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
849 "icp canister snapshot download root snap-1 --output backups/root -e staging"
850 );
851 }
852
853 #[test]
855 fn renders_network_target() {
856 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
857
858 assert_eq!(
859 icp.snapshot_create_display("aaaaa-aa"),
860 "icp canister snapshot create aaaaa-aa --json -n ic"
861 );
862 }
863
864 #[test]
866 fn renders_local_replica_commands() {
867 let icp = IcpCli::new("icp", None, None);
868
869 assert_eq!(
870 icp.local_replica_start_display(true, false),
871 "icp network start local --background"
872 );
873 assert_eq!(
874 icp.local_replica_start_display(false, false),
875 "icp network start local"
876 );
877 assert_eq!(
878 icp.local_replica_start_display(false, true),
879 "icp network start local --debug"
880 );
881 assert_eq!(
882 icp.local_replica_status_display(false),
883 "icp network status local"
884 );
885 assert_eq!(
886 icp.local_replica_status_display(true),
887 "icp network status local --debug"
888 );
889 assert_eq!(
890 icp.local_replica_stop_display(false),
891 "icp network stop local"
892 );
893 assert_eq!(
894 icp.local_replica_stop_display(true),
895 "icp network stop local --debug"
896 );
897 }
898
899 #[test]
901 fn renders_snapshot_restore_flow() {
902 let icp = IcpCli::new("icp", Some("prod".to_string()), None);
903
904 assert_eq!(
905 icp.snapshot_upload_display("root", Path::new("artifact")),
906 "icp canister snapshot upload root --input artifact --resume -e prod"
907 );
908 assert_eq!(
909 icp.snapshot_restore_display("root", "uploaded-1"),
910 "icp canister snapshot restore root uploaded-1 -e prod"
911 );
912 }
913
914 #[test]
916 fn parses_snapshot_id_from_output() {
917 let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
918
919 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
920 }
921
922 #[test]
924 fn parses_snapshot_id_from_table_output() {
925 let output = "\
926ID SIZE CREATED_AT
9270a0b0c0d 1.37 MiB 2026-05-10T17:04:19Z
928";
929
930 let snapshot_id = parse_snapshot_id(output);
931
932 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
933 }
934
935 #[test]
937 fn parses_snapshot_create_receipt_json() {
938 let receipt = serde_json::from_str::<IcpSnapshotCreateReceipt>(
939 r#"{
940 "snapshot_id": "0000000000000000ffffffffffc000020101",
941 "taken_at_timestamp": 1778709681897818005,
942 "total_size_bytes": 272586987
943}"#,
944 )
945 .expect("parse snapshot receipt");
946
947 assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
948 assert_eq!(receipt.total_size_bytes, Some(272_586_987));
949 }
950
951 #[test]
953 fn parses_canister_status_report_json() {
954 let report = serde_json::from_str::<IcpCanisterStatusReport>(
955 r#"{
956 "id": "t63gs-up777-77776-aaaba-cai",
957 "name": "motoko-ex",
958 "status": "Running",
959 "settings": {
960 "controllers": ["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"],
961 "compute_allocation": "0"
962 },
963 "module_hash": "0x66ce5ddcd06f1135c1a04792a2f1b7c3d9e229b977a8fc9762c71ecc5314c9eb",
964 "cycles": "1_497_896_187_059"
965}"#,
966 )
967 .expect("parse status report");
968
969 assert_eq!(report.status, "Running");
970 assert_eq!(
971 report.settings.expect("settings").controllers.as_slice(),
972 &["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"]
973 );
974 }
975}