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_status_report(
468 &self,
469 canister: &str,
470 ) -> Result<IcpCanisterStatusReport, IcpCommandError> {
471 let mut command = self.canister_command();
472 command.args(["status", canister]);
473 command.arg("--json");
474 self.add_target_args(&mut command);
475 run_json(&mut command)
476 }
477
478 pub fn snapshot_create_receipt(
480 &self,
481 canister: &str,
482 ) -> Result<IcpSnapshotCreateReceipt, IcpCommandError> {
483 let mut command = self.canister_command();
484 command.args(["snapshot", "create", canister]);
485 command.arg("--json");
486 self.add_target_args(&mut command);
487 run_json(&mut command)
488 }
489
490 pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
492 Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
493 }
494
495 pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
497 let mut command = self.canister_command();
498 command.args(["stop", canister]);
499 self.add_target_args(&mut command);
500 run_status(&mut command)
501 }
502
503 pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
505 let mut command = self.canister_command();
506 command.args(["start", canister]);
507 self.add_target_args(&mut command);
508 run_status(&mut command)
509 }
510
511 pub fn snapshot_download(
513 &self,
514 canister: &str,
515 snapshot_id: &str,
516 artifact_path: &Path,
517 ) -> Result<(), IcpCommandError> {
518 let mut command = self.canister_command();
519 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
520 command.arg(artifact_path);
521 self.add_target_args(&mut command);
522 run_status(&mut command)
523 }
524
525 pub fn snapshot_upload(
527 &self,
528 canister: &str,
529 artifact_path: &Path,
530 ) -> Result<String, IcpCommandError> {
531 Ok(self
532 .snapshot_upload_receipt(canister, artifact_path)?
533 .snapshot_id)
534 }
535
536 pub fn snapshot_upload_receipt(
538 &self,
539 canister: &str,
540 artifact_path: &Path,
541 ) -> Result<IcpSnapshotUploadReceipt, IcpCommandError> {
542 let mut command = self.canister_command();
543 command.args(["snapshot", "upload", canister, "--input"]);
544 command.arg(artifact_path);
545 command.arg("--resume");
546 command.arg("--json");
547 self.add_target_args(&mut command);
548 run_json(&mut command)
549 }
550
551 pub fn snapshot_restore(
553 &self,
554 canister: &str,
555 snapshot_id: &str,
556 ) -> Result<(), IcpCommandError> {
557 let mut command = self.canister_command();
558 command.args(["snapshot", "restore", canister, snapshot_id]);
559 self.add_target_args(&mut command);
560 run_status(&mut command)
561 }
562
563 #[must_use]
565 pub fn snapshot_create_display(&self, canister: &str) -> String {
566 let mut command = self.canister_command();
567 command.args(["snapshot", "create", canister]);
568 command.arg("--json");
569 self.add_target_args(&mut command);
570 command_display(&command)
571 }
572
573 #[must_use]
575 pub fn snapshot_download_display(
576 &self,
577 canister: &str,
578 snapshot_id: &str,
579 artifact_path: &Path,
580 ) -> String {
581 let mut command = self.canister_command();
582 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
583 command.arg(artifact_path);
584 self.add_target_args(&mut command);
585 command_display(&command)
586 }
587
588 #[must_use]
590 pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
591 let mut command = self.canister_command();
592 command.args(["snapshot", "upload", canister, "--input"]);
593 command.arg(artifact_path);
594 command.arg("--resume");
595 command.arg("--json");
596 self.add_target_args(&mut command);
597 command_display(&command)
598 }
599
600 #[must_use]
602 pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
603 let mut command = self.canister_command();
604 command.args(["snapshot", "restore", canister, snapshot_id]);
605 self.add_target_args(&mut command);
606 command_display(&command)
607 }
608
609 #[must_use]
611 pub fn stop_canister_display(&self, canister: &str) -> String {
612 let mut command = self.canister_command();
613 command.args(["stop", canister]);
614 self.add_target_args(&mut command);
615 command_display(&command)
616 }
617
618 #[must_use]
620 pub fn start_canister_display(&self, canister: &str) -> String {
621 let mut command = self.canister_command();
622 command.args(["start", canister]);
623 self.add_target_args(&mut command);
624 command_display(&command)
625 }
626
627 #[must_use]
629 pub fn canister_query_output_display(
630 &self,
631 canister: &str,
632 method: &str,
633 output: Option<&str>,
634 ) -> String {
635 let mut command = self.canister_command();
636 command.args(["call", canister, method]);
637 command.arg("()");
638 command.arg("--query");
639 if let Some(output) = output {
640 add_output_arg(&mut command, output);
641 }
642 self.add_target_args(&mut command);
643 command_display(&command)
644 }
645
646 fn add_target_args(&self, command: &mut Command) {
647 add_target_args(command, self.environment(), self.network());
648 }
649}
650
651#[must_use]
653pub fn default_command() -> Command {
654 IcpCli::new("icp", None, None).command()
655}
656
657#[must_use]
659pub fn default_command_in(cwd: &Path) -> Command {
660 IcpCli::new("icp", None, None).command_in(cwd)
661}
662
663pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
665 if let Some(environment) = environment {
666 if environment == LOCAL_NETWORK
667 && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
668 {
669 command.env_remove("ICP_ENVIRONMENT");
670 command.arg("-n").arg(url);
671 if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
672 command.arg("-k").arg(root_key);
673 }
674 return;
675 }
676 command.args(["-e", environment]);
677 } else if let Some(network) = network {
678 command.args(["-n", network]);
679 }
680}
681
682pub fn add_output_arg(command: &mut Command, output: &str) {
684 if output == "json" {
685 command.arg("--json");
686 } else {
687 command.args(["--output", output]);
688 }
689}
690
691pub fn add_debug_arg(command: &mut Command, debug: bool) {
693 if debug {
694 command.arg("--debug");
695 }
696}
697
698fn run_local_replica_start_command(
699 command: &mut Command,
700 background: bool,
701 debug: bool,
702) -> Result<String, IcpCommandError> {
703 add_debug_arg(command, debug);
704 if background {
705 command.arg("--background");
706 return run_output_with_stderr(command);
707 }
708 run_status_inherit(command)?;
709 Ok(String::new())
710}
711
712pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
714 let display = command_display(command);
715 let output = command.output()?;
716 if output.status.success() {
717 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
718 } else {
719 Err(IcpCommandError::Failed {
720 command: display,
721 stderr: command_stderr(&output),
722 })
723 }
724}
725
726pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
728 let display = command_display(command);
729 let output = command.output()?;
730 if output.status.success() {
731 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
732 text.push_str(&String::from_utf8_lossy(&output.stderr));
733 Ok(text.trim().to_string())
734 } else {
735 Err(IcpCommandError::Failed {
736 command: display,
737 stderr: command_stderr(&output),
738 })
739 }
740}
741
742pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
744where
745 T: serde::de::DeserializeOwned,
746{
747 let display = command_display(command);
748 let output = command.output()?;
749 if output.status.success() {
750 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
751 serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
752 command: display,
753 output: stdout,
754 source,
755 })
756 } else {
757 Err(IcpCommandError::Failed {
758 command: display,
759 stderr: command_stderr(&output),
760 })
761 }
762}
763
764pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
766 let display = command_display(command);
767 let output = command.output()?;
768 if output.status.success() {
769 Ok(())
770 } else {
771 Err(IcpCommandError::Failed {
772 command: display,
773 stderr: command_stderr(&output),
774 })
775 }
776}
777
778pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
780 let display = command_display(command);
781 let mut child = command
782 .stdout(Stdio::inherit())
783 .stderr(Stdio::piped())
784 .spawn()?;
785 let stderr_handle = child
786 .stderr
787 .take()
788 .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
789 let status = child.wait()?;
790 let stderr = match stderr_handle {
791 Some(handle) => match handle.join() {
792 Ok(result) => result?,
793 Err(_) => Vec::new(),
794 },
795 None => Vec::new(),
796 };
797 if status.success() {
798 Ok(())
799 } else {
800 let stderr = if stderr.is_empty() {
801 format!("command exited with status {}", exit_status_label(status))
802 } else {
803 String::from_utf8_lossy(&stderr).to_string()
804 };
805 Err(IcpCommandError::Failed {
806 command: display,
807 stderr,
808 })
809 }
810}
811
812fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
813 let mut captured = Vec::new();
814 let mut buffer = [0_u8; 8192];
815 let mut terminal = io::stderr().lock();
816 loop {
817 let read = stderr.read(&mut buffer)?;
818 if read == 0 {
819 break;
820 }
821 terminal.write_all(&buffer[..read])?;
822 captured.extend_from_slice(&buffer[..read]);
823 }
824 terminal.flush()?;
825 Ok(captured)
826}
827
828pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
830 Ok(command.output()?.status.success())
831}
832
833pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
835 let output = Command::new(program).args(args).output()?;
836 Ok(IcpRawOutput {
837 success: output.status.success(),
838 status: exit_status_label(output.status),
839 stdout: output.stdout,
840 stderr: output.stderr,
841 })
842}
843
844#[must_use]
846pub fn command_display(command: &Command) -> String {
847 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
848 parts.extend(
849 command
850 .get_args()
851 .map(|arg| arg.to_string_lossy().to_string()),
852 );
853 parts.join(" ")
854}
855
856#[must_use]
858pub fn parse_snapshot_id(output: &str) -> Option<String> {
859 let trimmed = output.trim();
860 if is_snapshot_id_token(trimmed) {
861 return Some(trimmed.to_string());
862 }
863
864 output
865 .lines()
866 .flat_map(|line| {
867 line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
868 })
869 .find(|part| is_snapshot_id_token(part))
870 .map(str::to_string)
871}
872
873fn is_snapshot_id_token(value: &str) -> bool {
875 !value.is_empty()
876 && value.len().is_multiple_of(2)
877 && value.chars().all(|c| c.is_ascii_hexdigit())
878}
879
880fn command_stderr(output: &std::process::Output) -> String {
882 let stderr = String::from_utf8_lossy(&output.stderr);
883 if stderr.trim().is_empty() {
884 String::from_utf8_lossy(&output.stdout).to_string()
885 } else {
886 stderr.to_string()
887 }
888}
889
890fn exit_status_label(status: std::process::ExitStatus) -> String {
892 status
893 .code()
894 .map_or_else(|| "signal".to_string(), |code| code.to_string())
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900
901 #[test]
903 fn renders_environment_target() {
904 let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
905
906 assert_eq!(
907 icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
908 "icp canister snapshot download root snap-1 --output backups/root -e staging"
909 );
910 }
911
912 #[test]
914 fn renders_network_target() {
915 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
916
917 assert_eq!(
918 icp.snapshot_create_display("aaaaa-aa"),
919 "icp canister snapshot create aaaaa-aa --json -n ic"
920 );
921 }
922
923 #[test]
925 fn renders_local_replica_commands() {
926 let icp = IcpCli::new("icp", None, None);
927
928 assert_eq!(
929 icp.local_replica_start_display(true, false),
930 "icp network start local --background"
931 );
932 assert_eq!(
933 icp.local_replica_start_display(false, false),
934 "icp network start local"
935 );
936 assert_eq!(
937 icp.local_replica_start_display(false, true),
938 "icp network start local --debug"
939 );
940 assert_eq!(
941 icp.local_replica_status_display(false),
942 "icp network status local"
943 );
944 assert_eq!(
945 icp.local_replica_status_display(true),
946 "icp network status local --debug"
947 );
948 assert_eq!(
949 icp.local_replica_stop_display(false),
950 "icp network stop local"
951 );
952 assert_eq!(
953 icp.local_replica_stop_display(true),
954 "icp network stop local --debug"
955 );
956 }
957
958 #[test]
960 fn renders_snapshot_restore_flow() {
961 let icp = IcpCli::new("icp", Some("prod".to_string()), None);
962
963 assert_eq!(
964 icp.snapshot_upload_display("root", Path::new("artifact")),
965 "icp canister snapshot upload root --input artifact --resume --json -e prod"
966 );
967 assert_eq!(
968 icp.snapshot_restore_display("root", "uploaded-1"),
969 "icp canister snapshot restore root uploaded-1 -e prod"
970 );
971 }
972
973 #[test]
975 fn renders_no_argument_query_call() {
976 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
977
978 assert_eq!(
979 icp.canister_query_output_display("root", "canic_ready", Some("json")),
980 "icp canister call root canic_ready () --query --json -n ic"
981 );
982 }
983
984 #[test]
986 fn parses_snapshot_id_from_output() {
987 let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
988
989 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
990 }
991
992 #[test]
994 fn parses_snapshot_id_from_table_output() {
995 let output = "\
996ID SIZE CREATED_AT
9970a0b0c0d 1.37 MiB 2026-05-10T17:04:19Z
998";
999
1000 let snapshot_id = parse_snapshot_id(output);
1001
1002 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1003 }
1004
1005 #[test]
1007 fn parses_snapshot_create_receipt_json() {
1008 let receipt = serde_json::from_str::<IcpSnapshotCreateReceipt>(
1009 r#"{
1010 "snapshot_id": "0000000000000000ffffffffffc000020101",
1011 "taken_at_timestamp": 1778709681897818005,
1012 "total_size_bytes": 272586987
1013}"#,
1014 )
1015 .expect("parse snapshot receipt");
1016
1017 assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1018 assert_eq!(receipt.total_size_bytes, Some(272_586_987));
1019 }
1020
1021 #[test]
1023 fn parses_snapshot_upload_receipt_json() {
1024 let receipt = serde_json::from_str::<IcpSnapshotUploadReceipt>(
1025 r#"{
1026 "snapshot_id": "0000000000000000ffffffffffc000020101"
1027}"#,
1028 )
1029 .expect("parse snapshot upload receipt");
1030
1031 assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1032 }
1033
1034 #[test]
1036 fn parses_canister_status_report_json() {
1037 let report = serde_json::from_str::<IcpCanisterStatusReport>(
1038 r#"{
1039 "id": "t63gs-up777-77776-aaaba-cai",
1040 "name": "motoko-ex",
1041 "status": "Running",
1042 "settings": {
1043 "controllers": ["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"],
1044 "compute_allocation": "0"
1045 },
1046 "module_hash": "0x66ce5ddcd06f1135c1a04792a2f1b7c3d9e229b977a8fc9762c71ecc5314c9eb",
1047 "cycles": "1_497_896_187_059"
1048}"#,
1049 )
1050 .expect("parse status report");
1051
1052 assert_eq!(report.status, "Running");
1053 assert_eq!(
1054 report.settings.expect("settings").controllers.as_slice(),
1055 &["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"]
1056 );
1057 }
1058}