Skip to main content

gog_core/
output.rs

1// gog-core output module
2// Ported from internal/outfmt/outfmt.go
3
4use std::io::Write;
5
6use serde::Serialize;
7use serde_json::Value;
8use thiserror::Error;
9
10// ---------------------------------------------------------------------------
11// Error type (local, avoids depending on error.rs stub)
12// ---------------------------------------------------------------------------
13
14#[derive(Debug, Error)]
15pub enum OutputError {
16    #[error("invalid output mode: cannot combine --json and --plain")]
17    ConflictingFlags,
18
19    #[error("serialize value: {0}")]
20    Serialize(#[from] serde_json::Error),
21
22    #[error("write output: {0}")]
23    Io(#[from] std::io::Error),
24}
25
26// ---------------------------------------------------------------------------
27// OutputMode
28// ---------------------------------------------------------------------------
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub enum OutputMode {
32    #[default]
33    Text,
34    Json,
35    Plain, // TSV
36}
37
38// ---------------------------------------------------------------------------
39// OutputConfig
40// ---------------------------------------------------------------------------
41
42#[derive(Debug, Clone, Default)]
43pub struct OutputConfig {
44    pub mode: OutputMode,
45    pub results_only: bool,
46    pub select_fields: Vec<String>,
47}
48
49impl OutputConfig {
50    /// Build an `OutputConfig` from CLI flag values.
51    /// Returns an error if both `json` and `plain` are true.
52    pub fn from_flags(json: bool, plain: bool) -> Result<Self, String> {
53        if json && plain {
54            return Err("invalid output mode (cannot combine --json and --plain)".to_string());
55        }
56        let mode = if json {
57            OutputMode::Json
58        } else if plain {
59            OutputMode::Plain
60        } else {
61            OutputMode::Text
62        };
63        Ok(OutputConfig {
64            mode,
65            results_only: false,
66            select_fields: Vec::new(),
67        })
68    }
69
70    pub fn is_json(&self) -> bool {
71        self.mode == OutputMode::Json
72    }
73
74    pub fn is_plain(&self) -> bool {
75        self.mode == OutputMode::Plain
76    }
77}
78
79// ---------------------------------------------------------------------------
80// write_json
81// ---------------------------------------------------------------------------
82
83/// Serialize `value` to pretty-printed JSON, applying any configured transforms.
84pub fn write_json<W: Write>(
85    w: &mut W,
86    value: &impl Serialize,
87    config: &OutputConfig,
88) -> Result<(), OutputError> {
89    // Roundtrip through serde_json::Value so we can apply transforms.
90    let mut v: Value = serde_json::to_value(value)?;
91
92    if config.results_only {
93        v = unwrap_primary(v);
94    }
95
96    if !config.select_fields.is_empty() {
97        v = select_fields(v, &config.select_fields);
98    }
99
100    // Pretty-print without HTML escaping (mirrors Go's SetEscapeHTML(false)).
101    let s = serde_json::to_string_pretty(&v)?;
102    w.write_all(s.as_bytes())?;
103    // Go's json.Encoder adds a trailing newline; keep parity.
104    w.write_all(b"\n")?;
105    Ok(())
106}
107
108// ---------------------------------------------------------------------------
109// unwrap_primary  (--results-only logic)
110// ---------------------------------------------------------------------------
111
112/// Unwrap the "primary" payload from an envelope object.
113///
114/// Precedence:
115/// 1. Explicit `"results"` key.
116/// 2. Single non-meta key.
117/// 3. Any array-typed candidate.
118/// 4. Known result-list keys.
119/// 5. Fall back to `v` unchanged.
120fn unwrap_primary(v: Value) -> Value {
121    let m = match v {
122        Value::Object(ref map) => map.clone(),
123        other => return other,
124    };
125
126    // 1. Explicit "results" key.
127    if let Some(results) = m.get("results") {
128        return results.clone();
129    }
130
131    // Meta / envelope keys to skip.
132    const META: &[&str] = &[
133        "nextPageToken",
134        "next_cursor",
135        "has_more",
136        "count",
137        "query",
138        "dry_run",
139        "dryRun",
140        "op",
141        "action",
142        "note",
143        "notes",
144    ];
145
146    let candidates: Vec<&str> = m
147        .keys()
148        .filter(|k| !META.contains(&k.as_str()))
149        .map(|k| k.as_str())
150        .collect();
151
152    // 2. Single non-meta key.
153    if candidates.len() == 1 {
154        return m[candidates[0]].clone();
155    }
156
157    // 3. Any array candidate — prefer the first one found.
158    for k in &candidates {
159        if m[*k].is_array() {
160            return m[*k].clone();
161        }
162    }
163
164    // 4. Known result-list keys (in priority order).
165    const KNOWN: &[&str] = &[
166        "files",
167        "threads",
168        "messages",
169        "labels",
170        "events",
171        "calendars",
172        "courses",
173        "topics",
174        "announcements",
175        "materials",
176        "coursework",
177        "submissions",
178        "invitations",
179        "guardians",
180        "notes",
181        "contacts",
182        "people",
183        "tasks",
184        "lists",
185        "groups",
186        "members",
187        "drives",
188        "rules",
189        "colors",
190        "spaces",
191        "request",
192    ];
193    for k in KNOWN {
194        if let Some(val) = m.get(*k) {
195            return val.clone();
196        }
197    }
198
199    // 5. Fall through.
200    v
201}
202
203// ---------------------------------------------------------------------------
204// select_fields  (--select logic)
205// ---------------------------------------------------------------------------
206
207/// Project `v` to only the specified fields.
208/// When `v` is an array, each element is projected.
209fn select_fields(v: Value, fields: &[String]) -> Value {
210    match v {
211        Value::Array(arr) => {
212            let projected = arr
213                .into_iter()
214                .map(|item| select_fields_from_item(item, fields))
215                .collect();
216            Value::Array(projected)
217        }
218        other => select_fields_from_item(other, fields),
219    }
220}
221
222fn select_fields_from_item(v: Value, fields: &[String]) -> Value {
223    let m = match v {
224        Value::Object(map) => map,
225        other => return other,
226    };
227
228    let mut out = serde_json::Map::new();
229    for f in fields {
230        // Build a temporary Value::Object to allow get_at_path to work.
231        let tmp = Value::Object(m.clone());
232        if let Some(val) = get_at_path(&tmp, f) {
233            out.insert(f.clone(), val);
234        }
235    }
236    Value::Object(out)
237}
238
239// ---------------------------------------------------------------------------
240// get_at_path  (dot-path access)
241// ---------------------------------------------------------------------------
242
243/// Traverse `v` along a dot-separated `path` and return the value at that
244/// location, or `None` if any segment is missing.
245fn get_at_path(v: &Value, path: &str) -> Option<Value> {
246    let path = path.trim();
247    if path.is_empty() {
248        return None;
249    }
250
251    let mut cur = v;
252    // We need owned intermediate values for the loop; use a local holder.
253    let mut _owned: Value;
254
255    let segs: Vec<&str> = path.split('.').collect();
256    let last = segs.len() - 1;
257
258    for (i, seg) in segs.iter().enumerate() {
259        let seg = seg.trim();
260        if seg.is_empty() {
261            return None;
262        }
263
264        match cur {
265            Value::Object(map) => {
266                let next = map.get(seg)?;
267                if i == last {
268                    return Some(next.clone());
269                }
270                _owned = next.clone();
271                cur = &_owned;
272            }
273            Value::Array(arr) => {
274                let idx: usize = seg.parse().ok()?;
275                let next = arr.get(idx)?;
276                if i == last {
277                    return Some(next.clone());
278                }
279                _owned = next.clone();
280                cur = &_owned;
281            }
282            _ => return None,
283        }
284    }
285
286    None
287}
288
289// ---------------------------------------------------------------------------
290// write_table  (text mode output)
291// ---------------------------------------------------------------------------
292
293/// A simple tabwriter that pads columns with spaces.
294///
295/// Mirrors Go's `text/tabwriter` with minwidth=0, tabwidth=4, padding=2.
296pub fn write_table<W: Write>(w: &mut W, rows: &[Vec<String>]) -> Result<(), OutputError> {
297    if rows.is_empty() {
298        return Ok(());
299    }
300
301    // Compute max width per column.
302    let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
303    let mut widths = vec![0usize; num_cols];
304    for row in rows {
305        for (i, cell) in row.iter().enumerate() {
306            if i < num_cols {
307                widths[i] = widths[i].max(cell.len());
308            }
309        }
310    }
311
312    for row in rows {
313        let last_col = row.len().saturating_sub(1);
314        for (i, cell) in row.iter().enumerate() {
315            if i == last_col {
316                // Last column: no trailing padding.
317                write!(w, "{}", cell)?;
318            } else {
319                // Pad to column width + 2 spaces gap.
320                write!(w, "{:<width$}  ", cell, width = widths[i])?;
321            }
322        }
323        writeln!(w)?;
324    }
325    Ok(())
326}
327
328/// Write tab-separated values (TSV) — used for `--plain` mode.
329pub fn write_tsv<W: Write>(w: &mut W, rows: &[Vec<String>]) -> Result<(), OutputError> {
330    for row in rows {
331        let line = row.join("\t");
332        writeln!(w, "{}", line)?;
333    }
334    Ok(())
335}
336
337// ---------------------------------------------------------------------------
338// Tests
339// ---------------------------------------------------------------------------
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use serde_json::json;
345
346    // ------------------------------------------------------------------
347    // OutputConfig::from_flags
348    // ------------------------------------------------------------------
349
350    #[test]
351    fn test_output_config_from_flags_json() {
352        let cfg = OutputConfig::from_flags(true, false).unwrap();
353        assert_eq!(cfg.mode, OutputMode::Json);
354        assert!(cfg.is_json());
355        assert!(!cfg.is_plain());
356    }
357
358    #[test]
359    fn test_output_config_from_flags_plain() {
360        let cfg = OutputConfig::from_flags(false, true).unwrap();
361        assert_eq!(cfg.mode, OutputMode::Plain);
362        assert!(!cfg.is_json());
363        assert!(cfg.is_plain());
364    }
365
366    #[test]
367    fn test_output_config_from_flags_default() {
368        let cfg = OutputConfig::from_flags(false, false).unwrap();
369        assert_eq!(cfg.mode, OutputMode::Text);
370        assert!(!cfg.is_json());
371        assert!(!cfg.is_plain());
372    }
373
374    #[test]
375    fn test_output_config_from_flags_both_error() {
376        let err = OutputConfig::from_flags(true, true).unwrap_err();
377        assert!(err.contains("cannot combine"));
378    }
379
380    // ------------------------------------------------------------------
381    // unwrap_primary
382    // ------------------------------------------------------------------
383
384    #[test]
385    fn test_unwrap_primary_results_key() {
386        let v = json!({
387            "results": [1, 2, 3],
388            "nextPageToken": "abc"
389        });
390        let out = unwrap_primary(v);
391        assert_eq!(out, json!([1, 2, 3]));
392    }
393
394    #[test]
395    fn test_unwrap_primary_single_candidate() {
396        let v = json!({
397            "messages": [{"id": "1"}, {"id": "2"}],
398            "nextPageToken": "tok"
399        });
400        let out = unwrap_primary(v);
401        assert_eq!(out, json!([{"id": "1"}, {"id": "2"}]));
402    }
403
404    #[test]
405    fn test_unwrap_primary_array_preference() {
406        // "label" is a string candidate, "items" is an array candidate.
407        // Array should be preferred.
408        let v = json!({
409            "label": "hello",
410            "items": [1, 2, 3]
411        });
412        let out = unwrap_primary(v);
413        assert_eq!(out, json!([1, 2, 3]));
414    }
415
416    #[test]
417    fn test_unwrap_primary_passthrough() {
418        // Non-object values are returned as-is.
419        let v = json!([10, 20, 30]);
420        let out = unwrap_primary(v.clone());
421        assert_eq!(out, v);
422
423        let v2 = json!("just a string");
424        let out2 = unwrap_primary(v2.clone());
425        assert_eq!(out2, v2);
426    }
427
428    // ------------------------------------------------------------------
429    // select_fields
430    // ------------------------------------------------------------------
431
432    #[test]
433    fn test_select_fields_flat() {
434        let v = json!({"id": "1", "name": "Alice", "email": "alice@example.com"});
435        let fields = vec!["id".to_string(), "name".to_string()];
436        let out = select_fields(v, &fields);
437        assert_eq!(out, json!({"id": "1", "name": "Alice"}));
438    }
439
440    #[test]
441    fn test_select_fields_array() {
442        let v = json!([
443            {"id": "1", "name": "Alice", "email": "a@b.com"},
444            {"id": "2", "name": "Bob",   "email": "b@b.com"}
445        ]);
446        let fields = vec!["id".to_string(), "name".to_string()];
447        let out = select_fields(v, &fields);
448        assert_eq!(
449            out,
450            json!([
451                {"id": "1", "name": "Alice"},
452                {"id": "2", "name": "Bob"}
453            ])
454        );
455    }
456
457    // ------------------------------------------------------------------
458    // get_at_path
459    // ------------------------------------------------------------------
460
461    #[test]
462    fn test_get_at_path_nested() {
463        let v = json!({"user": {"name": "Alice"}});
464        let result = get_at_path(&v, "user.name");
465        assert_eq!(result, Some(json!("Alice")));
466    }
467
468    #[test]
469    fn test_get_at_path_missing() {
470        let v = json!({"user": {"name": "Alice"}});
471        let result = get_at_path(&v, "user.email");
472        assert_eq!(result, None);
473    }
474
475    // ------------------------------------------------------------------
476    // write_json
477    // ------------------------------------------------------------------
478
479    #[test]
480    fn test_write_json_basic() {
481        let cfg = OutputConfig::default();
482        let value = json!({"hello": "world"});
483        let mut buf = Vec::new();
484        write_json(&mut buf, &value, &cfg).unwrap();
485        let output = String::from_utf8(buf).unwrap();
486        // Should be pretty-printed JSON ending with a newline.
487        assert!(output.contains("\"hello\""));
488        assert!(output.contains("\"world\""));
489        assert!(output.ends_with('\n'));
490        // Must be valid JSON.
491        let _: Value = serde_json::from_str(output.trim()).unwrap();
492    }
493
494    #[test]
495    fn test_write_json_results_only() {
496        let cfg = OutputConfig {
497            mode: OutputMode::Json,
498            results_only: true,
499            select_fields: Vec::new(),
500        };
501        let value = json!({
502            "results": [{"id": "1"}, {"id": "2"}],
503            "nextPageToken": "token"
504        });
505        let mut buf = Vec::new();
506        write_json(&mut buf, &value, &cfg).unwrap();
507        let output = String::from_utf8(buf).unwrap();
508        let parsed: Value = serde_json::from_str(output.trim()).unwrap();
509        // Should have been unwrapped to the array.
510        assert!(parsed.is_array());
511        assert_eq!(parsed.as_array().unwrap().len(), 2);
512    }
513}