use std::{
error::Error,
fmt,
io::{self, Read, Write},
path::Path,
process::{Command, Stdio},
thread,
};
use serde::{Deserialize, Serialize};
const LOCAL_NETWORK: &str = "local";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IcpRawOutput {
pub success: bool,
pub status: String,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
#[derive(Debug)]
pub enum IcpCommandError {
Io(std::io::Error),
Failed {
command: String,
stderr: String,
},
Json {
command: String,
output: String,
source: serde_json::Error,
},
SnapshotIdUnavailable {
output: String,
},
}
impl fmt::Display for IcpCommandError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(formatter, "{err}"),
Self::Failed { command, stderr } => {
write!(formatter, "icp command failed: {command}\n{stderr}")
}
Self::Json {
command,
output,
source,
} => {
write!(
formatter,
"could not parse icp json output for {command}: {source}\n{output}"
)
}
Self::SnapshotIdUnavailable { output } => {
write!(
formatter,
"could not parse snapshot id from icp output: {output}"
)
}
}
}
}
impl Error for IcpCommandError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Json { source, .. } => Some(source),
Self::Failed { .. } | Self::SnapshotIdUnavailable { .. } => None,
}
}
}
impl From<std::io::Error> for IcpCommandError {
fn from(err: std::io::Error) -> Self {
Self::Io(err)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IcpCli {
executable: String,
environment: Option<String>,
network: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct IcpSnapshotCreateReceipt {
pub snapshot_id: String,
pub taken_at_timestamp: Option<u64>,
pub total_size_bytes: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct IcpCanisterStatusReport {
pub id: String,
pub name: Option<String>,
pub status: String,
pub settings: Option<IcpCanisterStatusSettings>,
pub module_hash: Option<String>,
pub memory_size: Option<String>,
pub cycles: Option<String>,
pub reserved_cycles: Option<String>,
pub idle_cycles_burned_per_day: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct IcpCanisterStatusSettings {
#[serde(default)]
pub controllers: Vec<String>,
pub compute_allocation: Option<String>,
pub memory_allocation: Option<String>,
pub freezing_threshold: Option<String>,
pub reserved_cycles_limit: Option<String>,
pub wasm_memory_limit: Option<String>,
pub wasm_memory_threshold: Option<String>,
pub log_memory_limit: Option<String>,
}
impl IcpCli {
#[must_use]
pub fn new(
executable: impl Into<String>,
environment: Option<String>,
network: Option<String>,
) -> Self {
Self {
executable: executable.into(),
environment,
network,
}
}
#[must_use]
pub fn environment(&self) -> Option<&str> {
self.environment.as_deref()
}
#[must_use]
pub fn network(&self) -> Option<&str> {
self.network.as_deref()
}
#[must_use]
pub fn command(&self) -> Command {
Command::new(&self.executable)
}
#[must_use]
pub fn command_in(&self, cwd: &Path) -> Command {
let mut command = self.command();
command.current_dir(cwd);
command
}
#[must_use]
pub fn canister_command(&self) -> Command {
let mut command = self.command();
command.arg("canister");
command
}
pub fn version(&self) -> Result<String, IcpCommandError> {
let mut command = self.command();
command.arg("--version");
run_output(&mut command)
}
pub fn local_replica_start(
&self,
background: bool,
debug: bool,
) -> Result<String, IcpCommandError> {
let mut command = self.local_replica_command("start");
add_debug_arg(&mut command, debug);
if background {
command.arg("--background");
return run_output_with_stderr(&mut command);
}
run_status_inherit(&mut command)?;
Ok(String::new())
}
pub fn local_replica_status(&self, debug: bool) -> Result<String, IcpCommandError> {
let mut command = self.local_replica_command("status");
add_debug_arg(&mut command, debug);
run_output_with_stderr(&mut command)
}
pub fn local_replica_status_json(
&self,
debug: bool,
) -> Result<serde_json::Value, IcpCommandError> {
let mut command = self.local_replica_command("status");
add_debug_arg(&mut command, debug);
command.arg("--json");
run_json(&mut command)
}
pub fn local_replica_project_running(&self, debug: bool) -> Result<bool, IcpCommandError> {
let mut command = self.local_replica_command("status");
add_debug_arg(&mut command, debug);
run_success(&mut command)
}
pub fn local_replica_ping(&self, debug: bool) -> Result<bool, IcpCommandError> {
let mut command = self.local_replica_command("ping");
add_debug_arg(&mut command, debug);
run_success(&mut command)
}
pub fn local_replica_stop(&self, debug: bool) -> Result<String, IcpCommandError> {
let mut command = self.local_replica_command("stop");
add_debug_arg(&mut command, debug);
run_output_with_stderr(&mut command)
}
#[must_use]
pub fn local_replica_start_display(&self, background: bool, debug: bool) -> String {
let mut command = self.local_replica_command("start");
add_debug_arg(&mut command, debug);
if background {
command.arg("--background");
}
command_display(&command)
}
#[must_use]
pub fn local_replica_status_display(&self, debug: bool) -> String {
let mut command = self.local_replica_command("status");
add_debug_arg(&mut command, debug);
command_display(&command)
}
#[must_use]
pub fn local_replica_stop_display(&self, debug: bool) -> String {
let mut command = self.local_replica_command("stop");
add_debug_arg(&mut command, debug);
command_display(&command)
}
fn local_replica_command(&self, action: &str) -> Command {
let mut command = self.command();
command.args(["network", action, LOCAL_NETWORK]);
command
}
pub fn canister_call_output(
&self,
canister: &str,
method: &str,
output: Option<&str>,
) -> Result<String, IcpCommandError> {
let mut command = self.canister_command();
command.args(["call", canister, method]);
command.arg("()");
if let Some(output) = output {
add_output_arg(&mut command, output);
}
self.add_target_args(&mut command);
run_output(&mut command)
}
pub fn canister_call_arg_output(
&self,
canister: &str,
method: &str,
arg: &str,
output: Option<&str>,
) -> Result<String, IcpCommandError> {
let mut command = self.canister_command();
command.args(["call", canister, method]);
command.arg(arg);
if let Some(output) = output {
add_output_arg(&mut command, output);
}
self.add_target_args(&mut command);
run_output(&mut command)
}
pub fn canister_query_arg_output(
&self,
canister: &str,
method: &str,
arg: &str,
output: Option<&str>,
) -> Result<String, IcpCommandError> {
let mut command = self.canister_command();
command.args(["call", canister, method]);
command.arg(arg);
command.arg("--query");
if let Some(output) = output {
add_output_arg(&mut command, output);
}
self.add_target_args(&mut command);
run_output(&mut command)
}
pub fn canister_metadata_output(
&self,
canister: &str,
metadata_name: &str,
) -> Result<String, IcpCommandError> {
let mut command = self.canister_command();
command.args(["metadata", canister, metadata_name]);
self.add_target_args(&mut command);
run_output(&mut command)
}
pub fn canister_status(&self, canister: &str) -> Result<String, IcpCommandError> {
let mut command = self.canister_command();
command.args(["status", canister]);
self.add_target_args(&mut command);
run_output(&mut command)
}
pub fn canister_status_report(
&self,
canister: &str,
) -> Result<IcpCanisterStatusReport, IcpCommandError> {
let mut command = self.canister_command();
command.args(["status", canister]);
command.arg("--json");
self.add_target_args(&mut command);
run_json(&mut command)
}
pub fn snapshot_create_receipt(
&self,
canister: &str,
) -> Result<IcpSnapshotCreateReceipt, IcpCommandError> {
let mut command = self.canister_command();
command.args(["snapshot", "create", canister]);
command.arg("--json");
self.add_target_args(&mut command);
run_json(&mut command)
}
pub fn snapshot_create_id(&self, canister: &str) -> Result<String, IcpCommandError> {
Ok(self.snapshot_create_receipt(canister)?.snapshot_id)
}
pub fn stop_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
let mut command = self.canister_command();
command.args(["stop", canister]);
self.add_target_args(&mut command);
run_status(&mut command)
}
pub fn start_canister(&self, canister: &str) -> Result<(), IcpCommandError> {
let mut command = self.canister_command();
command.args(["start", canister]);
self.add_target_args(&mut command);
run_status(&mut command)
}
pub fn snapshot_download(
&self,
canister: &str,
snapshot_id: &str,
artifact_path: &Path,
) -> Result<(), IcpCommandError> {
let mut command = self.canister_command();
command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
command.arg(artifact_path);
self.add_target_args(&mut command);
run_status(&mut command)
}
pub fn snapshot_upload(
&self,
canister: &str,
artifact_path: &Path,
) -> Result<String, IcpCommandError> {
let mut command = self.canister_command();
command.args(["snapshot", "upload", canister, "--input"]);
command.arg(artifact_path);
command.arg("--resume");
self.add_target_args(&mut command);
run_output_with_stderr(&mut command)
}
pub fn snapshot_restore(
&self,
canister: &str,
snapshot_id: &str,
) -> Result<(), IcpCommandError> {
let mut command = self.canister_command();
command.args(["snapshot", "restore", canister, snapshot_id]);
self.add_target_args(&mut command);
run_status(&mut command)
}
#[must_use]
pub fn snapshot_create_display(&self, canister: &str) -> String {
let mut command = self.canister_command();
command.args(["snapshot", "create", canister]);
command.arg("--json");
self.add_target_args(&mut command);
command_display(&command)
}
#[must_use]
pub fn snapshot_download_display(
&self,
canister: &str,
snapshot_id: &str,
artifact_path: &Path,
) -> String {
let mut command = self.canister_command();
command.args(["snapshot", "download", canister, snapshot_id, "--output"]);
command.arg(artifact_path);
self.add_target_args(&mut command);
command_display(&command)
}
#[must_use]
pub fn snapshot_upload_display(&self, canister: &str, artifact_path: &Path) -> String {
let mut command = self.canister_command();
command.args(["snapshot", "upload", canister, "--input"]);
command.arg(artifact_path);
command.arg("--resume");
self.add_target_args(&mut command);
command_display(&command)
}
#[must_use]
pub fn snapshot_restore_display(&self, canister: &str, snapshot_id: &str) -> String {
let mut command = self.canister_command();
command.args(["snapshot", "restore", canister, snapshot_id]);
self.add_target_args(&mut command);
command_display(&command)
}
#[must_use]
pub fn stop_canister_display(&self, canister: &str) -> String {
let mut command = self.canister_command();
command.args(["stop", canister]);
self.add_target_args(&mut command);
command_display(&command)
}
#[must_use]
pub fn start_canister_display(&self, canister: &str) -> String {
let mut command = self.canister_command();
command.args(["start", canister]);
self.add_target_args(&mut command);
command_display(&command)
}
fn add_target_args(&self, command: &mut Command) {
add_target_args(command, self.environment(), self.network());
}
}
#[must_use]
pub fn default_command() -> Command {
IcpCli::new("icp", None, None).command()
}
#[must_use]
pub fn default_command_in(cwd: &Path) -> Command {
IcpCli::new("icp", None, None).command_in(cwd)
}
pub fn add_target_args(command: &mut Command, environment: Option<&str>, network: Option<&str>) {
if let Some(environment) = environment {
command.args(["-e", environment]);
} else if let Some(network) = network {
command.args(["-n", network]);
}
}
pub fn add_output_arg(command: &mut Command, output: &str) {
if output == "json" {
command.arg("--json");
} else {
command.args(["--output", output]);
}
}
pub fn add_debug_arg(command: &mut Command, debug: bool) {
if debug {
command.arg("--debug");
}
}
pub fn run_output(command: &mut Command) -> Result<String, IcpCommandError> {
let display = command_display(command);
let output = command.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err(IcpCommandError::Failed {
command: display,
stderr: command_stderr(&output),
})
}
}
pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
let display = command_display(command);
let output = command.output()?;
if output.status.success() {
let mut text = String::from_utf8_lossy(&output.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&output.stderr));
Ok(text.trim().to_string())
} else {
Err(IcpCommandError::Failed {
command: display,
stderr: command_stderr(&output),
})
}
}
pub fn run_json<T>(command: &mut Command) -> Result<T, IcpCommandError>
where
T: serde::de::DeserializeOwned,
{
let display = command_display(command);
let output = command.output()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
command: display,
output: stdout,
source,
})
} else {
Err(IcpCommandError::Failed {
command: display,
stderr: command_stderr(&output),
})
}
}
pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
let display = command_display(command);
let output = command.output()?;
if output.status.success() {
Ok(())
} else {
Err(IcpCommandError::Failed {
command: display,
stderr: command_stderr(&output),
})
}
}
pub fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
let display = command_display(command);
let mut child = command
.stdout(Stdio::inherit())
.stderr(Stdio::piped())
.spawn()?;
let stderr_handle = child
.stderr
.take()
.map(|stderr| thread::spawn(move || stream_and_capture_stderr(stderr)));
let status = child.wait()?;
let stderr = match stderr_handle {
Some(handle) => match handle.join() {
Ok(result) => result?,
Err(_) => Vec::new(),
},
None => Vec::new(),
};
if status.success() {
Ok(())
} else {
let stderr = if stderr.is_empty() {
format!("command exited with status {}", exit_status_label(status))
} else {
String::from_utf8_lossy(&stderr).to_string()
};
Err(IcpCommandError::Failed {
command: display,
stderr,
})
}
}
fn stream_and_capture_stderr(mut stderr: impl Read) -> io::Result<Vec<u8>> {
let mut captured = Vec::new();
let mut buffer = [0_u8; 8192];
let mut terminal = io::stderr().lock();
loop {
let read = stderr.read(&mut buffer)?;
if read == 0 {
break;
}
terminal.write_all(&buffer[..read])?;
captured.extend_from_slice(&buffer[..read]);
}
terminal.flush()?;
Ok(captured)
}
pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
Ok(command.output()?.status.success())
}
pub fn run_raw_output(program: &str, args: &[String]) -> Result<IcpRawOutput, std::io::Error> {
let output = Command::new(program).args(args).output()?;
Ok(IcpRawOutput {
success: output.status.success(),
status: exit_status_label(output.status),
stdout: output.stdout,
stderr: output.stderr,
})
}
#[must_use]
pub fn command_display(command: &Command) -> String {
let mut parts = vec![command.get_program().to_string_lossy().to_string()];
parts.extend(
command
.get_args()
.map(|arg| arg.to_string_lossy().to_string()),
);
parts.join(" ")
}
#[must_use]
pub fn parse_snapshot_id(output: &str) -> Option<String> {
let trimmed = output.trim();
if is_snapshot_id_token(trimmed) {
return Some(trimmed.to_string());
}
output
.lines()
.flat_map(|line| {
line.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
})
.find(|part| is_snapshot_id_token(part))
.map(str::to_string)
}
fn is_snapshot_id_token(value: &str) -> bool {
!value.is_empty()
&& value.len().is_multiple_of(2)
&& value.chars().all(|c| c.is_ascii_hexdigit())
}
fn command_stderr(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).to_string()
} else {
stderr.to_string()
}
}
fn exit_status_label(status: std::process::ExitStatus) -> String {
status
.code()
.map_or_else(|| "signal".to_string(), |code| code.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_environment_target() {
let icp = IcpCli::new("icp", Some("staging".to_string()), Some("ic".to_string()));
assert_eq!(
icp.snapshot_download_display("root", "snap-1", Path::new("backups/root")),
"icp canister snapshot download root snap-1 --output backups/root -e staging"
);
}
#[test]
fn renders_network_target() {
let icp = IcpCli::new("icp", None, Some("ic".to_string()));
assert_eq!(
icp.snapshot_create_display("aaaaa-aa"),
"icp canister snapshot create aaaaa-aa --json -n ic"
);
}
#[test]
fn renders_local_replica_commands() {
let icp = IcpCli::new("icp", None, None);
assert_eq!(
icp.local_replica_start_display(true, false),
"icp network start local --background"
);
assert_eq!(
icp.local_replica_start_display(false, false),
"icp network start local"
);
assert_eq!(
icp.local_replica_start_display(false, true),
"icp network start local --debug"
);
assert_eq!(
icp.local_replica_status_display(false),
"icp network status local"
);
assert_eq!(
icp.local_replica_status_display(true),
"icp network status local --debug"
);
assert_eq!(
icp.local_replica_stop_display(false),
"icp network stop local"
);
assert_eq!(
icp.local_replica_stop_display(true),
"icp network stop local --debug"
);
}
#[test]
fn renders_snapshot_restore_flow() {
let icp = IcpCli::new("icp", Some("prod".to_string()), None);
assert_eq!(
icp.snapshot_upload_display("root", Path::new("artifact")),
"icp canister snapshot upload root --input artifact --resume -e prod"
);
assert_eq!(
icp.snapshot_restore_display("root", "uploaded-1"),
"icp canister snapshot restore root uploaded-1 -e prod"
);
}
#[test]
fn parses_snapshot_id_from_output() {
let snapshot_id = parse_snapshot_id("Created snapshot: 0a0b0c0d\n");
assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
}
#[test]
fn parses_snapshot_id_from_table_output() {
let output = "\
ID SIZE CREATED_AT
0a0b0c0d 1.37 MiB 2026-05-10T17:04:19Z
";
let snapshot_id = parse_snapshot_id(output);
assert_eq!(snapshot_id.as_deref(), Some("0a0b0c0d"));
}
#[test]
fn parses_snapshot_create_receipt_json() {
let receipt = serde_json::from_str::<IcpSnapshotCreateReceipt>(
r#"{
"snapshot_id": "0000000000000000ffffffffffc000020101",
"taken_at_timestamp": 1778709681897818005,
"total_size_bytes": 272586987
}"#,
)
.expect("parse snapshot receipt");
assert_eq!(receipt.snapshot_id, "0000000000000000ffffffffffc000020101");
assert_eq!(receipt.total_size_bytes, Some(272_586_987));
}
#[test]
fn parses_canister_status_report_json() {
let report = serde_json::from_str::<IcpCanisterStatusReport>(
r#"{
"id": "t63gs-up777-77776-aaaba-cai",
"name": "motoko-ex",
"status": "Running",
"settings": {
"controllers": ["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"],
"compute_allocation": "0"
},
"module_hash": "0x66ce5ddcd06f1135c1a04792a2f1b7c3d9e229b977a8fc9762c71ecc5314c9eb",
"cycles": "1_497_896_187_059"
}"#,
)
.expect("parse status report");
assert_eq!(report.status, "Running");
assert_eq!(
report.settings.expect("settings").controllers.as_slice(),
&["zbf4m-zw3nk-6owqc-qmluz-xhwxt-2pkky-xhjy2-kqxor-qzxsn-6d2bz-nae"]
);
}
}