Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Unreleased

* feat: `icp candid build` allows using the Candid-assist wizard from `icp canister call` to construct a precompiled argument file
* feat: `icp canister logs` supports filtering by timestamp (`--since`, `--until`) and log index (`--since-index`, `--until-index`)
* feat: Support `log_memory_limit` canister setting in `icp canister settings update` and `icp canister settings sync`
* feat: Leaving off the method name parameter in `icp canister call` prompts you with an interactive list of methods
Expand Down
215 changes: 215 additions & 0 deletions crates/icp-cli/src/commands/candid/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
use anyhow::{Context as _, bail};
use candid::Principal;
use candid::types::{Type, TypeInner};
use candid::{TypeEnv, types::Function};
use candid_parser::assist;
use candid_parser::utils::CandidSource;
use clap::{ArgGroup, Args, ValueEnum};
use ic_agent::Agent;
use icp::context::{Context, GetCanisterIdError, GetCanisterIdForEnvError};
use icp::prelude::*;
use icp::store_id::LookupIdError;
use std::io::Write;

use crate::commands::args;
use crate::operations::misc::fetch_canister_metadata;

#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub(crate) enum OutputFormat {
#[default]
Bin,
Hex,
Candid,
}

/// Interactively build Candid arguments for a canister method
#[derive(Args, Debug)]
#[command(group(ArgGroup::new("input").required(true).args(["canister", "candid_file"])))]
pub(crate) struct BuildArgs {
#[command(flatten)]
pub(crate) cmd_args: args::OptionalCanisterCommandArgs,

/// Name of canister method to build arguments for.
/// If not provided, an interactive prompt will be launched.
pub(crate) method: Option<String>,

/// Output file path. Pass `-` for stdout.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this a little unusual, why not make it optional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the default value being bin, this would dump garbage to the terminal if you forgot it. (Should it be bin?)

#[arg(short, long)]
pub(crate) output: PathBuf,

/// Output format.
#[arg(long, default_value = "bin")]
pub(crate) format: OutputFormat,

/// Optionally provide a local Candid file describing the canister interface,
/// instead of looking it up from canister metadata.
#[arg(short = 'c', long)]
pub(crate) candid_file: Option<PathBuf>,
}

pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow::Error> {
let selections = args.cmd_args.selections();

let interface = if let Some(path) = &args.candid_file {
// the canister is optional if the candid file is provided
load_candid_file(path)?
} else {
let agent = ctx
.get_agent(
&selections.identity,
&selections.network,
&selections.environment,
)
.await?;
// otherwise, look up the canister
let cid = match ctx
.get_canister_id(
&selections.canister.expect("required by arg group"),
&selections.network,
&selections.environment,
)
.await
{
Ok(cid) => cid,
Err(GetCanisterIdError::GetCanisterIdForEnv {
source:
GetCanisterIdForEnvError::CanisterIdLookup {
source,
canister_name,
environment_name,
},
}) if matches!(*source, LookupIdError::IdNotFound { .. }) => {
bail!(
"Canister {canister_name} has not been deployed in environment {environment_name}. This command requires an active deployment to reference"
);
}
Err(e) => return Err(e).context("failed to look up canister ID"),
};
let Some(interface) = get_candid_type(&agent, cid).await else {
bail!(
"Could not fetch Candid interface from `candid:service` metadata section of canister {cid}"
);
};
interface
};

let method = if let Some(method) = &args.method {
method.clone()
} else {
pick_method(&interface, "Select a method")?
};

let Some(func) = interface.get_method(&method) else {
bail!("method `{method}` not found in the canister's Candid interface");
};

let context = assist::Context::new(interface.env.clone());
eprintln!("Build arguments for `{method}`:");
let arguments = assist::input_args(&context, &func.args)?;

let bytes = arguments
.to_bytes_with_types(&interface.env, &func.args)
.context("failed to serialize Candid arguments")?;

if args.output.as_str() == "-" {
match args.format {
OutputFormat::Bin => {
std::io::stdout().write_all(&bytes)?;
}
OutputFormat::Hex => {
println!("{}", hex::encode(&bytes));
}
OutputFormat::Candid => {
println!("{arguments}");
}
}
} else {
let path = &args.output;
match args.format {
OutputFormat::Bin => {
icp::fs::write(path, &bytes)?;
}
OutputFormat::Hex => {
icp::fs::write_string(path, &hex::encode(&bytes))?;
}
OutputFormat::Candid => {
icp::fs::write_string(path, &format!("{arguments}\n"))?;
}
}
_ = ctx.term.write_line(&format!("Written to {path}"));
}

Ok(())
}

/// Interactively pick a method from a canister's Candid interface.
pub(crate) fn pick_method(
interface: &CanisterInterface,
prompt: &str,
) -> Result<String, anyhow::Error> {
let methods: Vec<&str> = interface.methods().collect();
if methods.is_empty() {
bail!("the canister's Candid interface has no methods");
}
let selection = dialoguer::Select::new()
.with_prompt(prompt)
.items(&methods)
.default(0)
.interact()?;
Ok(methods[selection].to_string())
}

/// Gets the Candid type of a method on a canister by fetching its Candid interface.
///
/// This is a best effort function: it will succeed if
/// - the canister exposes its Candid interface in its metadata;
/// - the IDL file can be parsed and type checked in Rust parser;
/// - has an actor in the IDL file. If anything fails, it returns None.
pub(crate) async fn get_candid_type(
agent: &Agent,
canister_id: Principal,
) -> Option<CanisterInterface> {
let candid_interface = fetch_canister_metadata(agent, canister_id, "candid:service").await?;
let candid_source = CandidSource::Text(&candid_interface);
let (type_env, ty) = candid_source.load().ok()?;
let actor = ty?;
Some(CanisterInterface {
env: type_env,
ty: actor,
})
}

/// Loads a canister's Candid interface from a local `.did` file.
pub(crate) fn load_candid_file(path: &Path) -> Result<CanisterInterface, anyhow::Error> {
let candid_source = CandidSource::File(path.as_std_path());
let (type_env, ty) = candid_source
.load()
.with_context(|| format!("failed to load Candid file {path}"))?;
let actor = ty.with_context(|| format!("Candid file {path} does not define a service"))?;
Ok(CanisterInterface {
env: type_env,
ty: actor,
})
}

pub(crate) struct CanisterInterface {
pub(crate) env: TypeEnv,
pub(crate) ty: Type,
}

impl CanisterInterface {
pub(crate) fn methods(&self) -> impl Iterator<Item = &str> {
let ty = if let TypeInner::Class(_, t) = &*self.ty.0 {
t
} else {
&self.ty
};
let TypeInner::Service(methods) = &*ty.0 else {
unreachable!("check_prog should verify service type")
};
methods.iter().map(|(name, _)| name.as_str())
}
pub(crate) fn get_method<'a>(&'a self, method_name: &'a str) -> Option<&'a Function> {
self.env.get_method(&self.ty, method_name).ok()
}
}
9 changes: 9 additions & 0 deletions crates/icp-cli/src/commands/candid/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use clap::Subcommand;

pub(crate) mod build;

/// Candid encoding utilities
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
Build(build::BuildArgs),
}
69 changes: 14 additions & 55 deletions crates/icp-cli/src/commands/canister/call.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
use anyhow::{Context as _, anyhow, bail};
use candid::types::{Type, TypeInner};
use candid::{Encode, IDLArgs, Nat, Principal, TypeEnv, types::Function};
use candid_parser::assist;
use candid_parser::parse_idl_args;
use candid_parser::utils::CandidSource;
use clap::{Args, ValueEnum};
use dialoguer::console::Term;
use ic_agent::Agent;
use icp::context::Context;
use icp::fs;
use icp::manifest::InitArgsFormat;
Expand All @@ -16,7 +13,8 @@ use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult};
use std::io::{self, Write};
use tracing::warn;

use crate::{commands::args, operations::misc::fetch_canister_metadata};
use crate::commands::args;
use crate::commands::candid::build::{get_candid_type, load_candid_file, pick_method};

/// How to interpret and display the call response blob.
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
Expand Down Expand Up @@ -79,6 +77,11 @@ pub(crate) struct CallArgs {
/// How to interpret and display the response.
#[arg(long, short, default_value = "auto")]
pub(crate) output: CallOutputMode,

/// Optionally provide a local Candid file describing the canister interface,
/// instead of looking it up from canister metadata.
#[arg(short = 'c', long)]
pub(crate) candid_file: Option<PathBuf>,
}

pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::Error> {
Expand All @@ -99,22 +102,16 @@ pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::E
)
.await?;

let candid_types = get_candid_type(&agent, cid).await;
let candid_types = if let Some(path) = &args.candid_file {
Some(load_candid_file(path)?)
} else {
get_candid_type(&agent, cid).await
};

let method = if let Some(method) = &args.method {
method.clone()
} else if let Some(interface) = &candid_types {
// Interactive method selection using candid assist
let methods: Vec<&str> = interface.methods().collect();
if methods.is_empty() {
bail!("the canister's Candid interface has no methods");
}
let selection = dialoguer::Select::new()
.with_prompt("Select a method to call")
.items(&methods)
.default(0)
.interact()?;
methods[selection].to_string()
pick_method(interface, "Select a method to call")?
} else {
bail!(
"method name was not provided and could not fetch candid type to assist method selection"
Expand Down Expand Up @@ -313,48 +310,10 @@ pub(crate) fn print_candid_for_term(term: &mut Term, args: &IDLArgs) -> io::Resu
Ok(())
}

/// Gets the Candid type of a method on a canister by fetching its Candid interface.
///
/// This is a best effort function: it will succeed if
/// - the canister exposes its Candid interface in its metadata;
/// - the IDL file can be parsed and type checked in Rust parser;
/// - has an actor in the IDL file. If anything fails, it returns None.
async fn get_candid_type(agent: &Agent, canister_id: Principal) -> Option<CanisterInterface> {
let candid_interface = fetch_canister_metadata(agent, canister_id, "candid:service").await?;
let candid_source = CandidSource::Text(&candid_interface);
let (type_env, ty) = candid_source.load().ok()?;
let actor = ty?;
Some(CanisterInterface {
env: type_env,
ty: actor,
})
}

struct CanisterInterface {
env: TypeEnv,
ty: Type,
}

impl CanisterInterface {
fn methods(&self) -> impl Iterator<Item = &str> {
let ty = if let TypeInner::Class(_, t) = &*self.ty.0 {
t
} else {
&self.ty
};
let TypeInner::Service(methods) = &*ty.0 else {
unreachable!("check_prog should verify service type")
};
methods.iter().map(|(name, _)| name.as_str())
}
fn get_method<'a>(&'a self, method_name: &'a str) -> Option<&'a Function> {
self.env.get_method(&self.ty, method_name).ok()
}
}

#[cfg(test)]
mod tests {
use super::*;
use candid_parser::utils::CandidSource;

#[test]
fn typed_decoding_preserves_record_field_names() {
Expand Down
3 changes: 3 additions & 0 deletions crates/icp-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use clap::Subcommand;

pub(crate) mod args;
pub(crate) mod build;
pub(crate) mod candid;
pub(crate) mod canister;
pub(crate) mod cycles;
pub(crate) mod deploy;
Expand All @@ -20,6 +21,8 @@ pub(crate) mod token;
pub(crate) enum Command {
Build(build::BuildArgs),
#[command(subcommand)]
Candid(candid::Command),
#[command(subcommand)]
Canister(canister::Command),
#[command(subcommand)]
Cycles(cycles::Command),
Expand Down
7 changes: 7 additions & 0 deletions crates/icp-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), E
// Build
Command::Build(args) => commands::build::exec(ctx, &args).await?,

// Candid
Command::Candid(cmd) => match cmd {
commands::candid::Command::Build(args) => {
commands::candid::build::exec(ctx, &args).await?
}
},

// Canister
Command::Canister(cmd) => match cmd {
commands::canister::Command::Call(args) => {
Expand Down
Loading
Loading