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 #[must_use]
204 pub fn with_document(mut self, document: OutputDocument) -> Self {
205 self.document = Some(document);
206 self
207 }
208
209 /// Returns the underlying rows when the result is not grouped.
210 ///
211 /// # Examples
212 ///
213 /// ```
214 /// use osp_cli::core::output_model::OutputResult;
215 /// use osp_cli::row;
216 ///
217 /// let output = OutputResult::from_rows(vec![row! { "uid" => "alice" }]);
218 ///
219 /// assert_eq!(output.as_rows().unwrap()[0]["uid"], "alice");
220 /// ```
221 pub fn as_rows(&self) -> Option<&[Row]> {
222 match &self.items {
223 OutputItems::Rows(rows) => Some(rows),
224 OutputItems::Groups(_) => None,
225 }
226 }
227
228 /// Consumes the result and returns its rows when the payload is row-based.
229 ///
230 /// # Examples
231 ///
232 /// ```
233 /// use osp_cli::core::output_model::OutputResult;
234 /// use osp_cli::row;
235 ///
236 /// let rows = OutputResult::from_rows(vec![row! { "uid" => "alice" }])
237 /// .into_rows()
238 /// .unwrap();
239 ///
240 /// assert_eq!(rows[0]["uid"], "alice");
241 /// ```
242 pub fn into_rows(self) -> Option<Vec<Row>> {
243 match self.items {
244 OutputItems::Rows(rows) => Some(rows),
245 OutputItems::Groups(_) => None,
246 }
247 }
248}
249
250/// Computes the stable first-seen column order across all rows.
251///
252/// # Examples
253///
254/// ```
255/// use osp_cli::core::output_model::compute_key_index;
256/// use osp_cli::row;
257///
258/// let rows = vec![
259/// row! { "uid" => "alice", "mail" => "a@example.com" },
260/// row! { "uid" => "bob", "cn" => "Bob" },
261/// ];
262///
263/// assert_eq!(compute_key_index(&rows), vec!["uid", "mail", "cn"]);
264/// ```
265pub fn compute_key_index(rows: &[Row]) -> Vec<String> {
266 let mut key_index = Vec::new();
267 let mut seen = HashSet::new();
268
269 for row in rows {
270 for key in row.keys() {
271 if seen.insert(key.clone()) {
272 key_index.push(key.clone());
273 }
274 }
275 }
276
277 key_index
278}
279
280/// Projects output items into a canonical JSON value.
281///
282/// # Examples
283///
284/// ```
285/// use osp_cli::core::output_model::{OutputItems, output_items_to_value};
286/// use osp_cli::row;
287///
288/// let value = output_items_to_value(&OutputItems::Rows(vec![row! { "uid" => "alice" }]));
289///
290/// assert_eq!(value["uid"], "alice");
291/// ```
292pub fn output_items_to_value(items: &OutputItems) -> Value {
293 match items {
294 OutputItems::Rows(rows) if rows.len() == 1 => rows
295 .first()
296 .cloned()
297 .map(Value::Object)
298 .unwrap_or_else(|| Value::Array(Vec::new())),
299 OutputItems::Rows(rows) => {
300 Value::Array(rows.iter().cloned().map(Value::Object).collect::<Vec<_>>())
301 }
302 OutputItems::Groups(groups) => Value::Array(
303 groups
304 .iter()
305 .map(|group| {
306 let mut item = Row::new();
307 item.insert("groups".to_string(), Value::Object(group.groups.clone()));
308 item.insert(
309 "aggregates".to_string(),
310 Value::Object(group.aggregates.clone()),
311 );
312 item.insert(
313 "rows".to_string(),
314 Value::Array(
315 group
316 .rows
317 .iter()
318 .cloned()
319 .map(Value::Object)
320 .collect::<Vec<_>>(),
321 ),
322 );
323 Value::Object(item)
324 })
325 .collect::<Vec<_>>(),
326 ),
327 }
328}
329
330/// Projects a canonical JSON value back into generic output items.
331///
332/// This is the inverse substrate bridge used by the canonical DSL: semantic payloads stay
333/// canonical as JSON, while the existing stage logic continues to operate over
334/// rows and groups derived from that JSON.
335///
336/// # Examples
337///
338/// ```
339/// use osp_cli::core::output_model::{OutputItems, output_items_from_value};
340/// use serde_json::json;
341///
342/// let items = output_items_from_value(json!({"uid": "alice"}));
343///
344/// assert_eq!(
345/// items,
346/// OutputItems::Rows(vec![json!({"uid": "alice"}).as_object().cloned().unwrap()])
347/// );
348/// ```
349pub fn output_items_from_value(value: Value) -> OutputItems {
350 match value {
351 Value::Array(items) => {
352 if let Some(groups) = groups_from_values(&items) {
353 OutputItems::Groups(groups)
354 } else if items.iter().all(|item| matches!(item, Value::Object(_))) {
355 OutputItems::Rows(
356 items
357 .into_iter()
358 .filter_map(|item| item.as_object().cloned())
359 .collect::<Vec<_>>(),
360 )
361 } else {
362 OutputItems::Rows(vec![row_with_value(Value::Array(items))])
363 }
364 }
365 Value::Object(map) => OutputItems::Rows(vec![map]),
366 scalar => OutputItems::Rows(vec![row_with_value(scalar)]),
367 }
368}
369
370fn row_with_value(value: Value) -> Row {
371 let mut row = Row::new();
372 row.insert("value".to_string(), value);
373 row
374}
375
376fn groups_from_values(values: &[Value]) -> Option<Vec<Group>> {
377 values.iter().map(group_from_value).collect()
378}
379
380fn group_from_value(value: &Value) -> Option<Group> {
381 let Value::Object(map) = value else {
382 return None;
383 };
384 let groups = map.get("groups")?.as_object()?.clone();
385 let aggregates = map.get("aggregates")?.as_object()?.clone();
386 let Value::Array(rows) = map.get("rows")? else {
387 return None;
388 };
389 let rows = rows
390 .iter()
391 .map(|row| row.as_object().cloned())
392 .collect::<Option<Vec<_>>>()?;
393
394 Some(Group {
395 groups,
396 aggregates,
397 rows,
398 })
399}
400
401#[cfg(test)]
402mod tests {
403 use super::{
404 Group, OutputDocument, OutputDocumentKind, OutputItems, OutputMeta, OutputResult,
405 output_items_from_value, output_items_to_value,
406 };
407 use serde_json::Value;
408 use serde_json::json;
409
410 #[test]
411 fn row_results_keep_first_seen_key_order_and_expose_row_views_unit() {
412 let rows = vec![
413 json!({"uid": "oistes", "cn": "Oistein"})
414 .as_object()
415 .cloned()
416 .expect("object"),
417 json!({"mail": "o@uio.no", "uid": "oistes", "title": "Engineer"})
418 .as_object()
419 .cloned()
420 .expect("object"),
421 ];
422
423 let output = OutputResult::from_rows(rows.clone());
424 assert_eq!(output.meta.key_index, vec!["uid", "cn", "mail", "title"]);
425 assert_eq!(output.as_rows(), Some(rows.as_slice()));
426 assert_eq!(output.into_rows(), Some(rows));
427 }
428
429 #[test]
430 fn grouped_results_and_semantic_documents_cover_non_row_views_unit() {
431 let grouped_output = OutputResult {
432 items: OutputItems::Groups(vec![Group {
433 groups: json!({"team": "ops"}).as_object().cloned().expect("object"),
434 aggregates: json!({"count": 1}).as_object().cloned().expect("object"),
435 rows: vec![
436 json!({"user": "alice"})
437 .as_object()
438 .cloned()
439 .expect("object"),
440 ],
441 }]),
442 document: None,
443 meta: OutputMeta::default(),
444 };
445
446 assert_eq!(grouped_output.as_rows(), None);
447 assert_eq!(grouped_output.into_rows(), None);
448
449 let document_output = OutputResult::from_rows(Vec::new()).with_document(
450 OutputDocument::new(OutputDocumentKind::Guide, json!({"usage": ["osp"]})),
451 );
452
453 assert!(matches!(
454 document_output.document,
455 Some(OutputDocument {
456 kind: OutputDocumentKind::Guide,
457 value: Value::Object(_),
458 })
459 ));
460 }
461
462 #[test]
463 fn output_items_projection_round_trips_rows_and_groups_unit() {
464 let rows = OutputItems::Rows(vec![
465 json!({"uid": "alice"})
466 .as_object()
467 .cloned()
468 .expect("object"),
469 ]);
470 let rows_value = output_items_to_value(&rows);
471 assert!(matches!(rows_value, Value::Object(_)));
472 assert_eq!(output_items_from_value(rows_value), rows);
473
474 let groups = OutputItems::Groups(vec![Group {
475 groups: json!({"team": "ops"}).as_object().cloned().expect("object"),
476 aggregates: json!({"count": 1}).as_object().cloned().expect("object"),
477 rows: vec![
478 json!({"uid": "alice"})
479 .as_object()
480 .cloned()
481 .expect("object"),
482 ],
483 }]);
484 let groups_value = output_items_to_value(&groups);
485 assert!(matches!(groups_value, Value::Array(_)));
486 assert_eq!(output_items_from_value(groups_value), groups);
487 }
488}