Skip to main content

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}