Skip to main content

spreadsheet_kit/cli/
output.rs

1use crate::cli::{OutputFormat, OutputShape};
2use crate::response_prune::prune_non_structural_empties;
3use anyhow::{Result, bail};
4use serde_json::{Map, Value};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum CompactProjectionTarget {
8    None,
9    RangeValues,
10    ReadTable,
11    SheetPage,
12    FormulaTrace,
13}
14
15pub fn emit_value(
16    value: &Value,
17    format: OutputFormat,
18    shape: OutputShape,
19    projection_target: CompactProjectionTarget,
20    compact: bool,
21    quiet: bool,
22) -> Result<()> {
23    if matches!(format, OutputFormat::Csv) {
24        bail!("csv output is not implemented yet for agent-spreadsheet")
25    }
26
27    let mut value = value.clone();
28    prune_non_structural_empties(&mut value);
29    apply_shape(&mut value, shape, projection_target);
30
31    let stdout = std::io::stdout();
32    let mut handle = stdout.lock();
33    let _ = (compact, quiet);
34    serde_json::to_writer(&mut handle, &value)?;
35    use std::io::Write;
36    handle.write_all(b"\n")?;
37    Ok(())
38}
39
40fn apply_shape(value: &mut Value, shape: OutputShape, projection_target: CompactProjectionTarget) {
41    if !matches!(shape, OutputShape::Compact) {
42        return;
43    }
44
45    match projection_target {
46        CompactProjectionTarget::None => {}
47        CompactProjectionTarget::RangeValues => project_range_values_compact(value),
48        CompactProjectionTarget::ReadTable => project_read_table_compact(value),
49        CompactProjectionTarget::SheetPage => project_sheet_page_compact(value),
50        CompactProjectionTarget::FormulaTrace => project_formula_trace_compact(value),
51    }
52}
53
54fn project_range_values_compact(value: &mut Value) {
55    // Keep range-values contract stable across canonical/compact. Global pruning
56    // already removes empty wrappers before projection.
57    let _ = value;
58}
59
60fn project_read_table_compact(value: &mut Value) {
61    // Ticket 3109 contract: keep root object and active payload branch unchanged;
62    // only drop empty wrappers when present.
63    let Value::Object(obj) = value else {
64        return;
65    };
66    drop_empty_wrappers(obj, &["headers", "types"]);
67}
68
69fn project_sheet_page_compact(value: &mut Value) {
70    // Ticket 3109 contract: preserve active payload branch (`rows` | `compact` |
71    // `values_only`) and continuation fields without flattening or collapsing.
72    // Global pruning already removed empty wrappers before projection.
73    let _ = value;
74}
75
76fn project_formula_trace_compact(value: &mut Value) {
77    // Ticket 3109 contract: compact projection omits per-layer highlights while
78    // preserving depth/summary/edges/has_more and top-level continuation fields.
79    let Some(layers) = value.get_mut("layers").and_then(Value::as_array_mut) else {
80        return;
81    };
82
83    for layer in layers {
84        if let Value::Object(layer_obj) = layer {
85            layer_obj.remove("highlights");
86        }
87    }
88}
89
90fn drop_empty_wrappers(target: &mut Map<String, Value>, keys: &[&str]) {
91    for key in keys {
92        let should_remove = target
93            .get(*key)
94            .map(|value| {
95                value.is_null()
96                    || value.as_array().is_some_and(Vec::is_empty)
97                    || value.as_object().is_some_and(Map::is_empty)
98            })
99            .unwrap_or(false);
100        if should_remove {
101            target.remove(*key);
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use serde_json::json;
110
111    #[test]
112    fn compact_shape_3109_formula_trace_omits_highlights_only_for_trace_target() {
113        let mut compact_payload = json!({
114            "workbook_id": "wb",
115            "sheet_name": "Sheet1",
116            "origin": "A1",
117            "direction": "dependents",
118            "next_cursor": { "depth": 1, "offset": 5 },
119            "notes": ["note"],
120            "layers": [{
121                "depth": 1,
122                "summary": { "total_nodes": 1 },
123                "highlights": { "top_ranges": [], "top_formula_groups": [], "notable_cells": [] },
124                "edges": [{ "from": "A1", "to": "B1" }],
125                "has_more": true
126            }]
127        });
128
129        apply_shape(
130            &mut compact_payload,
131            OutputShape::Compact,
132            CompactProjectionTarget::FormulaTrace,
133        );
134
135        let layer = compact_payload["layers"]
136            .as_array()
137            .expect("layers array")
138            .first()
139            .cloned()
140            .expect("first layer");
141        assert!(layer.get("highlights").is_none());
142        assert_eq!(layer["depth"], json!(1));
143        assert!(layer.get("summary").is_some());
144        assert!(layer.get("edges").is_some());
145        assert_eq!(layer["has_more"], json!(true));
146        assert_eq!(compact_payload["next_cursor"]["depth"], json!(1));
147        assert_eq!(compact_payload["next_cursor"]["offset"], json!(5));
148    }
149
150    #[test]
151    fn compact_shape_3109_none_target_does_not_apply_trace_projection() {
152        let mut payload = json!({
153            "layers": [{
154                "depth": 1,
155                "summary": { "total_nodes": 1 },
156                "highlights": { "top_ranges": [] },
157                "edges": [],
158                "has_more": false
159            }]
160        });
161
162        apply_shape(
163            &mut payload,
164            OutputShape::Compact,
165            CompactProjectionTarget::None,
166        );
167
168        let layer = payload["layers"]
169            .as_array()
170            .expect("layers array")
171            .first()
172            .expect("first layer");
173        assert!(layer.get("highlights").is_some());
174    }
175
176    #[test]
177    fn compact_shape_3109_range_values_keeps_stable_shape() {
178        let base_payload = json!({
179            "workbook_id": "wb",
180            "sheet_name": "Sheet1",
181            "values": [{
182                "range": "A1:B2",
183                "rows": [[{"kind":"text","value":"x"}]],
184                "next_start_row": 3
185            }]
186        });
187
188        let mut compact_range_values = base_payload.clone();
189        apply_shape(
190            &mut compact_range_values,
191            OutputShape::Compact,
192            CompactProjectionTarget::RangeValues,
193        );
194        assert!(compact_range_values.get("values").is_some());
195        assert!(compact_range_values.get("range").is_none());
196
197        let mut compact_none_target = base_payload;
198        apply_shape(
199            &mut compact_none_target,
200            OutputShape::Compact,
201            CompactProjectionTarget::None,
202        );
203        assert!(compact_none_target.get("values").is_some());
204        assert!(compact_none_target.get("range").is_none());
205    }
206}