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    /// Return one canister status report.
217    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    /// Create one canister snapshot and return combined stdout/stderr text.
225    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    /// Create one canister snapshot and resolve the resulting snapshot id.
233    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    /// Stop one canister.
239    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    /// Start one canister.
247    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    /// Download one canister snapshot into an artifact directory.
255    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    /// Upload one snapshot artifact and return the uploaded snapshot id.
270    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    /// Restore one uploaded snapshot onto a canister.
284    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    /// Render a dry-run snapshot-create command.
296    #[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    /// Render a dry-run snapshot-download command.
305    #[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    /// Render a dry-run snapshot-upload command.
321    #[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    /// Render a dry-run snapshot-restore command.
332    #[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    /// Render a dry-run stop command.
341    #[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    /// Render a dry-run start command.
350    #[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/// Build a base `icp` command with the default executable.
364#[must_use]
365pub fn default_command() -> Command {
366    IcpCli::new("icp", None, None).command()
367}
368
369/// Build a base `icp` command rooted at one workspace directory.
370#[must_use]
371pub fn default_command_in(cwd: &Path) -> Command {
372    IcpCli::new("icp", None, None).command_in(cwd)
373}
374
375/// Add optional ICP CLI target arguments, preferring named environments.
376pub 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
384/// Add ICP CLI output formatting, handling JSON as its own flag.
385pub 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
393/// Add ICP CLI debug logging when requested.
394pub fn add_debug_arg(command: &mut Command, debug: bool) {
395    if debug {
396        command.arg("--debug");
397    }
398}
399
400/// Execute a command and capture trimmed stdout.
401pub 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
414/// Execute a command and capture stdout plus stderr on success.
415pub 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
430/// Execute a command and require a successful status.
431pub 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
444/// Execute a command with inherited terminal I/O and require a successful status.
445pub 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
458/// Execute a command and return whether it exits successfully.
459pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
460    Ok(command.output()?.status.success())
461}
462
463/// Execute a rendered ICP CLI command and return raw process output.
464pub 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/// Render a command for diagnostics and dry-run previews.
475#[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/// Parse a likely snapshot id from `icp canister snapshot create` output.
487#[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
500// Prefer stderr, but keep stdout diagnostics for CLI commands that report there.
501fn 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
510// Render process exit status without relying on platform-specific internals.
511fn 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    // Keep generated commands tied to ICP CLI environments when one is selected.
522    #[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    // Keep direct network targeting available for local and ad hoc command contexts.
533    #[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    // Keep local replica lifecycle commands explicit and project-scoped.
544    #[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    // Ensure restore planning uses the ICP CLI upload/restore flow.
579    #[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    // Ensure snapshot ids can be extracted from common create output.
594    #[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}