Skip to main content

canic_host/
icp.rs

1use std::{error::Error, fmt, path::Path, process::Command};
2
3///
4/// IcpRawOutput
5///
6
7#[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///
16/// IcpCommandError
17///
18
19#[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    // Render ICP CLI command failures with the command line and captured diagnostics.
28    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    // Preserve the underlying I/O error as the source when command execution fails locally.
46    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    // Convert process-spawn failures into the shared ICP CLI command error type.
56    fn from(err: std::io::Error) -> Self {
57        Self::Io(err)
58    }
59}
60
61///
62/// IcpCli
63///
64
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct IcpCli {
67    executable: String,
68    environment: Option<String>,
69    network: Option<String>,
70}
71
72impl IcpCli {
73    /// Build an ICP CLI command context from an executable path and optional target.
74    #[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    /// Return the optional ICP environment name carried by this command context.
88    #[must_use]
89    pub fn environment(&self) -> Option<&str> {
90        self.environment.as_deref()
91    }
92
93    /// Return the optional direct network name carried by this command context.
94    #[must_use]
95    pub fn network(&self) -> Option<&str> {
96        self.network.as_deref()
97    }
98
99    /// Build a base ICP CLI command from this context.
100    #[must_use]
101    pub fn command(&self) -> Command {
102        Command::new(&self.executable)
103    }
104
105    /// Build a base ICP CLI command rooted at one workspace directory.
106    #[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    /// Build an `icp canister ...` command with optional environment args applied.
114    #[must_use]
115    pub fn canister_command(&self) -> Command {
116        let mut command = self.command();
117        command.arg("canister");
118        command
119    }
120
121    /// Resolve the installed ICP CLI version.
122    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    /// Call one canister method with optional JSON output.
129    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    /// Return one canister status report.
146    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    /// Create one canister snapshot and return combined stdout/stderr text.
154    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    /// Create one canister snapshot and resolve the resulting snapshot id.
162    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    /// Stop one canister.
168    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    /// Start one canister.
176    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    /// Download one canister snapshot into an artifact directory.
184    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    /// Upload one snapshot artifact and return the uploaded snapshot id.
199    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    /// Restore one uploaded snapshot onto a canister.
213    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    /// Render a dry-run snapshot-create command.
225    #[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    /// Render a dry-run snapshot-download command.
234    #[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    /// Render a dry-run snapshot-upload command.
250    #[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    /// Render a dry-run snapshot-restore command.
261    #[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    /// Render a dry-run stop command.
270    #[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    /// Render a dry-run start command.
279    #[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/// Build a base `icp` command with the default executable.
293#[must_use]
294pub fn default_command() -> Command {
295    IcpCli::new("icp", None, None).command()
296}
297
298/// Build a base `icp` command rooted at one workspace directory.
299#[must_use]
300pub fn default_command_in(cwd: &Path) -> Command {
301    IcpCli::new("icp", None, None).command_in(cwd)
302}
303
304/// Add optional ICP CLI target arguments, preferring named environments.
305pub 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
313/// Add ICP CLI output formatting, handling JSON as its own flag.
314pub 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
322/// Execute a command and capture trimmed stdout.
323pub 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
336/// Execute a command and capture stdout plus stderr on success.
337pub 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
352/// Execute a command and require a successful status.
353pub 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
366/// Execute a rendered ICP CLI-compatible command and return raw process output.
367pub 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/// Render a command for diagnostics and dry-run previews.
378#[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/// Parse a likely snapshot id from `icp canister snapshot create` output.
390#[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
403// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
404fn 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
413// Render process exit status without relying on platform-specific internals.
414fn 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    // Keep generated commands tied to ICP CLI environments when one is selected.
425    #[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    // Keep direct network targeting available for local and ad hoc command contexts.
436    #[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    // Ensure restore planning uses the ICP CLI upload/restore flow.
447    #[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    // Ensure snapshot ids can be extracted from common create output.
462    #[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}