Skip to content

Commit 5d92dc8

Browse files
feat: Implement JSON output for remaining commands (#452)
1 parent be3d90e commit 5d92dc8

File tree

18 files changed

+641
-82
lines changed

18 files changed

+641
-82
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Unreleased
22

3+
* feat: Many more commands support `--json` and `--quiet`.
4+
35
# v0.2.1
46

57
* feat: icp-cli will now inform you if a new version is released. This can be disabled with `icp settings update-check`

crates/icp-cli/src/commands/canister/call.rs

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ use icp::manifest::InitArgsFormat;
1313
use icp::parsers::CyclesAmount;
1414
use icp::prelude::*;
1515
use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult};
16+
use serde::Serialize;
1617
use std::io::{self, Write};
17-
use tracing::warn;
18+
use tracing::{error, warn};
1819

1920
use crate::{commands::args, operations::misc::fetch_canister_metadata};
2021

@@ -79,6 +80,10 @@ pub(crate) struct CallArgs {
7980
/// How to interpret and display the response.
8081
#[arg(long, short, default_value = "auto")]
8182
pub(crate) output: CallOutputMode,
83+
84+
/// Output command results as JSON
85+
#[arg(long)]
86+
pub(crate) json: bool,
8287
}
8388

8489
pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::Error> {
@@ -246,41 +251,86 @@ pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::E
246251

247252
let mut term = Term::buffered_stdout();
248253
let res_hex = || format!("response (hex): {}", hex::encode(&res));
254+
let mut json_response = JsonCallResponse {
255+
response_bytes: hex::encode(&res),
256+
response_text: None,
257+
response_candid: None,
258+
};
249259

250-
match args.output {
251-
CallOutputMode::Auto => {
252-
if let Ok(ret) = try_decode_candid(&res, declared_method.as_ref()) {
253-
print_candid_for_term(&mut term, &ret)
254-
.context("failed to print candid return value")?;
255-
} else if let Ok(s) = std::str::from_utf8(&res) {
256-
writeln!(term, "{s}")?;
257-
term.flush()?;
258-
} else {
260+
// catch errors, because the json result should be printed regardless of errors
261+
let res = (|| {
262+
match args.output {
263+
CallOutputMode::Auto => {
264+
if let Ok(ret) = try_decode_candid(&res, declared_method.as_ref()) {
265+
if args.json {
266+
json_response.response_candid = Some(format!("{ret}"));
267+
} else {
268+
print_candid_for_term(&mut term, &ret)
269+
.context("failed to print candid return value")?;
270+
}
271+
} else if let Ok(s) = std::str::from_utf8(&res) {
272+
if args.json {
273+
json_response.response_text = Some(s.to_string());
274+
} else {
275+
writeln!(term, "{s}")?;
276+
term.flush()?;
277+
}
278+
} else if !args.json {
279+
writeln!(term, "{}", hex::encode(&res))?;
280+
term.flush()?;
281+
}
282+
}
283+
CallOutputMode::Candid => {
284+
let ret =
285+
try_decode_candid(&res, declared_method.as_ref()).with_context(res_hex)?;
286+
if args.json {
287+
json_response.response_candid = Some(format!("{ret}"));
288+
} else {
289+
print_candid_for_term(&mut term, &ret)
290+
.context("failed to print candid return value")?;
291+
}
292+
}
293+
CallOutputMode::Text => {
294+
let s = std::str::from_utf8(&res)
295+
.with_context(res_hex)
296+
.context("response is not valid UTF-8")?;
297+
if args.json {
298+
json_response.response_text = Some(s.to_string());
299+
} else {
300+
writeln!(term, "{s}")?;
301+
term.flush()?;
302+
}
303+
}
304+
CallOutputMode::Hex => {
259305
writeln!(term, "{}", hex::encode(&res))?;
260306
term.flush()?;
261307
}
262-
}
263-
CallOutputMode::Candid => {
264-
let ret = try_decode_candid(&res, declared_method.as_ref()).with_context(res_hex)?;
265-
print_candid_for_term(&mut term, &ret)
266-
.context("failed to print candid return value")?;
267-
}
268-
CallOutputMode::Text => {
269-
let s = std::str::from_utf8(&res)
270-
.with_context(res_hex)
271-
.context("response is not valid UTF-8")?;
272-
writeln!(term, "{s}")?;
273-
term.flush()?;
274-
}
275-
CallOutputMode::Hex => {
276-
writeln!(term, "{}", hex::encode(&res))?;
277-
term.flush()?;
308+
};
309+
anyhow::Ok(())
310+
})();
311+
if args.json {
312+
let write_result = serde_json::to_writer(term, &json_response);
313+
if let Err(write_err) = write_result {
314+
if let Err(decode_err) = res {
315+
error!("failed to write JSON response: {write_err}");
316+
return Err(decode_err);
317+
} else {
318+
return Err(write_err).context("failed to write JSON response");
319+
}
278320
}
279321
}
322+
res?;
280323

281324
Ok(())
282325
}
283326

327+
#[derive(Serialize)]
328+
struct JsonCallResponse {
329+
response_bytes: String,
330+
response_text: Option<String>,
331+
response_candid: Option<String>,
332+
}
333+
284334
/// Tries to decode the response as Candid. Returns `None` if decoding fails.
285335
fn try_decode_candid(
286336
res: &[u8],

crates/icp-cli/src/commands/canister/create.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
use std::io::stdout;
2+
13
use anyhow::anyhow;
24
use candid::{Nat, Principal};
35
use clap::{ArgGroup, Args, Parser};
46
use icp::context::Context;
57
use icp::parsers::{CyclesAmount, DurationAmount, MemoryAmount};
68
use icp::{Canister, context::CanisterSelection, prelude::*};
79
use icp_canister_interfaces::management_canister::CanisterSettingsArg;
10+
use serde::Serialize;
811
use tracing::info;
912

1013
use crate::{commands::args, operations::create::CreateOperation};
@@ -91,6 +94,10 @@ pub(crate) struct CreateArgs {
9194
required_unless_present = "canister"
9295
)]
9396
pub detached: bool,
97+
98+
/// Output command results as JSON
99+
#[arg(long, conflicts_with = "quiet")]
100+
pub(crate) json: bool,
94101
}
95102

96103
impl CreateArgs {
@@ -193,6 +200,14 @@ async fn create_canister(ctx: &Context, args: &CreateArgs) -> Result<(), anyhow:
193200

194201
if args.quiet {
195202
println!("{id}");
203+
} else if args.json {
204+
serde_json::to_writer(
205+
stdout(),
206+
&JsonCreate {
207+
canister_id: id,
208+
canister_name: None,
209+
},
210+
)?;
196211
} else {
197212
println!("Created canister with ID {id}");
198213
}
@@ -249,9 +264,23 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(),
249264

250265
if args.quiet {
251266
println!("{id}");
267+
} else if args.json {
268+
serde_json::to_writer(
269+
stdout(),
270+
&JsonCreate {
271+
canister_id: id,
272+
canister_name: Some(canister.clone()),
273+
},
274+
)?;
252275
} else {
253276
println!("Created canister {canister} with ID {id}");
254277
}
255278

256279
Ok(())
257280
}
281+
282+
#[derive(Serialize)]
283+
struct JsonCreate {
284+
canister_id: Principal,
285+
canister_name: Option<String>,
286+
}
Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
use std::io::stdout;
2+
13
use clap::Args;
24
use icp::context::Context;
5+
use serde::Serialize;
36

47
use crate::options::EnvironmentOpt;
58

@@ -8,15 +11,24 @@ use crate::options::EnvironmentOpt;
811
pub(crate) struct ListArgs {
912
#[command(flatten)]
1013
pub(crate) environment: EnvironmentOpt,
14+
/// Output command results as JSON
15+
#[arg(long)]
16+
pub(crate) json: bool,
1117
}
1218

1319
pub(crate) async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), anyhow::Error> {
1420
let environment_selection = args.environment.clone().into();
1521
let env = ctx.get_environment(&environment_selection).await?;
16-
17-
for c in env.canisters.keys() {
18-
println!("{c}");
22+
let canisters = env.canisters.keys().cloned().collect();
23+
if args.json {
24+
serde_json::to_writer(stdout(), &JsonList { canisters })?;
25+
} else {
26+
println!("{}", canisters.join("\n"));
1927
}
20-
2128
Ok(())
2229
}
30+
31+
#[derive(Serialize)]
32+
struct JsonList {
33+
canisters: Vec<String>,
34+
}

crates/icp-cli/src/commands/canister/logs.rs

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::io::stdout;
2+
13
use anyhow::{Context as _, anyhow};
24
use clap::Args;
35
use ic_utils::interfaces::ManagementCanister;
@@ -6,6 +8,8 @@ use ic_utils::interfaces::management_canister::{
68
};
79
use icp::context::Context;
810
use icp::signal::stop_signal;
11+
use itertools::Itertools;
12+
use serde::Serialize;
913
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
1014
use tokio::select;
1115

@@ -42,6 +46,10 @@ pub(crate) struct LogsArgs {
4246
/// Show logs before this log index (exclusive). Cannot be used with --follow
4347
#[arg(long, value_name = "INDEX", conflicts_with_all = ["follow", "since", "until"])]
4448
pub(crate) until_index: Option<u64>,
49+
50+
/// Output command results as JSON
51+
#[arg(long)]
52+
pub(crate) json: bool,
4553
}
4654

4755
fn parse_timestamp(s: &str) -> Result<u64, String> {
@@ -99,13 +107,24 @@ pub(crate) async fn exec(ctx: &Context, args: &LogsArgs) -> Result<(), anyhow::E
99107

100108
if args.follow {
101109
// Follow mode: continuously fetch and display new logs
102-
follow_logs(&mgmt, &canister_id, args.interval).await
110+
follow_logs(args, &mgmt, &canister_id, args.interval).await
103111
} else {
104112
// Single fetch mode: fetch all logs once
105-
fetch_and_display_logs(&mgmt, &canister_id, build_filter(args)?).await
113+
fetch_and_display_logs(args, &mgmt, &canister_id, build_filter(args)?).await
106114
}
107115
}
108116

117+
#[derive(Serialize)]
118+
struct JsonFollowRecord {
119+
timestamp: u64,
120+
index: u64,
121+
content: String,
122+
}
123+
#[derive(Serialize)]
124+
struct JsonListRecord {
125+
log_records: Vec<JsonFollowRecord>,
126+
}
127+
109128
fn build_filter(args: &LogsArgs) -> Result<Option<CanisterLogFilter>, anyhow::Error> {
110129
if args.since_index.is_some() || args.until_index.is_some() {
111130
let start = args.since_index.unwrap_or(0);
@@ -141,6 +160,7 @@ fn build_filter(args: &LogsArgs) -> Result<Option<CanisterLogFilter>, anyhow::Er
141160
}
142161

143162
async fn fetch_and_display_logs(
163+
args: &LogsArgs,
144164
mgmt: &ManagementCanister<'_>,
145165
canister_id: &candid::Principal,
146166
filter: Option<CanisterLogFilter>,
@@ -154,9 +174,30 @@ async fn fetch_and_display_logs(
154174
.await
155175
.context("Failed to fetch canister logs")?;
156176

157-
for log in result.canister_log_records {
158-
let formatted = format_log(&log);
159-
println!("{formatted}");
177+
if args.json {
178+
println!(
179+
"{}",
180+
result
181+
.canister_log_records
182+
.iter()
183+
.map(format_log)
184+
.format("\n")
185+
);
186+
} else {
187+
serde_json::to_writer(
188+
stdout(),
189+
&JsonListRecord {
190+
log_records: result
191+
.canister_log_records
192+
.iter()
193+
.map(|log| JsonFollowRecord {
194+
timestamp: log.timestamp_nanos,
195+
index: log.idx,
196+
content: String::from_utf8_lossy(&log.content).into_owned(),
197+
})
198+
.collect(),
199+
},
200+
)?;
160201
}
161202

162203
Ok(())
@@ -165,6 +206,7 @@ async fn fetch_and_display_logs(
165206
const FOLLOW_LOOKBACK_NANOS: u64 = 60 * 60 * 1_000_000_000; // 1 hour
166207

167208
async fn follow_logs(
209+
args: &LogsArgs,
168210
mgmt: &ManagementCanister<'_>,
169211
canister_id: &candid::Principal,
170212
interval_seconds: u64,
@@ -204,8 +246,18 @@ async fn follow_logs(
204246

205247
if !new_logs.is_empty() {
206248
for log in &new_logs {
207-
let formatted = format_log(log);
208-
println!("{formatted}");
249+
if args.json {
250+
serde_json::to_writer(
251+
stdout(),
252+
&JsonFollowRecord {
253+
timestamp: log.timestamp_nanos,
254+
index: log.idx,
255+
content: String::from_utf8_lossy(&log.content).into_owned(),
256+
},
257+
)?;
258+
} else {
259+
println!("{}", format_log(log));
260+
}
209261
}
210262
// Update last_idx to the highest idx we've displayed
211263
if let Some(last_log) = new_logs.last() {
@@ -387,6 +439,7 @@ mod tests {
387439
until,
388440
since_index,
389441
until_index,
442+
json: false,
390443
}
391444
}
392445

0 commit comments

Comments
 (0)