use std::collections::BTreeMap;
use serde::Serialize;
use serde_json::{Map, Value};
use crate::Error;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Output {
format: OutputFormat,
format_set: bool,
fields: Vec<String>,
schema: Option<Value>,
}
impl Output {
#[must_use]
pub const fn format(&self) -> OutputFormat {
self.format
}
#[must_use]
pub fn fields(&self) -> &[String] {
&self.fields
}
pub fn value<T: Serialize>(&self, value: &T) -> Result<Value, OutputError> {
let value = serde_json::to_value(value).map_err(OutputError::Serialize)?;
if self.fields.is_empty() {
return Ok(value);
}
if self.schema.is_none() {
return Err(OutputError::UnavailableFields);
}
Ok(project(value, &self.fields))
}
pub fn render_json<T: Serialize>(&self, value: &T) -> Result<String, OutputError> {
serde_json::to_string(&self.value(value)?).map_err(OutputError::Serialize)
}
pub(crate) fn set_format(&mut self, bytes: &[u8]) -> Result<(), Error> {
if self.format_set {
return Err(Error::DuplicateArgument { name: "--output" });
}
let value = std::str::from_utf8(bytes)
.map_err(|_| Error::InvalidUtf8 { name: "--output", value: bytes.to_vec() })?;
self.format = match value {
"text" => OutputFormat::Text,
"json" => OutputFormat::Json,
_ => {
return Err(Error::InvalidValue(Box::new(crate::InvalidValue {
name: "--output",
value: value.to_owned(),
reason: "expected one of: text, json".to_owned(),
})));
}
};
self.format_set = true;
Ok(())
}
pub(crate) fn push_fields(&mut self, bytes: &[u8]) -> Result<(), Error> {
for value in crate::cli::value::comma_values(vec![bytes.to_vec()]) {
let field = std::str::from_utf8(&value)
.map_err(|_| Error::InvalidUtf8 { name: "--fields", value: value.clone() })?;
let field = field.trim();
if field.is_empty() {
return Err(Error::InvalidValue(Box::new(crate::InvalidValue {
name: "--fields",
value: String::from_utf8_lossy(bytes).into_owned(),
reason: "field selectors must not be empty".to_owned(),
})));
}
if !self.fields.iter().any(|existing| existing == field) {
self.fields.push(field.to_owned());
}
}
Ok(())
}
pub(crate) fn finish(&mut self, schema: Option<Value>) -> Result<(), Error> {
if self.fields.is_empty() {
return Ok(());
}
if self.format != OutputFormat::Json {
return Err(Error::MissingRequirement {
name: "--output json",
required_by: "--fields",
});
}
let Some(schema) = schema else {
return Err(Error::OutputFieldsUnavailable);
};
for field in &self.fields {
if !valid_path(&schema, field) {
return Err(Error::InvalidOutputField { field: field.clone() });
}
}
self.schema = Some(schema);
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum OutputError {
#[error("failed to serialize handler output: {0}")]
Serialize(serde_json::Error),
#[error("field selection is unavailable for this command")]
UnavailableFields,
}
#[derive(Debug)]
pub struct Invocation<T> {
pub command: T,
pub output: Output,
}
impl<T> Invocation<T> {
#[must_use]
pub fn into_parts(self) -> (T, Output) {
(self.command, self.output)
}
}
fn project(value: Value, fields: &[String]) -> Value {
let mut tree = FieldTree::default();
for field in fields {
insert_path(&mut tree, field);
}
project_tree(value, &tree)
}
#[derive(Default)]
struct FieldTree<'a>(BTreeMap<&'a str, Self>);
fn insert_path<'a>(tree: &mut FieldTree<'a>, path: &'a str) {
let mut current = tree;
for segment in path.split('.') {
current = current.0.entry(segment).or_default();
}
}
fn project_tree(value: Value, tree: &FieldTree<'_>) -> Value {
match value {
Value::Object(object) => {
let mut projected = Map::new();
for (field, children) in &tree.0 {
if let Some(value) = object.get(*field) {
let value = if children.0.is_empty() {
value.clone()
} else {
project_tree(value.clone(), children)
};
projected.insert((*field).to_owned(), value);
}
}
Value::Object(projected)
}
Value::Array(values) => {
Value::Array(values.into_iter().map(|value| project_tree(value, tree)).collect())
}
value => value,
}
}
fn valid_path(schema: &Value, path: &str) -> bool {
let mut current = schema.get("$defs").and_then(|defs| defs.get("result"));
for segment in path.split('.') {
current = current.and_then(|current| property_schema(current, segment, schema));
if current.is_none() {
return false;
}
}
current.is_some()
}
fn property_schema<'a>(schema: &'a Value, name: &str, root: &'a Value) -> Option<&'a Value> {
let schema = dereference(schema, root)?;
if let Some(items) = schema.get("items") {
return property_schema(items, name, root);
}
if let Some(property) = schema.get("properties").and_then(|properties| properties.get(name)) {
return Some(property);
}
for keyword in ["anyOf", "oneOf", "allOf"] {
if let Some(branches) = schema.get(keyword).and_then(Value::as_array)
&& let Some(property) =
branches.iter().find_map(|branch| property_schema(branch, name, root))
{
return Some(property);
}
}
None
}
fn dereference<'a>(schema: &'a Value, root: &'a Value) -> Option<&'a Value> {
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return Some(schema);
};
let pointer = reference.strip_prefix('#')?;
root.pointer(pointer)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn fields_split_trim_deduplicate_and_project_nested_arrays() {
let mut output = Output::default();
output.push_fields(b"id, items.name").unwrap();
output.push_fields(b"items.name,items.id").unwrap();
assert_eq!(output.fields(), ["id", "items.name", "items.id"]);
let value = json!({
"id": 7,
"ignored": true,
"items": [
{"id": 1, "name": "one", "ignored": 1},
{"id": 2, "name": "two", "ignored": 2}
]
});
assert_eq!(
project(value, output.fields()),
json!({"id": 7, "items": [{"id": 1, "name": "one"}, {"id": 2, "name": "two"}]})
);
}
#[test]
fn empty_field_is_rejected() {
let mut output = Output::default();
assert!(output.push_fields(b"id,").is_err());
}
}