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_status(&self, canister: &str) -> Result<String, IcpCommandError> {
218 let mut command = self.canister_command();
219 command.args(["status", canister]);
220 self.add_target_args(&mut command);
221 run_output(&mut command)
222 }
223
224 pub fn snapshot_create(&self, canister: &str) -> Result<String, IcpCommandError> {
226 let mut command = self.canister_command();
227 command.args(["snapshot", "create", canister]);
228 self.add_target_args(&mut command);
229 run_output_with_stderr(&mut command)
230 }
231
232 pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
234 let output = self.snapshot_create(canister)?;
235 parse_snapshot_id(&output).ok_or(IcpCommandError::SnapshotIdUnavailable { output })
236 }
237
238 pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
240 let mut command = self.canister_command();
241 command.args(["stop", canister]);
242 self.add_target_args(&mut command);
243 run_status(&mut command)
244 }
245
246 pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
248 let mut command = self.canister_command();
249 command.args(["start", canister]);
250 self.add_target_args(&mut command);
251 run_status(&mut command)
252 }
253
254 pub fn snapshot_download(
256 &self,
257 canister: &str,
258 snapshot_id: &str,
259 artifact_path: &Path,
260 ) -> Result<(), IcpCommandError> {
261 let mut command = self.canister_command();
262 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
263 command.arg(artifact_path);
264 command.arg("--resume");
265 self.add_target_args(&mut command);
266 run_status(&mut command)
267 }
268
269 pub fn snapshot_upload(
271 &self,
272 canister: &str,
273 artifact_path: &Path,
274 ) -> Result<String, IcpCommandError> {
275 let mut command = self.canister_command();
276 command.args(["snapshot", "upload", canister, "--input"]);
277 command.arg(artifact_path);
278 command.arg("--resume");
279 self.add_target_args(&mut command);
280 run_output_with_stderr(&mut command)
281 }
282
283 pub fn snapshot_restore(
285 &self,
286 canister: &str,
287 snapshot_id: &str,
288 ) -> Result<(), IcpCommandError> {
289 let mut command = self.canister_command();
290 command.args(["snapshot", "restore", canister, snapshot_id]);
291 self.add_target_args(&mut command);
292 run_status(&mut command)
293 }
294
295 #[must_use]
297 pub fn snapshot_create_display(&self, canister: &str) -> String {
298 let mut command = self.canister_command();
299 command.args(["snapshot", "create", canister]);
300 self.add_target_args(&mut command);
301 command_display(&command)
302 }
303
304 #[must_use]
306 pub fn snapshot_download_display(
307 &self,
308 canister: &str,
309 snapshot_id: &str,
310 artifact_path: &Path,
311 ) -> String {
312 let mut command = self.canister_command();
313 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
314 command.arg(artifact_path);
315 command.arg("--resume");
316 self.add_target_args(&mut command);
317 command_display(&command)
318 }
319
320 #[must_use]
322 pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
323 let mut command = self.canister_command();
324 command.args(["snapshot", "upload", canister, "--input"]);
325 command.arg(artifact_path);
326 command.arg("--resume");
327 self.add_target_args(&mut command);
328 command_display(&command)
329 }
330
331 #[must_use]
333 pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
334 let mut command = self.canister_command();
335 command.args(["snapshot", "restore", canister, snapshot_id]);
336 self.add_target_args(&mut command);
337 command_display(&command)
338 }
339
340 #[must_use]
342 pub fn stop_canister_display(&self, canister: &str) -> String {
343 let mut command = self.canister_command();
344 command.args(["stop", canister]);
345 self.add_target_args(&mut command);
346 command_display(&command)
347 }
348
349 #[must_use]
351 pub fn start_canister_display(&self, canister: &str) -> String {
352 let mut command = self.canister_command();
353 command.args(["start", canister]);
354 self.add_target_args(&mut command);
355 command_display(&command)
356 }
357
358 fn add_target_args(&self, command: &mut Command) {
359 add_target_args(command, self.environment(), self.network());
360 }
361}
362
363#[must_use]
365pub fn default_command() -> Command {
366 IcpCli::new("icp", None, None).command()
367}
368
369#[must_use]
371pub fn default_command_in(cwd: &Path) -> Command {
372 IcpCli::new("icp", None, None).command_in(cwd)
373}
374
375pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
377 if let Some(environment) = environment {
378 command.args(["-e", environment]);
379 } else if let Some(network) = network {
380 command.args(["-n", network]);
381 }
382}
383
384pub fn add_output_arg(command: &mut Command, output: &str) {
386 if output == "json" {
387 command.arg("--json");
388 } else {
389 command.args(["--output", output]);
390 }
391}
392
393pub fn add_debug_arg(command: &mut Command, debug: bool) {
395 if debug {
396 command.arg("--debug");
397 }
398}
399
400pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
402 let display = command_display(command);
403 let output = command.output()?;
404 if output.status.success() {
405 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
406 } else {
407 Err(IcpCommandError::Failed {
408 command: display,
409 stderr: command_stderr(&output),
410 })
411 }
412}
413
414pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
416 let display = command_display(command);
417 let output = command.output()?;
418 if output.status.success() {
419 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
420 text.push_str(&String::from_utf8_lossy(&output.stderr));
421 Ok(text.trim().to_string())
422 } else {
423 Err(IcpCommandError::Failed {
424 command: display,
425 stderr: command_stderr(&output),
426 })
427 }
428}
429
430pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
432 let display = command_display(command);
433 let output = command.output()?;
434 if output.status.success() {
435 Ok(())
436 } else {
437 Err(IcpCommandError::Failed {
438 command: display,
439 stderr: command_stderr(&output),
440 })
441 }
442}
443
444pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
446 let display = command_display(command);
447 let status = command.status()?;
448 if status.success() {
449 Ok(())
450 } else {
451 Err(IcpCommandError::Failed {
452 command: display,
453 stderr: format!("command exited with status {}", exit_status_label(status)),
454 })
455 }
456}
457
458pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
460 Ok(command.output()?.status.success())
461}
462
463pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
465 let output = Command::new(program).args(args).output()?;
466 Ok(IcpRawOutput {
467 success: output.status.success(),
468 status: exit_status_label(output.status),
469 stdout: output.stdout,
470 stderr: output.stderr,
471 })
472}
473
474#[must_use]
476pub fn command_display(command: &Command) -> String {
477 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
478 parts.extend(
479 command
480 .get_args()
481 .map(|arg| arg.to_string_lossy().to_string()),
482 );
483 parts.join(" ")
484}
485
486#[must_use]
488pub fn parse_snapshot_id(output: &str) -> Option<String> {
489 output
490 .split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
491 .filter(|part| !part.is_empty())
492 .rev()
493 .find(|part| {
494 part.chars()
495 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
496 })
497 .map(str::to_string)
498}
499
500fn command_stderr(output: &std::process::Output) -> String {
502 let stderr = String::from_utf8_lossy(&output.stderr);
503 if stderr.trim().is_empty() {
504 String::from_utf8_lossy(&output.stdout).to_string()
505 } else {
506 stderr.to_string()
507 }
508}
509
510fn exit_status_label(status: std::process::ExitStatus) -> String {
512 status
513 .code()
514 .map_or_else(|| "signal".to_string(), |code| code.to_string())
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
523 fn renders_environment_target() {
524 let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
525
526 assert_eq!(
527 icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
528 "icp canister snapshot download root snap-1 --output backups/root --resume -e staging"
529 );
530 }
531
532 #[test]
534 fn renders_network_target() {
535 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
536
537 assert_eq!(
538 icp.snapshot_create_display("aaaaa-aa"),
539 "icp canister snapshot create aaaaa-aa -n ic"
540 );
541 }
542
543 #[test]
545 fn renders_local_replica_commands() {
546 let icp = IcpCli::new("icp", None, None);
547
548 assert_eq!(
549 icp.local_replica_start_display(true, false),
550 "icp network start local --background"
551 );
552 assert_eq!(
553 icp.local_replica_start_display(false, false),
554 "icp network start local"
555 );
556 assert_eq!(
557 icp.local_replica_start_display(false, true),
558 "icp network start local --debug"
559 );
560 assert_eq!(
561 icp.local_replica_status_display(false),
562 "icp network status local"
563 );
564 assert_eq!(
565 icp.local_replica_status_display(true),
566 "icp network status local --debug"
567 );
568 assert_eq!(
569 icp.local_replica_stop_display(false),
570 "icp network stop local"
571 );
572 assert_eq!(
573 icp.local_replica_stop_display(true),
574 "icp network stop local --debug"
575 );
576 }
577
578 #[test]
580 fn renders_snapshot_restore_flow() {
581 let icp = IcpCli::new("icp", Some("prod".to_string()), None);
582
583 assert_eq!(
584 icp.snapshot_upload_display("root", Path::new("artifact")),
585 "icp canister snapshot upload root --input artifact --resume -e prod"
586 );
587 assert_eq!(
588 icp.snapshot_restore_display("root", "uploaded-1"),
589 "icp canister snapshot restore root uploaded-1 -e prod"
590 );
591 }
592
593 #[test]
595 fn parses_snapshot_id_from_output() {
596 let snapshot_id = parse_snapshot_id("Created snapshot: snap_abc-123\n");
597
598 assert_eq!(snapshot_id.as_deref(), Some("snap_abc-123"));
599 }
600}