Skip to main content

systemprompt_cli/shared/command_result/
mod.rs

1//! Structured command output and its terminal/JSON rendering.
2//!
3//! [`CommandOutput`] wraps the typed [`CliArtifact`] a command produces.
4//! Machine output (`--output json`/`yaml`) serializes the artifact verbatim —
5//! the same tagged union the MCP server deserializes. [`render_result`] renders
6//! the artifact for an interactive terminal, dispatching per variant. The
7//! reusable payload shapes [`TextOutput`], [`SuccessOutput`],
8//! [`KeyValueOutput`], and [`TableOutput`] remain available as command-side
9//! data structs.
10
11mod output_types;
12mod render;
13
14pub use output_types::{KeyValueItem, KeyValueOutput, SuccessOutput, TableOutput, TextOutput};
15pub use render::render_result;
16pub use systemprompt_models::artifacts::ChartType;
17
18use serde::Serialize;
19use serde_json::Value as JsonValue;
20use systemprompt_models::artifacts::{
21    CardSection, ChartArtifact, CliArtifact, Column, ColumnType, CopyPasteTextArtifact,
22    DashboardArtifact, ListArtifact, ListItem, PresentationCardArtifact, TableArtifact,
23    TextArtifact,
24};
25
26/// A command's renderable result: a typed [`CliArtifact`] plus terminal-only
27/// presentation state (an optional section title and a render-suppression
28/// flag).
29///
30/// The artifact is the single source of truth on the wire; `title` and
31/// `skip_render` only affect interactive terminal rendering.
32#[derive(Debug, Clone)]
33pub struct CommandOutput {
34    artifact: CliArtifact,
35    title: Option<String>,
36    skip_render: bool,
37}
38
39impl CommandOutput {
40    #[must_use]
41    pub const fn new(artifact: CliArtifact) -> Self {
42        Self {
43            artifact,
44            title: None,
45            skip_render: false,
46        }
47    }
48
49    #[must_use]
50    pub const fn artifact(&self) -> &CliArtifact {
51        &self.artifact
52    }
53
54    #[must_use]
55    pub fn into_artifact(self) -> CliArtifact {
56        self.artifact
57    }
58
59    #[must_use]
60    pub const fn should_skip_render(&self) -> bool {
61        self.skip_render
62    }
63
64    #[must_use]
65    pub const fn with_skip_render(mut self) -> Self {
66        self.skip_render = true;
67        self
68    }
69
70    #[must_use]
71    pub fn with_title(mut self, title: impl Into<String>) -> Self {
72        self.title = Some(title.into());
73        self
74    }
75
76    #[must_use]
77    pub fn title(&self) -> Option<&str> {
78        self.title.as_deref()
79    }
80
81    #[must_use]
82    pub fn text(content: impl Into<String>) -> Self {
83        Self::new(CliArtifact::text(TextArtifact::new(content)))
84    }
85
86    #[must_use]
87    pub fn text_titled(title: impl Into<String>, content: impl Into<String>) -> Self {
88        let title = title.into();
89        Self::new(CliArtifact::text(
90            TextArtifact::new(content).with_title(title.clone()),
91        ))
92        .with_title(title)
93    }
94
95    #[must_use]
96    pub fn copy_paste(content: impl Into<String>) -> Self {
97        Self::new(CliArtifact::copy_paste_text(CopyPasteTextArtifact::new(
98            content,
99        )))
100    }
101
102    #[must_use]
103    pub fn copy_paste_titled(title: impl Into<String>, content: impl Into<String>) -> Self {
104        let title = title.into();
105        Self::new(CliArtifact::copy_paste_text(
106            CopyPasteTextArtifact::new(content).with_title(title.clone()),
107        ))
108        .with_title(title)
109    }
110
111    #[must_use]
112    pub fn table(columns: Vec<impl Into<String>>, rows: Vec<JsonValue>) -> Self {
113        let cols: Vec<Column> = columns
114            .into_iter()
115            .map(|c| Column::new(c, ColumnType::String))
116            .collect();
117        Self::new(CliArtifact::table(TableArtifact::new(cols).with_rows(rows)))
118    }
119
120    /// Build a table by serializing each row item to a JSON object. `columns`
121    /// names the fields to display; the renderer reads them off each object.
122    #[must_use]
123    pub fn table_of<T: Serialize>(columns: Vec<impl Into<String>>, items: &[T]) -> Self {
124        let rows: Vec<JsonValue> = items
125            .iter()
126            .map(|item| serde_json::to_value(item).unwrap_or(JsonValue::Null))
127            .collect();
128        Self::table(columns, rows)
129    }
130
131    #[must_use]
132    pub const fn table_artifact(artifact: TableArtifact) -> Self {
133        Self::new(CliArtifact::table(artifact))
134    }
135
136    #[must_use]
137    pub fn list(items: Vec<ListItem>) -> Self {
138        Self::new(CliArtifact::list(ListArtifact::new().with_items(items)))
139    }
140
141    #[must_use]
142    pub const fn card(card: PresentationCardArtifact) -> Self {
143        Self::new(CliArtifact::presentation_card(card))
144    }
145
146    /// Build a presentation card whose sections are the top-level fields of a
147    /// serializable value (one `CardSection` per field). Deterministic
148    /// producer-side mapping — the wire carries a concrete card.
149    #[must_use]
150    pub fn card_value(title: impl Into<String>, value: &impl Serialize) -> Self {
151        let sections = sections_from_value(&serde_json::to_value(value).unwrap_or(JsonValue::Null));
152        Self::card(PresentationCardArtifact::new(title).with_sections(sections))
153    }
154
155    #[must_use]
156    pub const fn chart(chart: ChartArtifact) -> Self {
157        Self::new(CliArtifact::chart(chart))
158    }
159
160    #[must_use]
161    pub const fn dashboard(dashboard: DashboardArtifact) -> Self {
162        Self::new(CliArtifact::dashboard(dashboard))
163    }
164}
165
166impl From<CliArtifact> for CommandOutput {
167    fn from(artifact: CliArtifact) -> Self {
168        Self::new(artifact)
169    }
170}
171
172/// Turn a JSON value into card sections: one section per top-level object
173/// field. Scalars render as their display string; nested arrays/objects as
174/// compact JSON. A non-object value yields a single `Value` section.
175fn sections_from_value(value: &JsonValue) -> Vec<CardSection> {
176    match value {
177        JsonValue::Object(map) => map
178            .iter()
179            .map(|(key, val)| CardSection::new(key.clone(), value_to_display(val)))
180            .collect(),
181        JsonValue::Null => Vec::new(),
182        other => vec![CardSection::new("Value", value_to_display(other))],
183    }
184}
185
186fn value_to_display(value: &JsonValue) -> String {
187    match value {
188        JsonValue::String(s) => s.clone(),
189        JsonValue::Null => String::new(),
190        JsonValue::Bool(_) | JsonValue::Number(_) => value.to_string(),
191        JsonValue::Array(_) | JsonValue::Object(_) => {
192            serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
193        },
194    }
195}