1use serde_json::{Map, Value, json};
9
10use crate::args::OutputForm;
11use crate::command::Command;
12use crate::exit::{ExitCategory, Outcome};
13use crate::host::Host;
14
15pub const OUTPUT_SCHEMA_VERSION: u64 = 1;
17
18pub const MAX_OUTPUT_BYTES: usize = 256 * 1024;
20
21#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct PageInfo {
24 pub page_size: u16,
26 pub returned: usize,
28 pub next_cursor: Option<String>,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct Diagnostic {
35 pub code: String,
37 pub detail: String,
39}
40
41impl Diagnostic {
42 #[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#[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 #[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 #[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 #[must_use]
89 pub fn with_page(mut self, page: PageInfo) -> Self {
90 self.page = Some(page);
91 self
92 }
93
94 #[must_use]
96 pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
97 self.diagnostics.push(diagnostic);
98 self
99 }
100
101 #[must_use]
103 pub const fn category(&self) -> ExitCategory {
104 self.category
105 }
106
107 #[must_use]
109 pub const fn outcome(&self) -> Outcome {
110 self.category.outcome()
111 }
112}
113
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub struct OutputFailure;
120
121#[derive(Clone, Copy, Debug)]
123pub struct Writer {
124 form: OutputForm,
125 color: bool,
126}
127
128impl Writer {
129 #[must_use]
131 pub const fn new(form: OutputForm, color: bool) -> Self {
132 Self { form, color }
133 }
134
135 #[must_use]
137 pub const fn form(&self) -> OutputForm {
138 self.form
139 }
140
141 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 host.write_stdout(rendered.as_bytes())
162 .and_then(|()| host.flush_stdout())
163 .map_err(|_| OutputFailure)
164 }
165
166 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 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 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
214fn 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
247fn 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
290fn 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
299fn 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
348fn omitted(render: &impl Fn(&Value, bool) -> String) -> String {
350 render(
351 &json!({ "omitted": "the value exceeds the response bound" }),
352 true,
353 )
354}
355
356fn 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 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 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}