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//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14mod output_types;
15mod render;
16
17pub use output_types::{KeyValueItem, KeyValueOutput, SuccessOutput, TableOutput, TextOutput};
18pub use render::render_result;
19pub use systemprompt_models::artifacts::ChartType;
20
21use serde::Serialize;
22use serde_json::Value as JsonValue;
23use systemprompt_models::artifacts::{
24    CardSection, ChartArtifact, CliArtifact, Column, ColumnType, CopyPasteTextArtifact,
25    DashboardArtifact, ListArtifact, ListItem, MessageArtifact, NoticeLine,
26    PresentationCardArtifact, TableArtifact, TextArtifact,
27};
28
29/// A command's renderable result: a typed [`CliArtifact`] plus terminal-only
30/// presentation state (an optional section title and a render-suppression
31/// flag).
32///
33/// The artifact is the single source of truth on the wire; `title` and
34/// `skip_render` only affect interactive terminal rendering.
35#[derive(Debug, Clone)]
36pub struct CommandOutput {
37    artifact: CliArtifact,
38    title: Option<String>,
39    skip_render: bool,
40}
41
42impl CommandOutput {
43    #[must_use]
44    pub const fn new(artifact: CliArtifact) -> Self {
45        Self {
46            artifact,
47            title: None,
48            skip_render: false,
49        }
50    }
51
52    #[must_use]
53    pub const fn artifact(&self) -> &CliArtifact {
54        &self.artifact
55    }
56
57    #[must_use]
58    pub fn into_artifact(self) -> CliArtifact {
59        self.artifact
60    }
61
62    #[must_use]
63    pub const fn should_skip_render(&self) -> bool {
64        self.skip_render
65    }
66
67    #[must_use]
68    pub const fn with_skip_render(mut self) -> Self {
69        self.skip_render = true;
70        self
71    }
72
73    #[must_use]
74    pub fn with_title(mut self, title: impl Into<String>) -> Self {
75        self.title = Some(title.into());
76        self
77    }
78
79    #[must_use]
80    pub fn title(&self) -> Option<&str> {
81        self.title.as_deref()
82    }
83
84    #[must_use]
85    pub fn text(content: impl Into<String>) -> Self {
86        Self::new(CliArtifact::text(TextArtifact::new(content)))
87    }
88
89    #[must_use]
90    pub fn text_titled(title: impl Into<String>, content: impl Into<String>) -> Self {
91        let title = title.into();
92        Self::new(CliArtifact::text(
93            TextArtifact::new(content).with_title(title.clone()),
94        ))
95        .with_title(title)
96    }
97
98    #[must_use]
99    pub fn copy_paste(content: impl Into<String>) -> Self {
100        Self::new(CliArtifact::copy_paste_text(CopyPasteTextArtifact::new(
101            content,
102        )))
103    }
104
105    #[must_use]
106    pub fn copy_paste_titled(title: impl Into<String>, content: impl Into<String>) -> Self {
107        let title = title.into();
108        Self::new(CliArtifact::copy_paste_text(
109            CopyPasteTextArtifact::new(content).with_title(title.clone()),
110        ))
111        .with_title(title)
112    }
113
114    #[must_use]
115    pub fn table(columns: Vec<impl Into<String>>, rows: Vec<JsonValue>) -> Self {
116        let cols: Vec<Column> = columns
117            .into_iter()
118            .map(|c| Column::new(c, ColumnType::String))
119            .collect();
120        Self::new(CliArtifact::table(TableArtifact::new(cols).with_rows(rows)))
121    }
122
123    #[must_use]
124    pub fn table_of<T: Serialize>(columns: Vec<impl Into<String>>, items: &[T]) -> Self {
125        let rows: Vec<JsonValue> = items
126            .iter()
127            .map(|item| serde_json::to_value(item).unwrap_or(JsonValue::Null))
128            .collect();
129        Self::table(columns, rows)
130    }
131
132    #[must_use]
133    pub const fn table_artifact(artifact: TableArtifact) -> Self {
134        Self::new(CliArtifact::table(artifact))
135    }
136
137    #[must_use]
138    pub fn list(items: Vec<ListItem>) -> Self {
139        Self::new(CliArtifact::list(ListArtifact::new().with_items(items)))
140    }
141
142    #[must_use]
143    pub const fn card(card: PresentationCardArtifact) -> Self {
144        Self::new(CliArtifact::presentation_card(card))
145    }
146
147    #[must_use]
148    pub fn card_value(title: impl Into<String>, value: &impl Serialize) -> Self {
149        let sections = sections_from_value(&serde_json::to_value(value).unwrap_or(JsonValue::Null));
150        Self::card(PresentationCardArtifact::new(title).with_sections(sections))
151    }
152
153    #[must_use]
154    pub const fn chart(chart: ChartArtifact) -> Self {
155        Self::new(CliArtifact::chart(chart))
156    }
157
158    #[must_use]
159    pub const fn dashboard(dashboard: DashboardArtifact) -> Self {
160        Self::new(CliArtifact::dashboard(dashboard))
161    }
162
163    #[must_use]
164    pub fn message(lines: Vec<NoticeLine>) -> Self {
165        Self::new(CliArtifact::message(MessageArtifact::new(lines)))
166    }
167}
168
169impl From<CliArtifact> for CommandOutput {
170    fn from(artifact: CliArtifact) -> Self {
171        Self::new(artifact)
172    }
173}
174
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}