use crate::{Command, ExplainTarget, ReadTarget, RuntimeError, SetTarget};
const SPAN_SUMMARY_LIMIT: usize = 5;
pub(crate) fn write_api_success<W: std::io::Write>(
command: &Command,
body: &str,
output: &mut W,
) -> Result<(), RuntimeError> {
if command.wants_json() {
writeln!(output, "{body}")?;
return Ok(());
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
writeln!(output, "{body}")?;
return Ok(());
};
if let Some(summary) = human_summary(command, &value) {
write!(output, "{summary}")?;
} else {
writeln!(output, "{body}")?;
}
Ok(())
}
fn human_summary(command: &Command, value: &serde_json::Value) -> Option<String> {
match command {
Command::Read { target, .. } => read_summary(target, value),
Command::Explain { target, .. } => explain_summary(target, value),
Command::Set { target, .. } => set_summary(target, value),
Command::Help { .. }
| Command::Login { .. }
| Command::Logout { .. }
| Command::Setup { .. }
| Command::Status { .. }
| Command::Version { .. }
| Command::Watch { .. } => None,
}
}
fn read_summary(target: &ReadTarget, value: &serde_json::Value) -> Option<String> {
match target {
ReadTarget::Logs => list_summary("Logs", list_items(value, "logs")?, log_line),
ReadTarget::Issues => list_summary("Issues", list_items(value, "issues")?, issue_line),
ReadTarget::Actions => list_summary("Actions", list_items(value, "actions")?, action_line),
ReadTarget::Releases => {
list_summary("Releases", list_items(value, "releases")?, release_line)
}
ReadTarget::Trace(id) => trace_summary(value, id.as_str()),
ReadTarget::Issue(_) => issue_detail_summary(value),
}
}
fn explain_summary(target: &ExplainTarget, value: &serde_json::Value) -> Option<String> {
match target {
ExplainTarget::Issue(_) => issue_detail_summary(value),
ExplainTarget::Trace(id) => trace_summary(value, id.as_str()),
}
}
fn set_summary(target: &SetTarget, value: &serde_json::Value) -> Option<String> {
match target {
SetTarget::IssueStatus { .. } => {
let issue = issue_value(value)?;
let id = field(issue, "id")?;
let status = field(issue, "status")?;
let mut output = format!("Issue {id} marked {status}");
append_labeled_field(&mut output, "trace", issue, "trace_id");
output.push_str(release_environment_suffix(issue).as_str());
output.push_str(".\n");
Some(output)
}
}
}
fn list_items<'a>(
value: &'a serde_json::Value,
wrapper_key: &str,
) -> Option<&'a [serde_json::Value]> {
if let Some(items) = value.as_array() {
Some(items.as_slice())
} else {
value.get(wrapper_key)?.as_array().map(Vec::as_slice)
}
}
fn list_summary(
title: &str,
items: &[serde_json::Value],
line_builder: fn(&serde_json::Value) -> Option<String>,
) -> Option<String> {
let mut output = format!("{title} ({})\n", items.len());
if items.is_empty() {
output.push_str(empty_list_message(title).as_str());
return Some(output);
}
for item in items {
output.push_str("- ");
output.push_str(line_builder(item)?.as_str());
output.push('\n');
}
Some(output)
}
fn empty_list_message(title: &str) -> String {
format!(
"No {} found.\nNext: widen filters or check --release/--environment.\n",
title.to_ascii_lowercase()
)
}
fn log_line(value: &serde_json::Value) -> Option<String> {
let message = field(value, "message")?;
let mut output = String::new();
if let Some(severity) = display_severity(value) {
output.push_str(&severity);
output.push(' ');
}
output.push_str(message);
append_labeled_field(&mut output, "trace", value, "trace_id");
output.push_str(release_environment_suffix(value).as_str());
Some(output)
}
fn issue_line(value: &serde_json::Value) -> Option<String> {
let id = field(value, "id")?;
let status = field(value, "status")?;
let mut output = format!("{id} {status}");
if let Some(severity) = display_severity(value) {
output.push(' ');
output.push_str(&severity);
}
if let Some(title) = field(value, "title") {
output.push(' ');
output.push_str(title);
}
if let Some(occurrences) = int_field(value, "occurrence_count") {
output.push_str(" occurrences=");
output.push_str(occurrences.to_string().as_str());
}
append_labeled_field(&mut output, "trace", value, "trace_id");
output.push_str(release_environment_suffix(value).as_str());
Some(output)
}
fn action_line(value: &serde_json::Value) -> Option<String> {
let name = field(value, "name")?;
let mut output = name.to_owned();
if let Some(severity) = display_severity(value) {
output.push(' ');
output.push_str(&severity);
}
append_labeled_field(&mut output, "user", value, "distinct_id");
append_labeled_field(&mut output, "trace", value, "trace_id");
output.push_str(release_environment_suffix(value).as_str());
Some(output)
}
fn release_line(value: &serde_json::Value) -> Option<String> {
let release = field(value, "release")?;
let environment = field(value, "environment")?;
let logs = count_field(value, "log_count");
let issues = count_field(value, "issue_count");
let spans = count_field(value, "trace_span_count");
let actions = count_field(value, "action_count");
Some(format!(
"{release} {environment} logs={logs} issues={issues} spans={spans} actions={actions}"
))
}
fn trace_summary(value: &serde_json::Value, fallback_trace_id: &str) -> Option<String> {
let (trace_id, context, spans) = trace_parts(value, fallback_trace_id)?;
let mut output = format!(
"Trace {trace_id} spans={}{}\n",
spans.len(),
context.map_or_else(String::new, release_environment_suffix)
);
append_span_names(&mut output, spans);
if spans.is_empty() {
output.push_str("Next: widen filters or check --release/--environment.\n");
}
Some(output)
}
fn trace_parts<'a>(
value: &'a serde_json::Value,
fallback_trace_id: &'a str,
) -> Option<(
&'a str,
Option<&'a serde_json::Value>,
&'a [serde_json::Value],
)> {
if let Some(trace) = value.get("trace") {
let spans = trace
.get("spans")
.and_then(serde_json::Value::as_array)
.map_or(&[][..], Vec::as_slice);
let trace_id = field(trace, "trace_id")
.or_else(|| spans.first().and_then(|span| field(span, "trace_id")))
.unwrap_or(fallback_trace_id);
let context = if release_environment_suffix(trace).is_empty() {
spans.first()
} else {
Some(trace)
};
return Some((trace_id, context, spans));
}
let spans = value.as_array()?;
let trace_id = spans
.first()
.and_then(|span| field(span, "trace_id"))
.unwrap_or(fallback_trace_id);
Some((trace_id, spans.first(), spans.as_slice()))
}
fn append_span_names(output: &mut String, spans: &[serde_json::Value]) {
let names = spans
.iter()
.filter_map(|span| field(span, "name"))
.collect::<Vec<_>>();
for name in names.iter().take(SPAN_SUMMARY_LIMIT) {
output.push_str("- ");
output.push_str(name);
output.push('\n');
}
if names.len() > SPAN_SUMMARY_LIMIT {
output.push_str("- ... ");
output.push_str((names.len() - SPAN_SUMMARY_LIMIT).to_string().as_str());
output.push_str(" more spans\n");
}
}
fn issue_detail_summary(value: &serde_json::Value) -> Option<String> {
let issue = issue_value(value)?;
let id = field(issue, "id")?;
let status = field(issue, "status")?;
let mut output = format!("Issue {id} {status}");
if let Some(severity) = display_severity(issue) {
output.push(' ');
output.push_str(&severity);
}
append_labeled_field(&mut output, "trace", issue, "trace_id");
output.push_str(release_environment_suffix(issue).as_str());
output.push('\n');
if let Some(title) = field(issue, "title") {
output.push_str("Title: ");
output.push_str(title);
output.push('\n');
}
if let Some(message) = field(issue, "message") {
output.push_str("Message: ");
output.push_str(message);
output.push('\n');
}
if let Some(occurrences) = int_field(issue, "occurrence_count") {
output.push_str("Occurrences: ");
output.push_str(occurrences.to_string().as_str());
output.push('\n');
}
if let Some(first_seen) = field(issue, "first_seen_at") {
output.push_str("First seen: ");
output.push_str(first_seen);
output.push('\n');
}
if let Some(last_seen) = field(issue, "last_seen_at") {
output.push_str("Last seen: ");
output.push_str(last_seen);
output.push('\n');
}
output.push_str("Next: ");
output.push_str(issue_next_step(id, status).as_str());
output.push('\n');
Some(output)
}
fn issue_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
value
.get("issue")
.or_else(|| value.as_object().map(|_| value))
}
fn field<'a>(value: &'a serde_json::Value, name: &str) -> Option<&'a str> {
value.get(name)?.as_str()
}
fn display_severity(value: &serde_json::Value) -> Option<std::borrow::Cow<'_, str>> {
field(value, "severity")
.or_else(|| field(value, "level"))
.map(canonical_severity_label)
}
fn canonical_severity_label(value: &str) -> std::borrow::Cow<'_, str> {
match value.to_ascii_lowercase().as_str() {
"trace" | "debug" | "info" | "information" => std::borrow::Cow::Borrowed("info"),
"warn" | "warning" => std::borrow::Cow::Borrowed("warning"),
"error" | "err" => std::borrow::Cow::Borrowed("error"),
"fatal" | "critical" => std::borrow::Cow::Borrowed("critical"),
_ => std::borrow::Cow::Borrowed(value),
}
}
fn append_labeled_field(
output: &mut String,
label: &str,
value: &serde_json::Value,
field_name: &str,
) {
if let Some(field_value) = field(value, field_name) {
output.push(' ');
output.push_str(label);
output.push('=');
output.push_str(field_value);
}
}
fn int_field(value: &serde_json::Value, name: &str) -> Option<i64> {
value.get(name)?.as_i64()
}
fn count_field(value: &serde_json::Value, name: &str) -> String {
value
.get(name)
.and_then(serde_json::Value::as_u64)
.map_or_else(|| String::from("0"), |count| count.to_string())
}
fn issue_next_step(id: &str, status: &str) -> String {
match status {
"resolved" | "ignored" => format!("logbrew reopen {id}"),
_ => format!("logbrew resolve {id} or logbrew ignore {id}"),
}
}
fn release_environment_suffix(value: &serde_json::Value) -> String {
let release = field(value, "release");
let environment = field(value, "environment");
match (release, environment) {
(Some(release), Some(environment)) => format!(" [{release} / {environment}]"),
(Some(release), None) => format!(" [{release}]"),
(None, Some(environment)) => format!(" [{environment}]"),
(None, None) => String::new(),
}
}