Skip to main content

canic_host/
icp.rs

1use std::{error::Error, fmt, path::Path, process::Command};
2
3const LOCAL_ENVIRONMENT: &str = "local";
4
5///
6/// IcpRawOutput
7///
8
9#[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///
18/// IcpCommandError
19///
20
21#[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    // Render ICP CLI command failures with the command line and captured diagnostics.
30    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    // Preserve the underlying I/O error as the source when command execution fails locally.
48    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    // Convert process-spawn failures into the shared ICP CLI command error type.
58    fn from(err: std::io::Error) -> Self {
59        Self::Io(err)
60    }
61}
62
63///
64/// IcpCli
65///
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct IcpCli {
69    executable: String,
70    environment: Option<String>,
71    network: Option<String>,
72}
73
74impl IcpCli {
75    /// Build an ICP CLI command context from an executable path and optional target.
76    #[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    /// Return the optional ICP environment name carried by this command context.
90    #[must_use]
91    pub fn environment(&self) -> Option<&str> {
92        self.environment.as_deref()
93    }
94
95    /// Return the optional direct network name carried by this command context.
96    #[must_use]
97    pub fn network(&self) -> Option<&str> {
98        self.network.as_deref()
99    }
100
101    /// Build a base ICP CLI command from this context.
102    #[must_use]
103    pub fn command(&self) -> Command {
104        Command::new(&self.executable)
105    }
106
107    /// Build a base ICP CLI command rooted at one workspace directory.
108    #[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    /// Build an `icp canister ...` command with optional environment args applied.
116    #[must_use]
117    pub fn canister_command(&self) -> Command {
118        let mut command = self.command();
119        command.arg("canister");
120        command
121    }
122
123    /// Resolve the installed ICP CLI version.
124    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    /// Start the local ICP replica.
131    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    /// Return local ICP replica status.
147    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    /// Return whether the local ICP replica responds to ping.
154    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    /// Stop the local ICP replica.
161    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    /// Render a local replica start command.
168    #[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    /// Render a local replica status command.
179    #[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    /// Render a local replica stop command.
187    #[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    /// Call one canister method with optional JSON output.
201    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    /// Call one canister method with an explicit Candid argument and optional JSON output.
218    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    /// Query one canister method with an explicit Candid argument and optional JSON output.
236    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    /// Read one canister metadata section.
255    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    /// Return one canister status report.
267    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    /// Create one canister snapshot and return combined stdout/stderr text.
275    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    /// Create one canister snapshot and resolve the resulting snapshot id.
284    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    /// Stop one canister.
290    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    /// Start one canister.
298    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    /// Download one canister snapshot into an artifact directory.
306    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    /// Upload one snapshot artifact and return the uploaded snapshot id.
320    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    /// Restore one uploaded snapshot onto a canister.
334    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    /// Render a dry-run snapshot-create command.
346    #[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    /// Render a dry-run snapshot-download command.
356    #[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    /// Render a dry-run snapshot-upload command.
371    #[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    /// Render a dry-run snapshot-restore command.
382    #[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    /// Render a dry-run stop command.
391    #[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    /// Render a dry-run start command.
400    #[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/// Build a base `icp` command with the default executable.
414#[must_use]
415pub fn default_command() -> Command {
416    IcpCli::new("icp", None, None).command()
417}
418
419/// Build a base `icp` command rooted at one workspace directory.
420#[must_use]
421pub fn default_command_in(cwd: &Path) -> Command {
422    IcpCli::new("icp", None, None).command_in(cwd)
423}
424
425/// Add optional ICP CLI target arguments, preferring named environments.
426pub 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
434/// Add ICP CLI output formatting, handling JSON as its own flag.
435pub 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
443/// Add ICP CLI debug logging when requested.
444pub fn add_debug_arg(command: &mut Command, debug: bool) {
445    if debug {
446        command.arg("--debug");
447    }
448}
449
450/// Execute a command and capture trimmed stdout.
451pub 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
464/// Execute a command and capture stdout plus stderr on success.
465pub 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
480/// Execute a command and require a successful status.
481pub 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
494/// Execute a command with inherited terminal I/O and require a successful status.
495pub 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
508/// Execute a command and return whether it exits successfully.
509pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
510    Ok(command.output()?.status.success())
511}
512
513/// Execute a rendered ICP CLI command and return raw process output.
514pub 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/// Render a command for diagnostics and dry-run previews.
525#[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/// Parse a likely snapshot id from `icp canister snapshot create` output.
537#[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
553// ICP snapshot ids are rendered as even-length hexadecimal blobs.
554fn 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
560// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
561fn 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
570// Render process exit status without relying on platform-specific internals.
571fn 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    // Keep generated commands tied to ICP CLI environments when one is selected.
582    #[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    // Keep direct network targeting available for local and ad hoc command contexts.
593    #[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    // Keep local replica lifecycle commands explicit and project-scoped.
604    #[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    // Ensure restore planning uses the ICP CLI upload/restore flow.
639    #[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    // Ensure snapshot ids can be extracted from common create output.
654    #[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    // Ensure table units are not mistaken for snapshot ids.
662    #[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}