Skip to main content

oxide_batch_cli/
output.rs

1//! Bounded human and machine output.
2//!
3//! Both forms are rendered from one already-redacted projection, so a redaction
4//! rule cannot hold in the machine form while the human form leaks. Output is
5//! written only after a mutating command's durable effect is committed, so a
6//! display failure can never lose an effect.
7
8use serde_json::{Map, Value, json};
9
10use crate::args::OutputForm;
11use crate::command::Command;
12use crate::exit::{ExitCategory, Outcome};
13use crate::host::Host;
14
15/// The integer CLI output schema version.
16pub const OUTPUT_SCHEMA_VERSION: u64 = 1;
17
18/// Largest encoded response written for one invocation.
19pub const MAX_OUTPUT_BYTES: usize = 256 * 1024;
20
21/// Pagination fields of a paginated command.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct PageInfo {
24    /// The bound the traversal requested.
25    pub page_size: u16,
26    /// The number of rows this page returned.
27    pub returned: usize,
28    /// The opaque token that continues the traversal.
29    pub next_cursor: Option<String>,
30}
31
32/// One bounded, redacted diagnostic record.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct Diagnostic {
35    /// Stable machine code.
36    pub code: String,
37    /// Safe-to-display detail. Never a secret, SQL, or user error text.
38    pub detail: String,
39}
40
41impl Diagnostic {
42    /// Builds one diagnostic record.
43    #[must_use]
44    pub fn new(code: impl Into<String>, detail: impl Into<String>) -> Self {
45        Self {
46            code: code.into(),
47            detail: detail.into(),
48        }
49    }
50}
51
52/// The complete result of one invocation.
53#[derive(Clone, Debug)]
54pub struct Response {
55    command: Command,
56    category: ExitCategory,
57    data: Value,
58    page: Option<PageInfo>,
59    diagnostics: Vec<Diagnostic>,
60}
61
62impl Response {
63    /// Builds a successful response carrying one redacted projection.
64    #[must_use]
65    pub const fn success(command: Command, data: Value) -> Self {
66        Self {
67            command,
68            category: ExitCategory::Success,
69            data,
70            page: None,
71            diagnostics: Vec::new(),
72        }
73    }
74
75    /// Builds a response that reports a non-success category.
76    #[must_use]
77    pub const fn failed(command: Command, category: ExitCategory, data: Value) -> Self {
78        Self {
79            command,
80            category,
81            data,
82            page: None,
83            diagnostics: Vec::new(),
84        }
85    }
86
87    /// Attaches the pagination fields of a paginated command.
88    #[must_use]
89    pub fn with_page(mut self, page: PageInfo) -> Self {
90        self.page = Some(page);
91        self
92    }
93
94    /// Attaches one bounded diagnostic record.
95    #[must_use]
96    pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
97        self.diagnostics.push(diagnostic);
98        self
99    }
100
101    /// Returns the exit category this response reports.
102    #[must_use]
103    pub const fn category(&self) -> ExitCategory {
104        self.category
105    }
106
107    /// Returns the envelope outcome this response reports.
108    #[must_use]
109    pub const fn outcome(&self) -> Outcome {
110        self.category.outcome()
111    }
112}
113
114/// A failure to write the result.
115///
116/// The operation identifier lets the caller re-read or replay the request, so
117/// the CLI never repeats a mutating call to recover from a display failure.
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub struct OutputFailure;
120
121/// Renders responses in the selected form.
122#[derive(Clone, Copy, Debug)]
123pub struct Writer {
124    form: OutputForm,
125    color: bool,
126}
127
128impl Writer {
129    /// Binds one output form and styling decision.
130    #[must_use]
131    pub const fn new(form: OutputForm, color: bool) -> Self {
132        Self { form, color }
133    }
134
135    /// Returns the selected output form.
136    #[must_use]
137    pub const fn form(&self) -> OutputForm {
138        self.form
139    }
140
141    /// Writes one response.
142    ///
143    /// Output stops at the first write failure and performs no further write,
144    /// so a closed pipe produces one bounded attempt rather than a loop.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`OutputFailure`] when standard output cannot be written.
149    pub fn emit<H: Host>(&self, host: &mut H, response: &Response) -> Result<(), OutputFailure> {
150        let rendered = match self.form {
151            OutputForm::Json => fit(&response.data, |data, truncated| {
152                render_json(response, data, truncated)
153            }),
154            OutputForm::Human => fit(&response.data, |data, truncated| {
155                self.render_human(response, data, truncated)
156            }),
157        };
158        // Every write failure is the same observable event: the result could
159        // not be delivered. The underlying error is deliberately not inspected,
160        // because its message may name a path or a device.
161        host.write_stdout(rendered.as_bytes())
162            .and_then(|()| host.flush_stdout())
163            .map_err(|_| OutputFailure)
164    }
165
166    /// Renders the unversioned presentation form from already-bounded data.
167    fn render_human(self, response: &Response, data: &Value, truncated: bool) -> String {
168        use std::fmt::Write as _;
169
170        let mut text = String::new();
171        if !matches!(response.outcome(), Outcome::Success) {
172            self.heading(response.category.as_str(), &mut text);
173            text.push('\n');
174        }
175        render_value(data, 0, self, &mut text);
176        if let Some(page) = &response.page {
177            self.heading("page", &mut text);
178            text.push('\n');
179            // Writing into a `String` cannot fail, so the formatting result
180            // carries no information a caller could act on.
181            let _ = writeln!(
182                text,
183                "  page_size: {}\n  returned: {}",
184                page.page_size, page.returned
185            );
186            match &page.next_cursor {
187                Some(cursor) => {
188                    let _ = writeln!(text, "  next_cursor: {cursor}");
189                }
190                None => text.push_str("  next_cursor: -\n"),
191            }
192        }
193        for diagnostic in &response.diagnostics {
194            let _ = writeln!(text, "{}: {}", diagnostic.code, diagnostic.detail);
195        }
196        if truncated {
197            text.push_str("truncated: true\n");
198        }
199        text
200    }
201
202    /// Appends a heading, styled when styling is enabled.
203    fn heading(self, text: &str, target: &mut String) {
204        if self.color {
205            target.push_str("\u{1b}[1m");
206            target.push_str(text);
207            target.push_str("\u{1b}[0m");
208        } else {
209            target.push_str(text);
210        }
211    }
212}
213
214/// Renders the versioned machine envelope from already-bounded data.
215fn render_json(response: &Response, data: &Value, truncated: bool) -> String {
216    let mut envelope = Map::new();
217    envelope.insert("schema_version".to_owned(), json!(OUTPUT_SCHEMA_VERSION));
218    envelope.insert("command".to_owned(), json!(response.command.as_str()));
219    envelope.insert("outcome".to_owned(), json!(response.outcome().as_str()));
220    envelope.insert("data".to_owned(), data.clone());
221    if let Some(page) = &response.page {
222        envelope.insert(
223            "page".to_owned(),
224            json!({
225                "page_size": page.page_size,
226                "returned": page.returned,
227                "next_cursor": page.next_cursor,
228            }),
229        );
230    }
231    envelope.insert(
232        "diagnostics".to_owned(),
233        Value::Array(
234            response
235                .diagnostics
236                .iter()
237                .map(|diagnostic| json!({ "code": diagnostic.code, "detail": diagnostic.detail }))
238                .collect(),
239        ),
240    );
241    envelope.insert("truncated".to_owned(), json!(truncated));
242    let mut encoded = Value::Object(envelope).to_string();
243    encoded.push('\n');
244    encoded
245}
246
247/// Renders one JSON value as indented human text.
248fn render_value(value: &Value, depth: usize, writer: Writer, text: &mut String) {
249    use std::fmt::Write as _;
250
251    let indent = "  ".repeat(depth);
252    match value {
253        Value::Object(entries) => {
254            for (key, entry) in entries {
255                match entry {
256                    Value::Object(_) | Value::Array(_) => {
257                        text.push_str(&indent);
258                        writer.heading(key, text);
259                        text.push('\n');
260                        render_value(entry, depth + 1, writer, text);
261                    }
262                    _ => {
263                        let _ = writeln!(text, "{indent}{key}: {}", scalar(entry));
264                    }
265                }
266            }
267        }
268        Value::Array(rows) => {
269            if rows.is_empty() {
270                let _ = writeln!(text, "{indent}-");
271            }
272            for row in rows {
273                match row {
274                    Value::Object(_) | Value::Array(_) => {
275                        let _ = writeln!(text, "{indent}-");
276                        render_value(row, depth + 1, writer, text);
277                    }
278                    _ => {
279                        let _ = writeln!(text, "{indent}- {}", scalar(row));
280                    }
281                }
282            }
283        }
284        _ => {
285            let _ = writeln!(text, "{indent}{}", scalar(value));
286        }
287    }
288}
289
290/// Renders one scalar without quoting noise.
291fn scalar(value: &Value) -> String {
292    match value {
293        Value::Null => "-".to_owned(),
294        Value::String(text) => text.clone(),
295        other => other.to_string(),
296    }
297}
298
299/// Applies the response bound to the complete rendered output.
300///
301/// The bound covers everything written, not only the projection: an envelope
302/// carrying a large page or diagnostic list cannot push the result past the
303/// bound while still reporting `truncated: false`.
304///
305/// Rows are dropped from the end of an array, and keys from the end of an
306/// object, until the rendered output fits, so a bounded page degrades to fewer
307/// rows rather than to silently missing content without the flag. The largest
308/// fitting prefix is found by bisection rather than by removing one element at
309/// a time, so a full page costs a handful of renders instead of one per row.
310fn fit(data: &Value, render: impl Fn(&Value, bool) -> String) -> String {
311    let full = render(data, false);
312    if full.len() <= MAX_OUTPUT_BYTES {
313        return full;
314    }
315    match data {
316        Value::Array(rows) => {
317            let keep = largest_fitting(rows.len(), |count| {
318                render(&Value::Array(rows[..count].to_vec()), true).len() <= MAX_OUTPUT_BYTES
319            });
320            match keep {
321                Some(count) => render(&Value::Array(rows[..count].to_vec()), true),
322                None => omitted(&render),
323            }
324        }
325        Value::Object(entries) => {
326            let keys: Vec<String> = entries.keys().cloned().collect();
327            let prefix = |count: usize| {
328                let mut kept = Map::new();
329                for key in keys.iter().take(count) {
330                    if let Some(value) = entries.get(key) {
331                        kept.insert(key.clone(), value.clone());
332                    }
333                }
334                Value::Object(kept)
335            };
336            let keep = largest_fitting(keys.len(), |count| {
337                render(&prefix(count), true).len() <= MAX_OUTPUT_BYTES
338            });
339            match keep {
340                Some(count) => render(&prefix(count), true),
341                None => omitted(&render),
342            }
343        }
344        _ => omitted(&render),
345    }
346}
347
348/// Renders the marker used when nothing of the projection fits.
349fn omitted(render: &impl Fn(&Value, bool) -> String) -> String {
350    render(
351        &json!({ "omitted": "the value exceeds the response bound" }),
352        true,
353    )
354}
355
356/// Returns the largest `count` in `0..=len` for which `fits` holds.
357///
358/// `fits` is monotonic: a shorter prefix of the same value never renders
359/// larger than a longer one. Returns `None` when even the empty prefix does not
360/// fit, which means the envelope alone exceeds the bound.
361fn largest_fitting(len: usize, fits: impl Fn(usize) -> bool) -> Option<usize> {
362    if !fits(0) {
363        return None;
364    }
365    let (mut low, mut high) = (0_usize, len);
366    while low < high {
367        let middle = low + (high - low).div_ceil(2);
368        if fits(middle) {
369            low = middle;
370        } else {
371            high = middle - 1;
372        }
373    }
374    Some(low)
375}
376
377#[cfg(test)]
378mod tests {
379    #![allow(clippy::expect_used, clippy::panic)]
380
381    use serde_json::json;
382
383    use super::{
384        Diagnostic, MAX_OUTPUT_BYTES, OUTPUT_SCHEMA_VERSION, PageInfo, Response, Writer,
385        render_json,
386    };
387    use crate::args::OutputForm;
388    use crate::command::Command;
389    use crate::exit::ExitCategory;
390    use crate::host::testing::TestHost;
391
392    fn json_writer() -> Writer {
393        Writer::new(OutputForm::Json, false)
394    }
395
396    #[test]
397    fn the_envelope_carries_every_published_field() {
398        let mut host = TestHost::new();
399        let response = Response::success(Command::JobList, json!(["orders"]))
400            .with_page(PageInfo {
401                page_size: 50,
402                returned: 1,
403                next_cursor: None,
404            })
405            .with_diagnostic(Diagnostic::new("NOTE", "one page returned"));
406        json_writer()
407            .emit(&mut host, &response)
408            .expect("the write succeeds");
409        let value: serde_json::Value =
410            serde_json::from_str(&host.stdout_text()).expect("the output is JSON");
411        assert_eq!(value["schema_version"], json!(OUTPUT_SCHEMA_VERSION));
412        assert_eq!(value["command"], json!("job list"));
413        assert_eq!(value["outcome"], json!("success"));
414        assert_eq!(value["data"], json!(["orders"]));
415        assert_eq!(value["page"]["page_size"], json!(50));
416        assert_eq!(value["page"]["returned"], json!(1));
417        assert_eq!(value["page"]["next_cursor"], json!(null));
418        assert_eq!(value["diagnostics"][0]["code"], json!("NOTE"));
419        assert_eq!(value["truncated"], json!(false));
420    }
421
422    #[test]
423    fn one_object_is_emitted_per_invocation() {
424        let mut host = TestHost::new();
425        let response = Response::success(Command::JobList, json!([]));
426        json_writer()
427            .emit(&mut host, &response)
428            .expect("the write succeeds");
429        assert_eq!(host.stdout_text().trim_end().lines().count(), 1);
430    }
431
432    #[test]
433    fn a_failed_category_maps_to_its_outcome() {
434        let mut host = TestHost::new();
435        let response = Response::failed(
436            Command::ExecutionStop,
437            ExitCategory::OptimisticConflict,
438            json!({ "rejection": "OPTIMISTIC_CONFLICT" }),
439        );
440        json_writer()
441            .emit(&mut host, &response)
442            .expect("the write succeeds");
443        let value: serde_json::Value =
444            serde_json::from_str(&host.stdout_text()).expect("the output is JSON");
445        assert_eq!(value["outcome"], json!("conflict"));
446    }
447
448    #[test]
449    fn exceeding_the_bound_sets_the_truncation_flag() {
450        let mut host = TestHost::new();
451        let row = "x".repeat(1024);
452        let rows: Vec<serde_json::Value> = (0..512).map(|_| json!(row)).collect();
453        let response = Response::success(Command::JobList, json!(rows));
454        json_writer()
455            .emit(&mut host, &response)
456            .expect("the write succeeds");
457        let value: serde_json::Value =
458            serde_json::from_str(&host.stdout_text()).expect("the output is JSON");
459        assert_eq!(value["truncated"], json!(true));
460        let kept = value["data"].as_array().expect("data is an array").len();
461        assert!(kept < 512, "the bound removed no row");
462        assert!(value["data"].to_string().len() <= MAX_OUTPUT_BYTES);
463    }
464
465    #[test]
466    fn the_bound_covers_the_whole_envelope_not_only_the_projection() {
467        // The projection alone fits the bound; only the pagination and
468        // diagnostic fields push the rendered envelope past it. Bounding just
469        // the projection would therefore report `truncated: false` while
470        // writing more than the bound allows.
471        let mut host = TestHost::new();
472        let row = "y".repeat(1024);
473        let rows: Vec<serde_json::Value> = (0..250).map(|_| json!(row)).collect();
474        let data = json!(rows);
475        assert!(
476            data.to_string().len() <= MAX_OUTPUT_BYTES,
477            "the fixture is not discriminating: the projection alone already exceeds the bound"
478        );
479
480        let response = Response::success(Command::JobList, data.clone())
481            .with_page(PageInfo {
482                page_size: 500,
483                returned: 250,
484                next_cursor: Some("c".repeat(512)),
485            })
486            .with_diagnostic(Diagnostic::new("NOTE", "z".repeat(12 * 1024)));
487        assert!(
488            render_json(&response, &data, false).len() > MAX_OUTPUT_BYTES,
489            "the fixture is not discriminating: the envelope already fits"
490        );
491
492        json_writer()
493            .emit(&mut host, &response)
494            .expect("the write succeeds");
495
496        let written = host.stdout_text();
497        assert!(
498            written.len() <= MAX_OUTPUT_BYTES,
499            "the envelope exceeded the bound at {} bytes",
500            written.len()
501        );
502        let value: serde_json::Value = serde_json::from_str(&written).expect("the output is JSON");
503        assert_eq!(value["truncated"], json!(true));
504        // The fields that are not the projection survive truncation, because
505        // the page and diagnostics are what a caller needs to continue.
506        assert_eq!(value["page"]["page_size"], json!(500));
507        assert_eq!(value["diagnostics"][0]["code"], json!("NOTE"));
508        assert!(
509            !value["data"]
510                .as_array()
511                .expect("data is an array")
512                .is_empty(),
513            "truncation removed the whole projection"
514        );
515    }
516
517    #[test]
518    fn a_result_within_the_bound_is_never_flagged_truncated() {
519        let mut host = TestHost::new();
520        let response = Response::success(Command::JobList, json!(["orders"])).with_page(PageInfo {
521            page_size: 50,
522            returned: 1,
523            next_cursor: None,
524        });
525        json_writer()
526            .emit(&mut host, &response)
527            .expect("the write succeeds");
528        let value: serde_json::Value =
529            serde_json::from_str(&host.stdout_text()).expect("the output is JSON");
530        assert_eq!(value["truncated"], json!(false));
531        assert_eq!(value["data"], json!(["orders"]));
532    }
533
534    #[test]
535    fn the_human_form_obeys_the_same_bound() {
536        let mut host = TestHost::new();
537        let row = "y".repeat(1024);
538        let rows: Vec<serde_json::Value> = (0..512).map(|_| json!(row)).collect();
539        let response = Response::success(Command::JobList, json!(rows));
540        Writer::new(OutputForm::Human, false)
541            .emit(&mut host, &response)
542            .expect("the write succeeds");
543        let written = host.stdout_text();
544        assert!(written.len() <= MAX_OUTPUT_BYTES);
545        assert!(written.contains("truncated: true"));
546    }
547
548    #[test]
549    fn a_closed_pipe_reports_an_output_failure() {
550        let mut host = TestHost::new().with_stdout_capacity(4);
551        let response = Response::success(Command::JobList, json!(["orders", "invoices"]));
552        json_writer()
553            .emit(&mut host, &response)
554            .expect_err("the pipe is closed");
555        assert!(host.stdout_text().is_empty());
556    }
557
558    #[test]
559    fn the_human_form_renders_without_styling_when_disabled() {
560        let mut host = TestHost::new();
561        let response = Response::success(
562            Command::ExecutionShow,
563            json!({ "execution_id": 4, "status": "COMPLETED" }),
564        );
565        Writer::new(OutputForm::Human, false)
566            .emit(&mut host, &response)
567            .expect("the write succeeds");
568        let text = host.stdout_text();
569        assert!(text.contains("execution_id: 4"));
570        assert!(text.contains("status: COMPLETED"));
571        assert!(!text.contains('\u{1b}'), "styling leaked into plain output");
572    }
573}