osp_cli/core/output_model.rs
1//! Structured output payload model shared across commands, DSL stages, and UI.
2//!
3//! This module exists to keep command results in a small canonical shape while
4//! they move between execution, transformation, and rendering layers.
5//!
6//! High-level flow:
7//!
8//! - commands produce [`crate::core::output_model::OutputResult`]
9//! - the DSL transforms its [`crate::core::output_model::OutputItems`] and
10//! optional semantic document
11//! - the UI later lowers the result into rendered documents and terminal text
12//!
13//! Contract:
14//!
15//! - this module describes data shape, not rendering policy
16//! - semantic sidecar documents should stay canonical here instead of leaking
17//! format-specific assumptions into the DSL or UI
18
19use crate::core::output::OutputFormat;
20use crate::core::row::Row;
21use serde_json::Value;
22use std::collections::HashSet;
23
24/// Alignment hint for a rendered output column.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum ColumnAlignment {
27 /// Use renderer defaults for alignment.
28 #[default]
29 Default,
30 /// Left-align the column.
31 Left,
32 /// Center-align the column.
33 Center,
34 /// Right-align the column.
35 Right,
36}
37
38/// Grouped output with grouping keys, aggregate values, and member rows.
39#[derive(Clone, Debug, PartialEq)]
40pub struct Group {
41 /// Values that identify the group.
42 pub groups: Row,
43 /// Aggregate values computed for the group.
44 pub aggregates: Row,
45 /// Member rows belonging to the group.
46 pub rows: Vec<Row>,
47}
48
49/// Rendering metadata attached to an [`OutputResult`].
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct OutputMeta {
52 /// Stable first-seen column order for row rendering.
53 pub key_index: Vec<String>,
54 /// Per-column alignment hints.
55 pub column_align: Vec<ColumnAlignment>,
56 /// Whether the result should be easy to copy as plain text.
57 pub wants_copy: bool,
58 /// Whether the payload represents grouped data.
59 pub grouped: bool,
60 /// Preferred renderer for this result, when known.
61 pub render_recommendation: Option<RenderRecommendation>,
62}
63
64/// Suggested render target for a command result.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum RenderRecommendation {
67 /// Render using the specified output format.
68 Format(OutputFormat),
69 /// Render as structured guide/help content.
70 Guide,
71}
72
73/// Stable identity for a semantic payload carried through the output pipeline.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum OutputDocumentKind {
76 /// Structured guide/help/intro payload.
77 Guide,
78}
79
80/// Optional semantic document attached to rendered output.
81#[derive(Clone, Debug, PartialEq)]
82pub struct OutputDocument {
83 /// Semantic payload identity.
84 pub kind: OutputDocumentKind,
85 /// Canonical JSON substrate for the payload.
86 pub value: Value,
87}
88
89impl OutputDocument {
90 /// Builds a semantic payload from its identity and canonical JSON value.
91 ///
92 /// # Examples
93 ///
94 /// ```
95 /// use osp_cli::core::output_model::{OutputDocument, OutputDocumentKind};
96 /// use serde_json::json;
97 ///
98 /// let document = OutputDocument::new(OutputDocumentKind::Guide, json!({"title": "Help"}));
99 ///
100 /// assert_eq!(document.kind, OutputDocumentKind::Guide);
101 /// assert_eq!(document.value["title"], "Help");
102 /// ```
103 pub fn new(kind: OutputDocumentKind, value: Value) -> Self {
104 Self { kind, value }
105 }
106
107 /// Reprojects the payload over generic output items while keeping identity.
108 ///
109 /// The canonical DSL uses this to preserve payload identity without branching on
110 /// concrete semantic types inside the executor. Whether the projected JSON
111 /// still restores into the original payload kind is decided later by the
112 /// payload codec, not by the pipeline engine itself.
113 ///
114 /// # Examples
115 ///
116 /// ```
117 /// use osp_cli::core::output_model::{OutputDocument, OutputDocumentKind, OutputItems};
118 /// use osp_cli::row;
119 /// use serde_json::json;
120 ///
121 /// let document = OutputDocument::new(OutputDocumentKind::Guide, json!({"usage": ["osp"]}));
122 /// let projected = document.project_over_items(&OutputItems::Rows(vec![
123 /// row! { "uid" => "alice" },
124 /// ]));
125 ///
126 /// assert_eq!(projected.kind, OutputDocumentKind::Guide);
127 /// assert_eq!(projected.value["uid"], "alice");
128 /// ```
129 pub fn project_over_items(&self, items: &OutputItems) -> Self {
130 Self {
131 kind: self.kind,
132 value: output_items_to_value(items),
133 }
134 }
135}
136
137/// Result payload as either flat rows or grouped rows.
138#[derive(Clone, Debug, PartialEq)]
139pub enum OutputItems {
140 /// Ungrouped row output.
141 Rows(Vec<Row>),
142 /// Grouped output with aggregates and member rows.
143 Groups(Vec<Group>),
144}
145
146/// Structured command output plus rendering metadata.
147#[derive(Clone, Debug, PartialEq)]
148pub struct OutputResult {
149 /// Primary payload to render or further transform.
150 pub items: OutputItems,
151 /// Optional semantic sidecar document.
152 pub document: Option<OutputDocument>,
153 /// Rendering metadata derived during result construction.
154 pub meta: OutputMeta,
155}
156
157impl OutputResult {
158 /// Builds a row-based result and derives its key index from first-seen columns.
159 ///
160 /// # Examples
161 ///
162 /// ```
163 /// use osp_cli::core::output_model::OutputResult;
164 /// use osp_cli::row;
165 ///
166 /// let output = OutputResult::from_rows(vec![
167 /// row! { "uid" => "alice", "mail" => "a@example.com" },
168 /// row! { "uid" => "bob", "cn" => "Bob" },
169 /// ]);
170 ///
171 /// assert_eq!(output.meta.key_index, vec!["uid", "mail", "cn"]);
172 /// ```
173 pub fn from_rows(rows: Vec<Row>) -> Self {
174 let key_index = compute_key_index(&rows);
175 Self {
176 items: OutputItems::Rows(rows),
177 document: None,
178 meta: OutputMeta {
179 key_index,
180 column_align: Vec::new(),
181 wants_copy: false,
182 grouped: false,
183 render_recommendation: None,
184 },
185 }
186 }
187
188 /// Attaches a semantic document to the result and returns the updated value.
189 ///
190 /// # Examples
191 ///
192 /// ```
193 /// use osp_cli::core::output_model::{OutputDocument, OutputDocumentKind, OutputResult};
194 /// use serde_json::json;
195 ///
196 /// let output = OutputResult::from_rows(Vec::new()).with_document(OutputDocument::new(
197 /// OutputDocumentKind::Guide,
198 /// json!({"title": "Help"}),
199 /// ));
200 ///
201 /// assert!(output.document.is_some());
202 /// ```
203 pub fn with_document(mut self, document: OutputDocument) -> Self {
204 self.document = Some(document);
205 self
206 }
207
208 /// Returns the underlying rows when the result is not grouped.
209 ///
210 /// # Examples
211 ///
212 /// ```
213 /// use osp_cli::core::output_model::OutputResult;
214 /// use osp_cli::row;
215 ///
216 /// let output = OutputResult::from_rows(vec![row! { "uid" => "alice" }]);
217 ///
218 /// assert_eq!(output.as_rows().unwrap()[0]["uid"], "alice");
219 /// ```
220 pub fn as_rows(&self) -> Option<&[Row]> {
221 match &self.items {
222 OutputItems::Rows(rows) => Some(rows),
223 OutputItems::Groups(_) => None,
224 }
225 }
226
227 /// Consumes the result and returns its rows when the payload is row-based.
228 ///
229 /// # Examples
230 ///
231 /// ```
232 /// use osp_cli::core::output_model::OutputResult;
233 /// use osp_cli::row;
234 ///
235 /// let rows = OutputResult::from_rows(vec![row! { "uid" => "alice" }])
236 /// .into_rows()
237 /// .unwrap();
238 ///
239 /// assert_eq!(rows[0]["uid"], "alice");
240 /// ```
241 pub fn into_rows(self) -> Option<Vec<Row>> {
242 match self.items {
243 OutputItems::Rows(rows) => Some(rows),
244 OutputItems::Groups(_) => None,
245 }
246 }
247}
248
249/// Computes the stable first-seen column order across all rows.
250///
251/// # Examples
252///
253/// ```
254/// use osp_cli::core::output_model::compute_key_index;
255/// use osp_cli::row;
256///
257/// let rows = vec![
258/// row! { "uid" => "alice", "mail" => "a@example.com" },
259/// row! { "uid" => "bob", "cn" => "Bob" },
260/// ];
261///
262/// assert_eq!(compute_key_index(&rows), vec!["uid", "mail", "cn"]);
263/// ```
264pub fn compute_key_index(rows: &[Row]) -> Vec<String> {
265 let mut key_index = Vec::new();
266 let mut seen = HashSet::new();
267
268 for row in rows {
269 for key in row.keys() {
270 if seen.insert(key.clone()) {
271 key_index.push(key.clone());
272 }
273 }
274 }
275
276 key_index
277}
278
279/// Projects output items into a canonical JSON value.
280///
281/// # Examples
282///
283/// ```
284/// use osp_cli::core::output_model::{OutputItems, output_items_to_value};
285/// use osp_cli::row;
286///
287/// let value = output_items_to_value(&OutputItems::Rows(vec![row! { "uid" => "alice" }]));
288///
289/// assert_eq!(value["uid"], "alice");
290/// ```
291pub fn output_items_to_value(items: &OutputItems) -> Value {
292 match items {
293 OutputItems::Rows(rows) if rows.len() == 1 => rows
294 .first()
295 .cloned()
296 .map(Value::Object)
297 .unwrap_or_else(|| Value::Array(Vec::new())),
298 OutputItems::Rows(rows) => {
299 Value::Array(rows.iter().cloned().map(Value::Object).collect::<Vec<_>>())
300 }
301 OutputItems::Groups(groups) => Value::Array(
302 groups
303 .iter()
304 .map(|group| {
305 let mut item = Row::new();
306 item.insert("groups".to_string(), Value::Object(group.groups.clone()));
307 item.insert(
308 "aggregates".to_string(),
309 Value::Object(group.aggregates.clone()),
310 );
311 item.insert(
312 "rows".to_string(),
313 Value::Array(
314 group
315 .rows
316 .iter()
317 .cloned()
318 .map(Value::Object)
319 .collect::<Vec<_>>(),
320 ),
321 );
322 Value::Object(item)
323 })
324 .collect::<Vec<_>>(),
325 ),
326 }
327}
328
329/// Projects a canonical JSON value back into generic output items.
330///
331/// This is the inverse substrate bridge used by the canonical DSL: semantic payloads stay
332/// canonical as JSON, while the existing stage logic continues to operate over
333/// rows and groups derived from that JSON.
334///
335/// # Examples
336///
337/// ```
338/// use osp_cli::core::output_model::{OutputItems, output_items_from_value};
339/// use serde_json::json;
340///
341/// let items = output_items_from_value(json!({"uid": "alice"}));
342///
343/// assert_eq!(
344/// items,
345/// OutputItems::Rows(vec![json!({"uid": "alice"}).as_object().cloned().unwrap()])
346/// );
347/// ```
348pub fn output_items_from_value(value: Value) -> OutputItems {
349 match value {
350 Value::Array(items) => {
351 if let Some(groups) = groups_from_values(&items) {
352 OutputItems::Groups(groups)
353 } else if items.iter().all(|item| matches!(item, Value::Object(_))) {
354 OutputItems::Rows(
355 items
356 .into_iter()
357 .filter_map(|item| item.as_object().cloned())
358 .collect::<Vec<_>>(),
359 )
360 } else {
361 OutputItems::Rows(vec![row_with_value(Value::Array(items))])
362 }
363 }
364 Value::Object(map) => OutputItems::Rows(vec![map]),
365 scalar => OutputItems::Rows(vec![row_with_value(scalar)]),
366 }
367}
368
369fn row_with_value(value: Value) -> Row {
370 let mut row = Row::new();
371 row.insert("value".to_string(), value);
372 row
373}
374
375fn groups_from_values(values: &[Value]) -> Option<Vec<Group>> {
376 values.iter().map(group_from_value).collect()
377}
378
379fn group_from_value(value: &Value) -> Option<Group> {
380 let Value::Object(map) = value else {
381 return None;
382 };
383 let groups = map.get("groups")?.as_object()?.clone();
384 let aggregates = map.get("aggregates")?.as_object()?.clone();
385 let Value::Array(rows) = map.get("rows")? else {
386 return None;
387 };
388 let rows = rows
389 .iter()
390 .map(|row| row.as_object().cloned())
391 .collect::<Option<Vec<_>>>()?;
392
393 Some(Group {
394 groups,
395 aggregates,
396 rows,
397 })
398}
399
400#[cfg(test)]
401mod tests {
402 use super::{
403 Group, OutputDocument, OutputDocumentKind, OutputItems, OutputMeta, OutputResult,
404 output_items_from_value, output_items_to_value,
405 };
406 use serde_json::Value;
407 use serde_json::json;
408
409 #[test]
410 fn from_rows_keeps_first_seen_key_order() {
411 let rows = vec![
412 json!({"uid": "oistes", "cn": "Oistein"})
413 .as_object()
414 .cloned()
415 .expect("object"),
416 json!({"mail": "o@uio.no", "uid": "oistes", "title": "Engineer"})
417 .as_object()
418 .cloned()
419 .expect("object"),
420 ];
421
422 let output = OutputResult::from_rows(rows);
423 assert_eq!(output.meta.key_index, vec!["uid", "cn", "mail", "title"]);
424 }
425
426 #[test]
427 fn grouped_output_does_not_expose_rows_views() {
428 let output = OutputResult {
429 items: OutputItems::Groups(vec![Group {
430 groups: json!({"team": "ops"}).as_object().cloned().expect("object"),
431 aggregates: json!({"count": 1}).as_object().cloned().expect("object"),
432 rows: vec![
433 json!({"user": "alice"})
434 .as_object()
435 .cloned()
436 .expect("object"),
437 ],
438 }]),
439 document: None,
440 meta: OutputMeta::default(),
441 };
442
443 assert_eq!(output.as_rows(), None);
444 assert_eq!(output.into_rows(), None);
445 }
446
447 #[test]
448 fn row_output_exposes_rows_views() {
449 let rows = vec![
450 json!({"uid": "alice"})
451 .as_object()
452 .cloned()
453 .expect("object"),
454 ];
455 let output = OutputResult::from_rows(rows.clone());
456
457 assert_eq!(output.as_rows(), Some(rows.as_slice()));
458 assert_eq!(output.into_rows(), Some(rows));
459 }
460
461 #[test]
462 fn with_document_attaches_semantic_payload_unit() {
463 let output = OutputResult::from_rows(Vec::new()).with_document(OutputDocument::new(
464 OutputDocumentKind::Guide,
465 json!({"usage": ["osp"]}),
466 ));
467
468 assert!(matches!(
469 output.document,
470 Some(OutputDocument {
471 kind: OutputDocumentKind::Guide,
472 value: Value::Object(_),
473 })
474 ));
475 }
476
477 #[test]
478 fn output_items_to_value_projects_rows_and_groups_unit() {
479 let rows = OutputItems::Rows(vec![
480 json!({"uid": "alice"})
481 .as_object()
482 .cloned()
483 .expect("object"),
484 ]);
485 assert!(matches!(output_items_to_value(&rows), Value::Object(_)));
486
487 let groups = OutputItems::Groups(vec![Group {
488 groups: json!({"team": "ops"}).as_object().cloned().expect("object"),
489 aggregates: json!({"count": 1}).as_object().cloned().expect("object"),
490 rows: vec![
491 json!({"uid": "alice"})
492 .as_object()
493 .cloned()
494 .expect("object"),
495 ],
496 }]);
497 assert!(matches!(output_items_to_value(&groups), Value::Array(_)));
498 }
499
500 #[test]
501 fn output_items_from_value_round_trips_rows_and_groups_unit() {
502 let rows = OutputItems::Rows(vec![
503 json!({"uid": "alice"})
504 .as_object()
505 .cloned()
506 .expect("object"),
507 ]);
508 assert_eq!(output_items_from_value(output_items_to_value(&rows)), rows);
509
510 let groups = OutputItems::Groups(vec![Group {
511 groups: json!({"team": "ops"}).as_object().cloned().expect("object"),
512 aggregates: json!({"count": 1}).as_object().cloned().expect("object"),
513 rows: vec![
514 json!({"uid": "alice"})
515 .as_object()
516 .cloned()
517 .expect("object"),
518 ],
519 }]);
520 assert_eq!(
521 output_items_from_value(output_items_to_value(&groups)),
522 groups
523 );
524 }
525}