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