1use std::{error::Error, fmt, path::Path, process::Command};
2
3const LOCAL_ENVIRONMENT: &str = "local";
4
5#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct IcpRawOutput {
11 pub success: bool,
12 pub status: String,
13 pub stdout: Vec<u8>,
14 pub stderr: Vec<u8>,
15}
16
17#[derive(Debug)]
22pub enum IcpCommandError {
23 Io(std::io::Error),
24 Failed { command: String, stderr: String },
25 SnapshotIdUnavailable { output: String },
26}
27
28impl fmt::Display for IcpCommandError {
29 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 match self {
32 Self::Io(err) => write!(formatter, "{err}"),
33 Self::Failed { command, stderr } => {
34 write!(formatter, "icp command failed: {command}\n{stderr}")
35 }
36 Self::SnapshotIdUnavailable { output } => {
37 write!(
38 formatter,
39 "could not parse snapshot id from icp output: {output}"
40 )
41 }
42 }
43 }
44}
45
46impl Error for IcpCommandError {
47 fn source(&self) -> Option<&(dyn Error + 'static)> {
49 match self {
50 Self::Io(err) => Some(err),
51 Self::Failed { .. } | Self::SnapshotIdUnavailable { .. } => None,
52 }
53 }
54}
55
56impl From<std::io::Error> for IcpCommandError {
57 fn from(err: std::io::Error) -> Self {
59 Self::Io(err)
60 }
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct IcpCli {
69 executable: String,
70 environment: Option<String>,
71 network: Option<String>,
72}
73
74impl IcpCli {
75 #[must_use]
77 pub fn new(
78 executable: impl Into<String>,
79 environment: Option<String>,
80 network: Option<String>,
81 ) -> Self {
82 Self {
83 executable: executable.into(),
84 environment,
85 network,
86 }
87 }
88
89 #[must_use]
91 pub fn environment(&self) -> Option<&str> {
92 self.environment.as_deref()
93 }
94
95 #[must_use]
97 pub fn network(&self) -> Option<&str> {
98 self.network.as_deref()
99 }
100
101 #[must_use]
103 pub fn command(&self) -> Command {
104 Command::new(&self.executable)
105 }
106
107 #[must_use]
109 pub fn command_in(&self, cwd: &Path) -> Command {
110 let mut command = self.command();
111 command.current_dir(cwd);
112 command
113 }
114
115 #[must_use]
117 pub fn canister_command(&self) -> Command {
118 let mut command = self.command();
119 command.arg("canister");
120 command
121 }
122
123 pub fn version(&self) -> Result<String, IcpCommandError> {
125 let mut command = self.command();
126 command.arg("--version");
127 run_output(&mut command)
128 }
129
130 pub fn local_replica_start(
132 &self,
133 background: bool,
134 debug: bool,
135 ) -> Result<String, IcpCommandError> {
136 let mut command = self.local_replica_command("start");
137 add_debug_arg(&mut command, debug);
138 if background {
139 command.arg("--background");
140 return run_output_with_stderr(&mut command);
141 }
142 run_status_inherit(&mut command)?;
143 Ok(String::new())
144 }
145
146 pub fn local_replica_status(&self, debug: bool) -> Result<String, IcpCommandError> {
148 let mut command = self.local_replica_command("status");
149 add_debug_arg(&mut command, debug);
150 run_output_with_stderr(&mut command)
151 }
152
153 pub fn local_replica_ping(&self, debug: bool) -> Result<bool, IcpCommandError> {
155 let mut command = self.local_replica_command("ping");
156 add_debug_arg(&mut command, debug);
157 run_success(&mut command)
158 }
159
160 pub fn local_replica_stop(&self, debug: bool) -> Result<String, IcpCommandError> {
162 let mut command = self.local_replica_command("stop");
163 add_debug_arg(&mut command, debug);
164 run_output_with_stderr(&mut command)
165 }
166
167 #[must_use]
169 pub fn local_replica_start_display(&self, background: bool, debug: bool) -> String {
170 let mut command = self.local_replica_command("start");
171 add_debug_arg(&mut command, debug);
172 if background {
173 command.arg("--background");
174 }
175 command_display(&command)
176 }
177
178 #[must_use]
180 pub fn local_replica_status_display(&self, debug: bool) -> String {
181 let mut command = self.local_replica_command("status");
182 add_debug_arg(&mut command, debug);
183 command_display(&command)
184 }
185
186 #[must_use]
188 pub fn local_replica_stop_display(&self, debug: bool) -> String {
189 let mut command = self.local_replica_command("stop");
190 add_debug_arg(&mut command, debug);
191 command_display(&command)
192 }
193
194 fn local_replica_command(&self, action: &str) -> Command {
195 let mut command = self.command();
196 command.args(["network", action, "-e", LOCAL_ENVIRONMENT]);
197 command
198 }
199
200 pub fn canister_call_output(
202 &self,
203 canister: &str,
204 method: &str,
205 output: Option<&str>,
206 ) -> Result<String, IcpCommandError> {
207 let mut command = self.canister_command();
208 command.args(["call", canister, method]);
209 command.arg("()");
210 if let Some(output) = output {
211 add_output_arg(&mut command, output);
212 }
213 self.add_target_args(&mut command);
214 run_output(&mut command)
215 }
216
217 pub fn canister_call_arg_output(
219 &self,
220 canister: &str,
221 method: &str,
222 arg: &str,
223 output: Option<&str>,
224 ) -> Result<String, IcpCommandError> {
225 let mut command = self.canister_command();
226 command.args(["call", canister, method]);
227 command.arg(arg);
228 if let Some(output) = output {
229 add_output_arg(&mut command, output);
230 }
231 self.add_target_args(&mut command);
232 run_output(&mut command)
233 }
234
235 pub fn canister_query_arg_output(
237 &self,
238 canister: &str,
239 method: &str,
240 arg: &str,
241 output: Option<&str>,
242 ) -> Result<String, IcpCommandError> {
243 let mut command = self.canister_command();
244 command.args(["call", canister, method]);
245 command.arg(arg);
246 command.arg("--query");
247 if let Some(output) = output {
248 add_output_arg(&mut command, output);
249 }
250 self.add_target_args(&mut command);
251 run_output(&mut command)
252 }
253
254 pub fn canister_metadata_output(
256 &self,
257 canister: &str,
258 metadata_name: &str,
259 ) -> Result<String, IcpCommandError> {
260 let mut command = self.canister_command();
261 command.args(["metadata", canister, metadata_name]);
262 self.add_target_args(&mut command);
263 run_output(&mut command)
264 }
265
266 pub fn canister_status(&self, canister: &str) -> Result<String, IcpCommandError> {
268 let mut command = self.canister_command();
269 command.args(["status", canister]);
270 self.add_target_args(&mut command);
271 run_output(&mut command)
272 }
273
274 pub fn snapshot_create(&self, canister: &str) -> Result<String, IcpCommandError> {
276 let mut command = self.canister_command();
277 command.args(["snapshot", "create", canister]);
278 command.arg("--quiet");
279 self.add_target_args(&mut command);
280 run_output(&mut command)
281 }
282
283 pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
285 let output = self.snapshot_create(canister)?;
286 parse_snapshot_id(&output).ok_or(IcpCommandError::SnapshotIdUnavailable { output })
287 }
288
289 pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
291 let mut command = self.canister_command();
292 command.args(["stop", canister]);
293 self.add_target_args(&mut command);
294 run_status(&mut command)
295 }
296
297 pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
299 let mut command = self.canister_command();
300 command.args(["start", canister]);
301 self.add_target_args(&mut command);
302 run_status(&mut command)
303 }
304
305 pub fn snapshot_download(
307 &self,
308 canister: &str,
309 snapshot_id: &str,
310 artifact_path: &Path,
311 ) -> Result<(), IcpCommandError> {
312 let mut command = self.canister_command();
313 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
314 command.arg(artifact_path);
315 self.add_target_args(&mut command);
316 run_status(&mut command)
317 }
318
319 pub fn snapshot_upload(
321 &self,
322 canister: &str,
323 artifact_path: &Path,
324 ) -> Result<String, IcpCommandError> {
325 let mut command = self.canister_command();
326 command.args(["snapshot", "upload", canister, "--input"]);
327 command.arg(artifact_path);
328 command.arg("--resume");
329 self.add_target_args(&mut command);
330 run_output_with_stderr(&mut command)
331 }
332
333 pub fn snapshot_restore(
335 &self,
336 canister: &str,
337 snapshot_id: &str,
338 ) -> Result<(), IcpCommandError> {
339 let mut command = self.canister_command();
340 command.args(["snapshot", "restore", canister, snapshot_id]);
341 self.add_target_args(&mut command);
342 run_status(&mut command)
343 }
344
345 #[must_use]
347 pub fn snapshot_create_display(&self, canister: &str) -> String {
348 let mut command = self.canister_command();
349 command.args(["snapshot", "create", canister]);
350 command.arg("--quiet");
351 self.add_target_args(&mut command);
352 command_display(&command)
353 }
354
355 #[must_use]
357 pub fn snapshot_download_display(
358 &self,
359 canister: &str,
360 snapshot_id: &str,
361 artifact_path: &Path,
362 ) -> String {
363 let mut command = self.canister_command();
364 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
365 command.arg(artifact_path);
366 self.add_target_args(&mut command);
367 command_display(&command)
368 }
369
370 #[must_use]
372 pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
373 let mut command = self.canister_command();
374 command.args(["snapshot", "upload", canister, "--input"]);
375 command.arg(artifact_path);
376 command.arg("--resume");
377 self.add_target_args(&mut command);
378 command_display(&command)
379 }
380
381 #[must_use]
383 pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
384 let mut command = self.canister_command();
385 command.args(["snapshot", "restore", canister, snapshot_id]);
386 self.add_target_args(&mut command);
387 command_display(&command)
388 }
389
390 #[must_use]
392 pub fn stop_canister_display(&self, canister: &str) -> String {
393 let mut command = self.canister_command();
394 command.args(["stop", canister]);
395 self.add_target_args(&mut command);
396 command_display(&command)
397 }
398
399 #[must_use]
401 pub fn start_canister_display(&self, canister: &str) -> String {
402 let mut command = self.canister_command();
403 command.args(["start", canister]);
404 self.add_target_args(&mut command);
405 command_display(&command)
406 }
407
408 fn add_target_args(&self, command: &mut Command) {
409 add_target_args(command, self.environment(), self.network());
410 }
411}
412
413#[must_use]
415pub fn default_command() -> Command {
416 IcpCli::new("icp", None, None).command()
417}
418
419#[must_use]
421pub fn default_command_in(cwd: &Path) -> Command {
422 IcpCli::new("icp", None, None).command_in(cwd)
423}
424
425pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
427 if let Some(environment) = environment {
428 command.args(["-e", environment]);
429 } else if let Some(network) = network {
430 command.args(["-n", network]);
431 }
432}
433
434pub fn add_output_arg(command: &mut Command, output: &str) {
436 if output == "json" {
437 command.arg("--json");
438 } else {
439 command.args(["--output", output]);
440 }
441}
442
443pub fn add_debug_arg(command: &mut Command, debug: bool) {
445 if debug {
446 command.arg("--debug");
447 }
448}
449
450pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
452 let display = command_display(command);
453 let output = command.output()?;
454 if output.status.success() {
455 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
456 } else {
457 Err(IcpCommandError::Failed {
458 command: display,
459 stderr: command_stderr(&output),
460 })
461 }
462}
463
464pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
466 let display = command_display(command);
467 let output = command.output()?;
468 if output.status.success() {
469 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
470 text.push_str(&String::from_utf8_lossy(&output.stderr));
471 Ok(text.trim().to_string())
472 } else {
473 Err(IcpCommandError::Failed {
474 command: display,
475 stderr: command_stderr(&output),
476 })
477 }
478}
479
480pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
482 let display = command_display(command);
483 let output = command.output()?;
484 if output.status.success() {
485 Ok(())
486 } else {
487 Err(IcpCommandError::Failed {
488 command: display,
489 stderr: command_stderr(&output),
490 })
491 }
492}
493
494pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
496 let display = command_display(command);
497 let status = command.status()?;
498 if status.success() {
499 Ok(())
500 } else {
501 Err(IcpCommandError::Failed {
502 command: display,
503 stderr: format!("command exited with status {}", exit_status_label(status)),
504 })
505 }
506}
507
508pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
510 Ok(command.output()?.status.success())
511}
512
513pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
515 let output = Command::new(program).args(args).output()?;
516 Ok(IcpRawOutput {
517 success: output.status.success(),
518 status: exit_status_label(output.status),
519 stdout: output.stdout,
520 stderr: output.stderr,
521 })
522}
523
524#[must_use]
526pub fn command_display(command: &Command) -> String {
527 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
528 parts.extend(
529 command
530 .get_args()
531 .map(|arg| arg.to_string_lossy().to_string()),
532 );
533 parts.join(" ")
534}
535
536#[must_use]
538pub fn parse_snapshot_id(output: &str) -> Option<String> {
539 let trimmed = output.trim();
540 if is_snapshot_id_token(trimmed) {
541 return Some(trimmed.to_string());
542 }
543
544 output
545 .lines()
546 .flat_map(|line| {
547 line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
548 })
549 .find(|part| is_snapshot_id_token(part))
550 .map(str::to_string)
551}
552
553fn is_snapshot_id_token(value: &str) -> bool {
555 !value.is_empty()
556 && value.len().is_multiple_of(2)
557 && value.chars().all(|c| c.is_ascii_hexdigit())
558}
559
560fn command_stderr(output: &std::process::Output) -> String {
562 let stderr = String::from_utf8_lossy(&output.stderr);
563 if stderr.trim().is_empty() {
564 String::from_utf8_lossy(&output.stdout).to_string()
565 } else {
566 stderr.to_string()
567 }
568}
569
570fn exit_status_label(status: std::process::ExitStatus) -> String {
572 status
573 .code()
574 .map_or_else(|| "signal".to_string(), |code| code.to_string())
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
583 fn renders_environment_target() {
584 let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
585
586 assert_eq!(
587 icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
588 "icp canister snapshot download root snap-1 --output backups/root -e staging"
589 );
590 }
591
592 #[test]
594 fn renders_network_target() {
595 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
596
597 assert_eq!(
598 icp.snapshot_create_display("aaaaa-aa"),
599 "icp canister snapshot create aaaaa-aa --quiet -n ic"
600 );
601 }
602
603 #[test]
605 fn renders_local_replica_commands() {
606 let icp = IcpCli::new("icp", None, None);
607
608 assert_eq!(
609 icp.local_replica_start_display(true, false),
610 "icp network start -e local --background"
611 );
612 assert_eq!(
613 icp.local_replica_start_display(false, false),
614 "icp network start -e local"
615 );
616 assert_eq!(
617 icp.local_replica_start_display(false, true),
618 "icp network start -e local --debug"
619 );
620 assert_eq!(
621 icp.local_replica_status_display(false),
622 "icp network status -e local"
623 );
624 assert_eq!(
625 icp.local_replica_status_display(true),
626 "icp network status -e local --debug"
627 );
628 assert_eq!(
629 icp.local_replica_stop_display(false),
630 "icp network stop -e local"
631 );
632 assert_eq!(
633 icp.local_replica_stop_display(true),
634 "icp network stop -e local --debug"
635 );
636 }
637
638 #[test]
640 fn renders_snapshot_restore_flow() {
641 let icp = IcpCli::new("icp", Some("prod".to_string()), None);
642
643 assert_eq!(
644 icp.snapshot_upload_display("root", Path::new("artifact")),
645 "icp canister snapshot upload root --input artifact --resume -e prod"
646 );
647 assert_eq!(
648 icp.snapshot_restore_display("root", "uploaded-1"),
649 "icp canister snapshot restore root uploaded-1 -e prod"
650 );
651 }
652
653 #[test]
655 fn parses_snapshot_id_from_output() {
656 let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
657
658 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
659 }
660
661 #[test]
663 fn parses_snapshot_id_from_table_output() {
664 let output = "\
665ID SIZE CREATED_AT
6660a0b0c0d 1.37 MiB 2026-05-10T17:04:19Z
667";
668
669 let snapshot_id = parse_snapshot_id(output);
670
671 assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
672 }
673}