Skip to main content

zotron_cli/
output.rs

1//! Output formatting helpers: JSON serialization, list-envelope normalization,
2//! raw value passthrough, and structured error JSON.
3
4use serde_json::Value;
5
6/// Process exit code for caller-side errors (bad params): JSON-RPC `-32602`.
7pub const EXIT_CALLER_ERROR: i32 = 2;
8/// Process exit code for runtime/server errors: JSON-RPC `-32603` and the rest.
9pub const EXIT_RUNTIME_ERROR: i32 = 1;
10
11pub fn format_error_json(message: &str) -> String {
12    classify_error(message).0
13}
14
15/// Classify a CLI error string into a structured JSON envelope plus a
16/// differentiated process exit code.
17///
18/// JSON-RPC errors arrive Display-formatted as `[-32602] <message>`. The
19/// numeric code is surfaced as a stable envelope code (`CALLER_ERROR` /
20/// `RUNTIME_ERROR`) instead of collapsing every RPC failure to one code, and
21/// caller errors (`-32602`) get a distinct non-zero exit so scripts can tell a
22/// bad request apart from a server/runtime failure.
23pub fn classify_error(message: &str) -> (String, i32) {
24    let message = message.trim_end();
25
26    if let Some(rest) = strip_json_rpc_code(message, -32602) {
27        return (error_envelope("CALLER_ERROR", rest), EXIT_CALLER_ERROR);
28    }
29    if let Some(rest) = strip_json_rpc_code(message, -32603) {
30        return (error_envelope("RUNTIME_ERROR", rest), EXIT_RUNTIME_ERROR);
31    }
32
33    let (code, message) = split_error_code(message).unwrap_or(("RUNTIME_ERROR", message));
34    (error_envelope(code, message), EXIT_RUNTIME_ERROR)
35}
36
37fn error_envelope(code: &str, message: &str) -> String {
38    serde_json::json!({"error": {"code": code, "message": message}}).to_string()
39}
40
41/// Strip a `[<code>] ` prefix produced by `RpcError::JsonRpc`'s Display impl,
42/// returning the remaining message when `code` matches.
43fn strip_json_rpc_code(message: &str, code: i64) -> Option<&str> {
44    let prefix = format!("[{code}]");
45    message.strip_prefix(&prefix).map(|rest| rest.trim_start())
46}
47
48pub(crate) fn split_error_code(message: &str) -> Option<(&str, &str)> {
49    let (code, rest) = message.split_once(':')?;
50    if !code.is_empty()
51        && code
52            .chars()
53            .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
54    {
55        Some((code, rest.trim_start()))
56    } else {
57        None
58    }
59}
60
61pub(crate) fn normalize_list_envelope(
62    value: Value,
63    list_key: &str,
64    limit: Option<u64>,
65    offset: u64,
66) -> Value {
67    if let Value::Array(arr) = value {
68        let total = arr.len() as u64;
69        let mut obj = serde_json::Map::new();
70        obj.insert(list_key.to_string(), Value::Array(arr));
71        obj.insert("total".to_string(), Value::from(total));
72        if let Some(limit) = limit {
73            obj.insert("limit".to_string(), Value::from(limit));
74        }
75        obj.insert("offset".to_string(), Value::from(offset));
76        obj.insert("hasMore".to_string(), Value::Bool(false));
77        return Value::Object(obj);
78    }
79
80    let mut obj = match value {
81        Value::Object(obj) if obj.contains_key(list_key) => obj,
82        other => return other,
83    };
84
85    let items_len = obj
86        .get(list_key)
87        .and_then(Value::as_array)
88        .map_or(0, |a| a.len()) as u64;
89    let total = obj
90        .get("total")
91        .and_then(Value::as_u64)
92        .unwrap_or(items_len);
93
94    obj.insert("total".to_string(), Value::from(total));
95    if let Some(limit) = limit {
96        obj.insert("limit".to_string(), Value::from(limit));
97    }
98    obj.insert("offset".to_string(), Value::from(offset));
99    obj.insert(
100        "hasMore".to_string(),
101        Value::Bool(offset + items_len < total),
102    );
103
104    Value::Object(obj)
105}
106
107pub(crate) fn raw_value_output(value: &Value) -> Result<String, String> {
108    // Raw export content (bibtex/ris) arrives as a JSON string and must stay
109    // verbatim. Any other value type is serialized as compact JSON.
110    let mut out = match value {
111        Value::Null => String::new(),
112        Value::String(content) => content.clone(),
113        other => serde_json::to_string(other).map_err(|e| e.to_string())?,
114    };
115    out.push('\n');
116    Ok(out)
117}
118
119pub(crate) fn format_json(value: &Value) -> Result<String, String> {
120    let mut out = serde_json::to_string(value).map_err(|e| e.to_string())?;
121    out.push('\n');
122    Ok(out)
123}