use std::io::Write;
use serde::Serialize;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OutputError {
#[error("invalid output mode: cannot combine --json and --plain")]
ConflictingFlags,
#[error("serialize value: {0}")]
Serialize(#[from] serde_json::Error),
#[error("write output: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum OutputMode {
#[default]
Text,
Json,
Plain, }
#[derive(Debug, Clone, Default)]
pub struct OutputConfig {
pub mode: OutputMode,
pub results_only: bool,
pub select_fields: Vec<String>,
}
impl OutputConfig {
pub fn from_flags(json: bool, plain: bool) -> Result<Self, String> {
if json && plain {
return Err("invalid output mode (cannot combine --json and --plain)".to_string());
}
let mode = if json {
OutputMode::Json
} else if plain {
OutputMode::Plain
} else {
OutputMode::Text
};
Ok(OutputConfig {
mode,
results_only: false,
select_fields: Vec::new(),
})
}
pub fn is_json(&self) -> bool {
self.mode == OutputMode::Json
}
pub fn is_plain(&self) -> bool {
self.mode == OutputMode::Plain
}
}
pub fn write_json<W: Write>(
w: &mut W,
value: &impl Serialize,
config: &OutputConfig,
) -> Result<(), OutputError> {
let mut v: Value = serde_json::to_value(value)?;
if config.results_only {
v = unwrap_primary(v);
}
if !config.select_fields.is_empty() {
v = select_fields(v, &config.select_fields);
}
let s = serde_json::to_string_pretty(&v)?;
w.write_all(s.as_bytes())?;
w.write_all(b"\n")?;
Ok(())
}
fn unwrap_primary(v: Value) -> Value {
let m = match v {
Value::Object(ref map) => map.clone(),
other => return other,
};
if let Some(results) = m.get("results") {
return results.clone();
}
const META: &[&str] = &[
"nextPageToken",
"next_cursor",
"has_more",
"count",
"query",
"dry_run",
"dryRun",
"op",
"action",
"note",
"notes",
];
let candidates: Vec<&str> = m
.keys()
.filter(|k| !META.contains(&k.as_str()))
.map(|k| k.as_str())
.collect();
if candidates.len() == 1 {
return m[candidates[0]].clone();
}
for k in &candidates {
if m[*k].is_array() {
return m[*k].clone();
}
}
const KNOWN: &[&str] = &[
"files",
"threads",
"messages",
"labels",
"events",
"calendars",
"courses",
"topics",
"announcements",
"materials",
"coursework",
"submissions",
"invitations",
"guardians",
"notes",
"contacts",
"people",
"tasks",
"lists",
"groups",
"members",
"drives",
"rules",
"colors",
"spaces",
"request",
];
for k in KNOWN {
if let Some(val) = m.get(*k) {
return val.clone();
}
}
v
}
fn select_fields(v: Value, fields: &[String]) -> Value {
match v {
Value::Array(arr) => {
let projected = arr
.into_iter()
.map(|item| select_fields_from_item(item, fields))
.collect();
Value::Array(projected)
}
other => select_fields_from_item(other, fields),
}
}
fn select_fields_from_item(v: Value, fields: &[String]) -> Value {
let m = match v {
Value::Object(map) => map,
other => return other,
};
let mut out = serde_json::Map::new();
for f in fields {
let tmp = Value::Object(m.clone());
if let Some(val) = get_at_path(&tmp, f) {
out.insert(f.clone(), val);
}
}
Value::Object(out)
}
fn get_at_path(v: &Value, path: &str) -> Option<Value> {
let path = path.trim();
if path.is_empty() {
return None;
}
let mut cur = v;
let mut _owned: Value;
let segs: Vec<&str> = path.split('.').collect();
let last = segs.len() - 1;
for (i, seg) in segs.iter().enumerate() {
let seg = seg.trim();
if seg.is_empty() {
return None;
}
match cur {
Value::Object(map) => {
let next = map.get(seg)?;
if i == last {
return Some(next.clone());
}
_owned = next.clone();
cur = &_owned;
}
Value::Array(arr) => {
let idx: usize = seg.parse().ok()?;
let next = arr.get(idx)?;
if i == last {
return Some(next.clone());
}
_owned = next.clone();
cur = &_owned;
}
_ => return None,
}
}
None
}
pub fn write_table<W: Write>(w: &mut W, rows: &[Vec<String>]) -> Result<(), OutputError> {
if rows.is_empty() {
return Ok(());
}
let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let mut widths = vec![0usize; num_cols];
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < num_cols {
widths[i] = widths[i].max(cell.len());
}
}
}
for row in rows {
let last_col = row.len().saturating_sub(1);
for (i, cell) in row.iter().enumerate() {
if i == last_col {
write!(w, "{}", cell)?;
} else {
write!(w, "{:<width$} ", cell, width = widths[i])?;
}
}
writeln!(w)?;
}
Ok(())
}
pub fn write_tsv<W: Write>(w: &mut W, rows: &[Vec<String>]) -> Result<(), OutputError> {
for row in rows {
let line = row.join("\t");
writeln!(w, "{}", line)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_output_config_from_flags_json() {
let cfg = OutputConfig::from_flags(true, false).unwrap();
assert_eq!(cfg.mode, OutputMode::Json);
assert!(cfg.is_json());
assert!(!cfg.is_plain());
}
#[test]
fn test_output_config_from_flags_plain() {
let cfg = OutputConfig::from_flags(false, true).unwrap();
assert_eq!(cfg.mode, OutputMode::Plain);
assert!(!cfg.is_json());
assert!(cfg.is_plain());
}
#[test]
fn test_output_config_from_flags_default() {
let cfg = OutputConfig::from_flags(false, false).unwrap();
assert_eq!(cfg.mode, OutputMode::Text);
assert!(!cfg.is_json());
assert!(!cfg.is_plain());
}
#[test]
fn test_output_config_from_flags_both_error() {
let err = OutputConfig::from_flags(true, true).unwrap_err();
assert!(err.contains("cannot combine"));
}
#[test]
fn test_unwrap_primary_results_key() {
let v = json!({
"results": [1, 2, 3],
"nextPageToken": "abc"
});
let out = unwrap_primary(v);
assert_eq!(out, json!([1, 2, 3]));
}
#[test]
fn test_unwrap_primary_single_candidate() {
let v = json!({
"messages": [{"id": "1"}, {"id": "2"}],
"nextPageToken": "tok"
});
let out = unwrap_primary(v);
assert_eq!(out, json!([{"id": "1"}, {"id": "2"}]));
}
#[test]
fn test_unwrap_primary_array_preference() {
let v = json!({
"label": "hello",
"items": [1, 2, 3]
});
let out = unwrap_primary(v);
assert_eq!(out, json!([1, 2, 3]));
}
#[test]
fn test_unwrap_primary_passthrough() {
let v = json!([10, 20, 30]);
let out = unwrap_primary(v.clone());
assert_eq!(out, v);
let v2 = json!("just a string");
let out2 = unwrap_primary(v2.clone());
assert_eq!(out2, v2);
}
#[test]
fn test_select_fields_flat() {
let v = json!({"id": "1", "name": "Alice", "email": "alice@example.com"});
let fields = vec!["id".to_string(), "name".to_string()];
let out = select_fields(v, &fields);
assert_eq!(out, json!({"id": "1", "name": "Alice"}));
}
#[test]
fn test_select_fields_array() {
let v = json!([
{"id": "1", "name": "Alice", "email": "a@b.com"},
{"id": "2", "name": "Bob", "email": "b@b.com"}
]);
let fields = vec!["id".to_string(), "name".to_string()];
let out = select_fields(v, &fields);
assert_eq!(
out,
json!([
{"id": "1", "name": "Alice"},
{"id": "2", "name": "Bob"}
])
);
}
#[test]
fn test_get_at_path_nested() {
let v = json!({"user": {"name": "Alice"}});
let result = get_at_path(&v, "user.name");
assert_eq!(result, Some(json!("Alice")));
}
#[test]
fn test_get_at_path_missing() {
let v = json!({"user": {"name": "Alice"}});
let result = get_at_path(&v, "user.email");
assert_eq!(result, None);
}
#[test]
fn test_write_json_basic() {
let cfg = OutputConfig::default();
let value = json!({"hello": "world"});
let mut buf = Vec::new();
write_json(&mut buf, &value, &cfg).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("\"hello\""));
assert!(output.contains("\"world\""));
assert!(output.ends_with('\n'));
let _: Value = serde_json::from_str(output.trim()).unwrap();
}
#[test]
fn test_write_json_results_only() {
let cfg = OutputConfig {
mode: OutputMode::Json,
results_only: true,
select_fields: Vec::new(),
};
let value = json!({
"results": [{"id": "1"}, {"id": "2"}],
"nextPageToken": "token"
});
let mut buf = Vec::new();
write_json(&mut buf, &value, &cfg).unwrap();
let output = String::from_utf8(buf).unwrap();
let parsed: Value = serde_json::from_str(output.trim()).unwrap();
assert!(parsed.is_array());
assert_eq!(parsed.as_array().unwrap().len(), 2);
}
}