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 fn add_target_args(&self, command: &mut Command) {
672 add_target_args(command, self.environment(), self.network());
673 }
674}
675
676#[must_use]
678pub fn default_command() -> Command {
679 IcpCli::new("icp", None, None).command()
680}
681
682#[must_use]
684pub fn default_command_in(cwd: &Path) -> Command {
685 IcpCli::new("icp", None, None).command_in(cwd)
686}
687
688pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
690 if let Some(environment) = environment {
691 if environment == LOCAL_NETWORK
692 && let Some(url) = env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV)
693 {
694 command.env_remove("ICP_ENVIRONMENT");
695 command.arg("-n").arg(url);
696 if let Some(root_key) = env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV) {
697 command.arg("-k").arg(root_key);
698 }
699 return;
700 }
701 command.args(["-e", environment]);
702 } else if let Some(network) = network {
703 command.args(["-n", network]);
704 }
705}
706
707pub fn add_output_arg(command: &mut Command, output: &str) {
709 if output == "json" {
710 command.arg("--json");
711 } else {
712 command.args(["--output", output]);
713 }
714}
715
716pub fn add_debug_arg(command: &mut Command, debug: bool) {
718 if debug {
719 command.arg("--debug");
720 }
721}
722
723fn run_local_replica_start_command(
724 command: &mut Command,
725 background: bool,
726 debug: bool,
727) -> Result<String, IcpCommandError> {
728 add_debug_arg(command, debug);
729 if background {
730 command.arg("--background");
731 return run_output_with_stderr(command);
732 }
733 run_status_inherit(command)?;
734 Ok(String::new())
735}
736
737pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
739 let display = command_display(command);
740 let output = command.output()?;
741 if output.status.success() {
742 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
743 } else {
744 Err(IcpCommandError::Failed {
745 command: display,
746 stderr: command_stderr(&output),
747 })
748 }
749}
750
751pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
753 let display = command_display(command);
754 let output = command.output()?;
755 if output.status.success() {
756 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
757 text.push_str(&String::from_utf8_lossy(&output.stderr));
758 Ok(text.trim().to_string())
759 } else {
760 Err(IcpCommandError::Failed {
761 command: display,
762 stderr: command_stderr(&output),
763 })
764 }
765}
766
767pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
769where
770 T: serde::de::DeserializeOwned,
771{
772 let display = command_display(command);
773 let output = command.output()?;
774 if output.status.success() {
775 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
776 serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
777 command: display,
778 output: stdout,
779 source,
780 })
781 } else {
782 Err(IcpCommandError::Failed {
783 command: display,
784 stderr: command_stderr(&output),
785 })
786 }
787}
788
789pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
791 let display = command_display(command);
792 let output = command.output()?;
793 if output.status.success() {
794 Ok(())
795 } else {
796 Err(IcpCommandError::Failed {
797 command: display,
798 stderr: command_stderr(&output),
799 })
800 }
801}
802
803pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
805 let display = command_display(command);
806 let mut child = command
807 .stdout(Stdio::inherit())
808 .stderr(Stdio::piped())
809 .spawn()?;
810 let stderr_handle = child
811 .stderr
812 .take()
813 .map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
814 let status = child.wait()?;
815 let stderr = match stderr_handle {
816 Some(handle) => match handle.join() {
817 Ok(result) => result?,
818 Err(_) => Vec::new(),
819 },
820 None => Vec::new(),
821 };
822 if status.success() {
823 Ok(())
824 } else {
825 let stderr = if stderr.is_empty() {
826 format!("command exited with status {}", exit_status_label(status))
827 } else {
828 String::from_utf8_lossy(&stderr).to_string()
829 };
830 Err(IcpCommandError::Failed {
831 command: display,
832 stderr,
833 })
834 }
835}
836
837fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
838 let mut captured = Vec::new();
839 let mut buffer = [0_u8; 8192];
840 let mut terminal = io::stderr().lock();
841 loop {
842 let read = stderr.read(&mut buffer)?;
843 if read == 0 {
844 break;
845 }
846 terminal.write_all(&buffer[..read])?;
847 captured.extend_from_slice(&buffer[..read]);
848 }
849 terminal.flush()?;
850 Ok(captured)
851}
852
853pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
855 Ok(command.output()?.status.success())
856}
857
858pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
860 let output = Command::new(program).args(args).output()?;
861 Ok(IcpRawOutput {
862 success: output.status.success(),
863 status: exit_status_label(output.status),
864 stdout: output.stdout,
865 stderr: output.stderr,
866 })
867}
868
869#[must_use]
871pub fn command_display(command: &Command) -> String {
872 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
873 parts.extend(
874 command
875 .get_args()
876 .map(|arg| arg.to_string_lossy().to_string()),
877 );
878 parts.join(" ")
879}
880
881#[must_use]
883pub fn parse_snapshot_id(output: &str) -> Option<String> {
884 let trimmed = output.trim();
885 if is_snapshot_id_token(trimmed) {
886 return Some(trimmed.to_string());
887 }
888
889 output
890 .lines()
891 .flat_map(|line| {
892 line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
893 })
894 .find(|part| is_snapshot_id_token(part))
895 .map(str::to_string)
896}
897
898fn is_snapshot_id_token(value: &str) -> bool {
900 !value.is_empty()
901 && value.len().is_multiple_of(2)
902 && value.chars().all(|c| c.is_ascii_hexdigit())
903}
904
905fn command_stderr(output: &std::process::Output) -> String {
907 let stderr = String::from_utf8_lossy(&output.stderr);
908 if stderr.trim().is_empty() {
909 String::from_utf8_lossy(&output.stdout).to_string()
910 } else {
911 stderr.to_string()
912 }
913}
914
915fn exit_status_label(status: std::process::ExitStatus) -> String {
917 status
918 .code()
919 .map_or_else(|| "signal".to_string(), |code| code.to_string())
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925
926 #[test]
928 fn renders_environment_target() {
929 let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
930
931 assert_eq!(
932 icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
933 "icp canister snapshot download root snap-1 --output backups/root -e staging"
934 );
935 }
936
937 #[test]
939 fn renders_network_target() {
940 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
941
942 assert_eq!(
943 icp.snapshot_create_display("aaaaa-aa"),
944 "icp canister snapshot create aaaaa-aa --json -n ic"
945 );
946 }
947
948 #[test]
950 fn renders_local_replica_commands() {
951 let icp = IcpCli::new("icp", None, None);
952
953 assert_eq!(
954 icp.local_replica_start_display(true, false),
955 "icp network start local --background"
956 );
957 assert_eq!(
958 icp.local_replica_start_display(false, false),
959 "icp network start local"
960 );
961 assert_eq!(
962 icp.local_replica_start_display(false, true),
963 "icp network start local --debug"
964 );
965 assert_eq!(
966 icp.local_replica_status_display(false),
967 "icp network status local"
968 );
969 assert_eq!(
970 icp.local_replica_status_display(true),
971 "icp network status local --debug"
972 );
973 assert_eq!(
974 icp.local_replica_stop_display(false),
975 "icp network stop local"
976 );
977 assert_eq!(
978 icp.local_replica_stop_display(true),
979 "icp network stop local --debug"
980 );
981 }
982
983 #[test]
985 fn renders_snapshot_restore_flow() {
986 let icp = IcpCli::new("icp", Some("prod".to_string()), None);
987
988 assert_eq!(
989 icp.snapshot_upload_display("root", Path::new("artifact")),
990 "icp canister snapshot upload root --input artifact --resume --json -e prod"
991 );
992 assert_eq!(
993 icp.snapshot_restore_display("root", "uploaded-1"),
994 "icp canister snapshot restore root uploaded-1 -e prod"
995 );
996 }
997
998 #[test]
1000 fn renders_no_argument_query_call() {
1001 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1002
1003 assert_eq!(
1004 icp.canister_query_output_display("root", "canic_ready", Some("json")),
1005 "icp canister call root canic_ready () --query --json -n ic"
1006 );
1007 }
1008
1009 #[test]
1011 fn renders_canister_top_up() {
1012 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
1013
1014 assert_eq!(
1015 icp.canister_top_up_display("aaaaa-aa", 4_000_000_000_000),
1016 "icp canister top-up --amount 4000000000000 aaaaa-aa -n ic"
1017 );
1018 }
1019
1020 #[test]
1022 fn parses_snapshot_id_from_output() {
1023 let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
1024
1025 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1026 }
1027
1028 #[test]
1030 fn parses_snapshot_id_from_table_output() {
1031 let output = "\
1032ID SIZE CREATED_AT
10330a0b0c0d 1.37 MiB 2026-05-10T17:04:19Z
1034";
1035
1036 let snapshot_id = parse_snapshot_id(output);
1037
1038 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
1039 }
1040
1041 #[test]
1043 fn parses_snapshot_create_receipt_json() {
1044 let receipt = serde_json::from_str::<IcpSnapshotCreateReceipt>(
1045 r#"{
1046 "snapshot_id": "0000000000000000ffffffffffc000020101",
1047 "taken_at_timestamp": 1778709681897818005,
1048 "total_size_bytes": 272586987
1049}"#,
1050 )
1051 .expect("parse snapshot receipt");
1052
1053 assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1054 assert_eq!(receipt.total_size_bytes, Some(272_586_987));
1055 }
1056
1057 #[test]
1059 fn parses_snapshot_upload_receipt_json() {
1060 let receipt = serde_json::from_str::<IcpSnapshotUploadReceipt>(
1061 r#"{
1062 "snapshot_id": "0000000000000000ffffffffffc000020101"
1063}"#,
1064 )
1065 .expect("parse snapshot upload receipt");
1066
1067 assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
1068 }
1069
1070 #[test]
1072 fn parses_canister_status_report_json() {
1073 let report = serde_json::from_str::<IcpCanisterStatusReport>(
1074 r#"{
1075 "id": "t63gs-up777-77776-aaaba-cai",
1076 "name": "motoko-ex",
1077 "status": "Running",
1078 "settings": {
1079 "controllers": ["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"],
1080 "compute_allocation": "0"
1081 },
1082 "module_hash": "0x66ce5ddcd06f1135c1a04792a2f1b7c3d9e229b977a8fc9762c71ecc5314c9eb",
1083 "cycles": "1_497_896_187_059"
1084}"#,
1085 )
1086 .expect("parse status report");
1087
1088 assert_eq!(report.status, "Running");
1089 assert_eq!(
1090 report.settings.expect("settings").controllers.as_slice(),
1091 &["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"]
1092 );
1093 }
1094}