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