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 canister_call_output(
130 &self,
131 canister: &str,
132 method: &str,
133 output: Option<&str>,
134 ) -> Result<String, IcpCommandError> {
135 let mut command = self.canister_command();
136 command.args(["call", canister, method]);
137 command.arg("()");
138 if let Some(output) = output {
139 add_output_arg(&mut command, output);
140 }
141 self.add_target_args(&mut command);
142 run_output(&mut command)
143 }
144
145 pub fn canister_status(&self, canister: &str) -> Result<String, IcpCommandError> {
147 let mut command = self.canister_command();
148 command.args(["status", canister]);
149 self.add_target_args(&mut command);
150 run_output(&mut command)
151 }
152
153 pub fn snapshot_create(&self, canister: &str) -> Result<String, IcpCommandError> {
155 let mut command = self.canister_command();
156 command.args(["snapshot", "create", canister]);
157 self.add_target_args(&mut command);
158 run_output_with_stderr(&mut command)
159 }
160
161 pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
163 let output = self.snapshot_create(canister)?;
164 parse_snapshot_id(&output).ok_or(IcpCommandError::SnapshotIdUnavailable { output })
165 }
166
167 pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
169 let mut command = self.canister_command();
170 command.args(["stop", canister]);
171 self.add_target_args(&mut command);
172 run_status(&mut command)
173 }
174
175 pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
177 let mut command = self.canister_command();
178 command.args(["start", canister]);
179 self.add_target_args(&mut command);
180 run_status(&mut command)
181 }
182
183 pub fn snapshot_download(
185 &self,
186 canister: &str,
187 snapshot_id: &str,
188 artifact_path: &Path,
189 ) -> Result<(), IcpCommandError> {
190 let mut command = self.canister_command();
191 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
192 command.arg(artifact_path);
193 command.arg("--resume");
194 self.add_target_args(&mut command);
195 run_status(&mut command)
196 }
197
198 pub fn snapshot_upload(
200 &self,
201 canister: &str,
202 artifact_path: &Path,
203 ) -> Result<String, IcpCommandError> {
204 let mut command = self.canister_command();
205 command.args(["snapshot", "upload", canister, "--input"]);
206 command.arg(artifact_path);
207 command.arg("--resume");
208 self.add_target_args(&mut command);
209 run_output_with_stderr(&mut command)
210 }
211
212 pub fn snapshot_restore(
214 &self,
215 canister: &str,
216 snapshot_id: &str,
217 ) -> Result<(), IcpCommandError> {
218 let mut command = self.canister_command();
219 command.args(["snapshot", "restore", canister, snapshot_id]);
220 self.add_target_args(&mut command);
221 run_status(&mut command)
222 }
223
224 #[must_use]
226 pub fn snapshot_create_display(&self, canister: &str) -> String {
227 let mut command = self.canister_command();
228 command.args(["snapshot", "create", canister]);
229 self.add_target_args(&mut command);
230 command_display(&command)
231 }
232
233 #[must_use]
235 pub fn snapshot_download_display(
236 &self,
237 canister: &str,
238 snapshot_id: &str,
239 artifact_path: &Path,
240 ) -> String {
241 let mut command = self.canister_command();
242 command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
243 command.arg(artifact_path);
244 command.arg("--resume");
245 self.add_target_args(&mut command);
246 command_display(&command)
247 }
248
249 #[must_use]
251 pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
252 let mut command = self.canister_command();
253 command.args(["snapshot", "upload", canister, "--input"]);
254 command.arg(artifact_path);
255 command.arg("--resume");
256 self.add_target_args(&mut command);
257 command_display(&command)
258 }
259
260 #[must_use]
262 pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
263 let mut command = self.canister_command();
264 command.args(["snapshot", "restore", canister, snapshot_id]);
265 self.add_target_args(&mut command);
266 command_display(&command)
267 }
268
269 #[must_use]
271 pub fn stop_canister_display(&self, canister: &str) -> String {
272 let mut command = self.canister_command();
273 command.args(["stop", canister]);
274 self.add_target_args(&mut command);
275 command_display(&command)
276 }
277
278 #[must_use]
280 pub fn start_canister_display(&self, canister: &str) -> String {
281 let mut command = self.canister_command();
282 command.args(["start", canister]);
283 self.add_target_args(&mut command);
284 command_display(&command)
285 }
286
287 fn add_target_args(&self, command: &mut Command) {
288 add_target_args(command, self.environment(), self.network());
289 }
290}
291
292#[must_use]
294pub fn default_command() -> Command {
295 IcpCli::new("icp", None, None).command()
296}
297
298#[must_use]
300pub fn default_command_in(cwd: &Path) -> Command {
301 IcpCli::new("icp", None, None).command_in(cwd)
302}
303
304pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
306 if let Some(environment) = environment {
307 command.args(["-e", environment]);
308 } else if let Some(network) = network {
309 command.args(["-n", network]);
310 }
311}
312
313pub fn add_output_arg(command: &mut Command, output: &str) {
315 if output == "json" {
316 command.arg("--json");
317 } else {
318 command.args(["--output", output]);
319 }
320}
321
322pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
324 let display = command_display(command);
325 let output = command.output()?;
326 if output.status.success() {
327 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
328 } else {
329 Err(IcpCommandError::Failed {
330 command: display,
331 stderr: command_stderr(&output),
332 })
333 }
334}
335
336pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
338 let display = command_display(command);
339 let output = command.output()?;
340 if output.status.success() {
341 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
342 text.push_str(&String::from_utf8_lossy(&output.stderr));
343 Ok(text.trim().to_string())
344 } else {
345 Err(IcpCommandError::Failed {
346 command: display,
347 stderr: command_stderr(&output),
348 })
349 }
350}
351
352pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
354 let display = command_display(command);
355 let output = command.output()?;
356 if output.status.success() {
357 Ok(())
358 } else {
359 Err(IcpCommandError::Failed {
360 command: display,
361 stderr: command_stderr(&output),
362 })
363 }
364}
365
366pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
368 let output = Command::new(program).args(args).output()?;
369 Ok(IcpRawOutput {
370 success: output.status.success(),
371 status: exit_status_label(output.status),
372 stdout: output.stdout,
373 stderr: output.stderr,
374 })
375}
376
377#[must_use]
379pub fn command_display(command: &Command) -> String {
380 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
381 parts.extend(
382 command
383 .get_args()
384 .map(|arg| arg.to_string_lossy().to_string()),
385 );
386 parts.join(" ")
387}
388
389#[must_use]
391pub fn parse_snapshot_id(output: &str) -> Option<String> {
392 output
393 .split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
394 .filter(|part| !part.is_empty())
395 .rev()
396 .find(|part| {
397 part.chars()
398 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
399 })
400 .map(str::to_string)
401}
402
403fn command_stderr(output: &std::process::Output) -> String {
405 let stderr = String::from_utf8_lossy(&output.stderr);
406 if stderr.trim().is_empty() {
407 String::from_utf8_lossy(&output.stdout).to_string()
408 } else {
409 stderr.to_string()
410 }
411}
412
413fn exit_status_label(status: std::process::ExitStatus) -> String {
415 status
416 .code()
417 .map_or_else(|| "signal".to_string(), |code| code.to_string())
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
426 fn renders_environment_target() {
427 let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
428
429 assert_eq!(
430 icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
431 "icp canister snapshot download root snap-1 --output backups/root --resume -e staging"
432 );
433 }
434
435 #[test]
437 fn renders_network_target() {
438 let icp = IcpCli::new("icp", None, Some("ic".to_string()));
439
440 assert_eq!(
441 icp.snapshot_create_display("aaaaa-aa"),
442 "icp canister snapshot create aaaaa-aa -n ic"
443 );
444 }
445
446 #[test]
448 fn renders_snapshot_restore_flow() {
449 let icp = IcpCli::new("icp", Some("prod".to_string()), None);
450
451 assert_eq!(
452 icp.snapshot_upload_display("root", Path::new("artifact")),
453 "icp canister snapshot upload root --input artifact --resume -e prod"
454 );
455 assert_eq!(
456 icp.snapshot_restore_display("root", "uploaded-1"),
457 "icp canister snapshot restore root uploaded-1 -e prod"
458 );
459 }
460
461 #[test]
463 fn parses_snapshot_id_from_output() {
464 let snapshot_id = parse_snapshot_id("Created snapshot: snap_abc-123\n");
465
466 assert_eq!(snapshot_id.as_deref(), Some("snap_abc-123"));
467 }
468}