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    /// Start the local ICP replica.
129    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    /// Return local ICP replica status.
146    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    /// 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.command();
156        command.args(["network", "ping", "local"]);
157        add_debug_arg(&mut command, debug);
158        run_success(&mut command)
159    }
160
161    /// Stop the local ICP replica.
162    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    /// Render a local replica start command.
170    #[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    /// Render a local replica status command.
182    #[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    /// Render a local replica stop command.
191    #[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    /// Call one canister method with optional JSON output.
200    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    /// Read one canister metadata section.
217    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    /// Return one canister status report.
229    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    /// Create one canister snapshot and return combined stdout/stderr text.
237    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    /// Create one canister snapshot and resolve the resulting snapshot id.
246    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    /// Stop one canister.
252    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    /// Start one canister.
260    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    /// Download one canister snapshot into an artifact directory.
268    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    /// Upload one snapshot artifact and return the uploaded snapshot id.
282    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    /// Restore one uploaded snapshot onto a canister.
296    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    /// Render a dry-run snapshot-create command.
308    #[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    /// Render a dry-run snapshot-download command.
318    #[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    /// Render a dry-run snapshot-upload command.
333    #[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    /// Render a dry-run snapshot-restore command.
344    #[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    /// Render a dry-run stop command.
353    #[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    /// Render a dry-run start command.
362    #[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/// Build a base `icp` command with the default executable.
376#[must_use]
377pub fn default_command() -> Command {
378    IcpCli::new("icp", None, None).command()
379}
380
381/// Build a base `icp` command rooted at one workspace directory.
382#[must_use]
383pub fn default_command_in(cwd: &Path) -> Command {
384    IcpCli::new("icp", None, None).command_in(cwd)
385}
386
387/// Add optional ICP CLI target arguments, preferring named environments.
388pub 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
396/// Add ICP CLI output formatting, handling JSON as its own flag.
397pub 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
405/// Add ICP CLI debug logging when requested.
406pub fn add_debug_arg(command: &mut Command, debug: bool) {
407    if debug {
408        command.arg("--debug");
409    }
410}
411
412/// Execute a command and capture trimmed stdout.
413pub 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
426/// Execute a command and capture stdout plus stderr on success.
427pub 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
442/// Execute a command and require a successful status.
443pub 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
456/// Execute a command with inherited terminal I/O and require a successful status.
457pub 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
470/// Execute a command and return whether it exits successfully.
471pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
472    Ok(command.output()?.status.success())
473}
474
475/// Execute a rendered ICP CLI command and return raw process output.
476pub 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/// Render a command for diagnostics and dry-run previews.
487#[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/// Parse a likely snapshot id from `icp canister snapshot create` output.
499#[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
515// ICP snapshot ids are rendered as even-length hexadecimal blobs.
516fn 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
522// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
523fn 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
532// Render process exit status without relying on platform-specific internals.
533fn 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    // Keep generated commands tied to ICP CLI environments when one is selected.
544    #[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    // Keep direct network targeting available for local and ad hoc command contexts.
555    #[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    // Keep local replica lifecycle commands explicit and project-scoped.
566    #[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    // Ensure restore planning uses the ICP CLI upload/restore flow.
601    #[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    // Ensure snapshot ids can be extracted from common create output.
616    #[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    // Ensure table units are not mistaken for snapshot ids.
624    #[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}