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
250pub(crate) fn output_items_to_rows(items: &OutputItems) -> Vec<Row> {
251 match items {
252 OutputItems::Rows(rows) => rows.clone(),
253 OutputItems::Groups(groups) => groups.iter().flat_map(group_rows).collect(),
254 }
255}
256
257pub(crate) fn group_rows(group: &Group) -> Vec<Row> {
258 if group.rows.is_empty() {
259 return vec![group_header_row(group)];
260 }
261
262 group
263 .rows
264 .iter()
265 .map(|row| group_member_row(group, row))
266 .collect()
267}
268
269pub(crate) fn group_header_row(group: &Group) -> Row {
270 let mut row = group.groups.clone();
271 for (key, value) in &group.aggregates {
272 row.insert(key.clone(), value.clone());
273 }
274 row
275}
276
277pub(crate) fn group_member_row(group: &Group, row: &Row) -> Row {
278 let mut merged = group.groups.clone();
279 for (key, value) in &group.aggregates {
280 merged.insert(key.clone(), value.clone());
281 }
282 for (key, value) in row {
283 merged.insert(key.clone(), value.clone());
284 }
285 merged
286}
287
288/// Computes the stable first-seen column order across all rows.
289///
290/// # Examples
291///
292/// ```
293/// use osp_cli::core::output_model::compute_key_index;
294/// use osp_cli::row;
295///
296/// let rows = vec![
297/// row! { "uid" => "alice", "mail" => "a@example.com" },
298/// row! { "uid" => "bob", "cn" => "Bob" },
299/// ];
300///
301/// assert_eq!(compute_key_index(&rows), vec!["uid", "mail", "cn"]);
302/// ```
303pub fn compute_key_index(rows: &[Row]) -> Vec<String> {
304 let mut key_index = Vec::new();
305 let mut seen = HashSet::new();
306
307 for row in rows {
308 for key in row.keys() {
309 if seen.insert(key.clone()) {
310 key_index.push(key.clone());
311 }
312 }
313 }
314
315 key_index
316}
317
318/// Projects output items into a canonical JSON value.
319///
320/// # Examples
321///
322/// ```
323/// use osp_cli::core::output_model::{OutputItems, output_items_to_value};
324/// use osp_cli::row;
325///
326/// let value = output_items_to_value(&OutputItems::Rows(vec![row! { "uid" => "alice" }]));
327///
328/// assert_eq!(value["uid"], "alice");
329/// ```
330pub fn output_items_to_value(items: &OutputItems) -> Value {
331 match items {
332 OutputItems::Rows(rows) if rows.len() == 1 => rows
333 .first()
334 .cloned()
335 .map(Value::Object)
336 .unwrap_or_else(|| Value::Array(Vec::new())),
337 OutputItems::Rows(rows) => {
338 Value::Array(rows.iter().cloned().map(Value::Object).collect::<Vec<_>>())
339 }
340 OutputItems::Groups(groups) => Value::Array(
341 groups
342 .iter()
343 .map(|group| {
344 let mut item = Row::new();
345 item.insert("groups".to_string(), Value::Object(group.groups.clone()));
346 item.insert(
347 "aggregates".to_string(),
348 Value::Object(group.aggregates.clone()),
349 );
350 item.insert(
351 "rows".to_string(),
352 Value::Array(
353 group
354 .rows
355 .iter()
356 .cloned()
357 .map(Value::Object)
358 .collect::<Vec<_>>(),
359 ),
360 );
361 Value::Object(item)
362 })
363 .collect::<Vec<_>>(),
364 ),
365 }
366}
367
368/// Projects a canonical JSON value back into generic output items.
369///
370/// This is the inverse substrate bridge used by the canonical DSL: semantic payloads stay
371/// canonical as JSON, while the existing stage logic continues to operate over
372/// rows and groups derived from that JSON.
373///
374/// # Examples
375///
376/// ```
377/// use osp_cli::core::output_model::{OutputItems, output_items_from_value};
378/// use serde_json::json;
379///
380/// let items = output_items_from_value(json!({"uid": "alice"}));
381///
382/// assert_eq!(
383/// items,
384/// OutputItems::Rows(vec![json!({"uid": "alice"}).as_object().cloned().unwrap()])
385/// );
386/// ```
387pub fn output_items_from_value(value: Value) -> OutputItems {
388 match value {
389 Value::Array(items) => {
390 if let Some(groups) = groups_from_values(&items) {
391 OutputItems::Groups(groups)
392 } else if items.iter().all(|item| matches!(item, Value::Object(_))) {
393 OutputItems::Rows(
394 items
395 .into_iter()
396 .filter_map(|item| item.as_object().cloned())
397 .collect::<Vec<_>>(),
398 )
399 } else {
400 OutputItems::Rows(vec![row_with_value(Value::Array(items))])
401 }
402 }
403 Value::Object(map) => OutputItems::Rows(vec![map]),
404 scalar => OutputItems::Rows(vec![row_with_value(scalar)]),
405 }
406}
407
408/// Projects any JSON value into a row stream for pipeline-oriented processing.
409///
410/// Arrays expand into one row per item. Objects become single rows directly.
411/// Scalars become a single row under the canonical `value` column.
412pub fn rows_from_value(value: Value) -> Vec<Row> {
413 match value {
414 Value::Array(values) => values.into_iter().flat_map(row_items_from_value).collect(),
415 other => row_items_from_value(other),
416 }
417}
418
419fn row_items_from_value(value: Value) -> Vec<Row> {
420 match value {
421 Value::Object(map) => vec![map],
422 other => vec![row_with_value(other)],
423 }
424}
425
426fn row_with_value(value: Value) -> Row {
427 let mut row = Row::new();
428 row.insert("value".to_string(), value);
429 row
430}
431
432fn groups_from_values(values: &[Value]) -> Option<Vec<Group>> {
433 values.iter().map(group_from_value).collect()
434}
435
436fn group_from_value(value: &Value) -> Option<Group> {
437 let Value::Object(map) = value else {
438 return None;
439 };
440 let groups = map.get("groups")?.as_object()?.clone();
441 let aggregates = map.get("aggregates")?.as_object()?.clone();
442 let Value::Array(rows) = map.get("rows")? else {
443 return None;
444 };
445 let rows = rows
446 .iter()
447 .map(|row| row.as_object().cloned())
448 .collect::<Option<Vec<_>>>()?;
449
450 Some(Group {
451 groups,
452 aggregates,
453 rows,
454 })
455}
456
457#[cfg(test)]
458mod tests {
459 use super::{
460 Group, OutputDocument, OutputDocumentKind, OutputItems, OutputMeta, OutputResult,
461 output_items_from_value, output_items_to_rows, output_items_to_value,
462 };
463 use serde_json::Value;
464 use serde_json::json;
465
466 #[test]
467 fn row_results_keep_first_seen_key_order_and_expose_row_views_unit() {
468 let rows = vec![
469 json!({"uid": "oistes", "cn": "Oistein"})
470 .as_object()
471 .cloned()
472 .expect("object"),
473 json!({"mail": "o@uio.no", "uid": "oistes", "title": "Engineer"})
474 .as_object()
475 .cloned()
476 .expect("object"),
477 ];
478
479 let output = OutputResult::from_rows(rows.clone());
480 assert_eq!(output.meta.key_index, vec!["uid", "cn", "mail", "title"]);
481 assert_eq!(output.as_rows(), Some(rows.as_slice()));
482 assert_eq!(output.into_rows(), Some(rows));
483 }
484
485 #[test]
486 fn grouped_results_and_semantic_documents_cover_non_row_views_unit() {
487 let grouped_output = OutputResult {
488 items: OutputItems::Groups(vec![Group {
489 groups: json!({"team": "ops"}).as_object().cloned().expect("object"),
490 aggregates: json!({"count": 1}).as_object().cloned().expect("object"),
491 rows: vec![
492 json!({"user": "alice"})
493 .as_object()
494 .cloned()
495 .expect("object"),
496 ],
497 }]),
498 document: None,
499 meta: OutputMeta::default(),
500 };
501
502 assert_eq!(grouped_output.as_rows(), None);
503 assert_eq!(grouped_output.into_rows(), None);
504
505 let document_output = OutputResult::from_rows(Vec::new()).with_document(
506 OutputDocument::new(OutputDocumentKind::Guide, json!({"usage": ["osp"]})),
507 );
508
509 assert!(matches!(
510 document_output.document,
511 Some(OutputDocument {
512 kind: OutputDocumentKind::Guide,
513 value: Value::Object(_),
514 })
515 ));
516 }
517
518 #[test]
519 fn output_items_projection_round_trips_rows_and_groups_unit() {
520 let rows = OutputItems::Rows(vec![
521 json!({"uid": "alice"})
522 .as_object()
523 .cloned()
524 .expect("object"),
525 ]);
526 let rows_value = output_items_to_value(&rows);
527 assert!(matches!(rows_value, Value::Object(_)));
528 assert_eq!(output_items_from_value(rows_value), rows);
529
530 let groups = OutputItems::Groups(vec![Group {
531 groups: json!({"team": "ops"}).as_object().cloned().expect("object"),
532 aggregates: json!({"count": 1}).as_object().cloned().expect("object"),
533 rows: vec![
534 json!({"uid": "alice"})
535 .as_object()
536 .cloned()
537 .expect("object"),
538 ],
539 }]);
540 let groups_value = output_items_to_value(&groups);
541 assert!(matches!(groups_value, Value::Array(_)));
542 assert_eq!(output_items_from_value(groups_value), groups);
543 }
544
545 #[test]
546 fn output_items_to_rows_merges_group_context_once_unit() {
547 let items = OutputItems::Groups(vec![
548 Group {
549 groups: json!({"team": "ops"}).as_object().cloned().expect("object"),
550 aggregates: json!({"count": 2}).as_object().cloned().expect("object"),
551 rows: vec![
552 json!({"user": "alice"})
553 .as_object()
554 .cloned()
555 .expect("object"),
556 json!({"user": "bob"}).as_object().cloned().expect("object"),
557 ],
558 },
559 Group {
560 groups: json!({"team": "dev"}).as_object().cloned().expect("object"),
561 aggregates: json!({"count": 0}).as_object().cloned().expect("object"),
562 rows: Vec::new(),
563 },
564 ]);
565
566 let rows = output_items_to_rows(&items);
567
568 assert_eq!(rows.len(), 3);
569 assert_eq!(rows[0]["team"], "ops");
570 assert_eq!(rows[0]["count"], 2);
571 assert_eq!(rows[0]["user"], "alice");
572 assert_eq!(rows[1]["user"], "bob");
573 assert_eq!(rows[2]["team"], "dev");
574 assert_eq!(rows[2]["count"], 0);
575 assert!(rows[2].get("user").is_none());
576 }
577}