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
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}