Skip to main content

systemprompt_cli/shared/
command_result.rs

1//! Structured command output and its terminal/JSON rendering.
2//!
3//! [`CommandResult`] wraps a command's typed payload with an [`ArtifactType`]
4//! and optional [`RenderingHints`], decoupling what a command produces from how
5//! it is displayed. [`render_result`] dispatches on the active output format
6//! (table, JSON, YAML). The reusable payload shapes [`TextOutput`],
7//! [`SuccessOutput`], [`KeyValueOutput`], and [`TableOutput`] cover common
8//! cases.
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
15#[serde(rename_all = "snake_case")]
16pub enum ArtifactType {
17    Table,
18    List,
19    PresentationCard,
20    Text,
21    CopyPasteText,
22    RawText,
23    Chart,
24    Form,
25    Dashboard,
26}
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "snake_case")]
30pub enum ChartType {
31    Bar,
32    Line,
33    Pie,
34    Area,
35}
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
38pub struct RenderingHints {
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub columns: Option<Vec<String>>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub chart_type: Option<ChartType>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub theme: Option<String>,
45    // JSON: open-ended renderer hint bag; keys are renderer-defined and not known at compile time
46    #[serde(flatten)]
47    pub extra: HashMap<String, serde_json::Value>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
51pub struct CommandResult<T> {
52    pub data: T,
53    pub artifact_type: ArtifactType,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub title: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub hints: Option<RenderingHints>,
58    #[serde(skip)]
59    skip_render: bool,
60}
61
62impl<T> CommandResult<T> {
63    const fn new(data: T, artifact_type: ArtifactType) -> Self {
64        Self {
65            data,
66            artifact_type,
67            title: None,
68            hints: None,
69            skip_render: false,
70        }
71    }
72
73    pub const fn should_skip_render(&self) -> bool {
74        self.skip_render
75    }
76
77    pub const fn with_skip_render(mut self) -> Self {
78        self.skip_render = true;
79        self
80    }
81
82    pub const fn table(data: T) -> Self {
83        Self::new(data, ArtifactType::Table)
84    }
85
86    pub const fn list(data: T) -> Self {
87        Self::new(data, ArtifactType::List)
88    }
89
90    pub const fn card(data: T) -> Self {
91        Self::new(data, ArtifactType::PresentationCard)
92    }
93
94    pub const fn text(data: T) -> Self {
95        Self::new(data, ArtifactType::Text)
96    }
97
98    pub const fn copy_paste(data: T) -> Self {
99        Self::new(data, ArtifactType::CopyPasteText)
100    }
101
102    pub const fn raw_text(data: T) -> Self {
103        Self::new(data, ArtifactType::RawText)
104    }
105
106    pub fn chart(data: T, chart_type: ChartType) -> Self {
107        let mut result = Self::new(data, ArtifactType::Chart);
108        result.hints = Some(RenderingHints {
109            chart_type: Some(chart_type),
110            ..Default::default()
111        });
112        result
113    }
114
115    pub const fn form(data: T) -> Self {
116        Self::new(data, ArtifactType::Form)
117    }
118
119    pub const fn dashboard(data: T) -> Self {
120        Self::new(data, ArtifactType::Dashboard)
121    }
122
123    pub fn with_title(mut self, title: impl Into<String>) -> Self {
124        self.title = Some(title.into());
125        self
126    }
127
128    pub fn with_hints(mut self, hints: RenderingHints) -> Self {
129        self.hints = Some(hints);
130        self
131    }
132
133    pub fn with_columns(mut self, columns: Vec<String>) -> Self {
134        let mut hints = self.hints.unwrap_or_else(RenderingHints::default);
135        hints.columns = Some(columns);
136        self.hints = Some(hints);
137        self
138    }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
142pub struct TextOutput {
143    pub message: String,
144}
145
146impl TextOutput {
147    pub fn new(message: impl Into<String>) -> Self {
148        Self {
149            message: message.into(),
150        }
151    }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
155pub struct SuccessOutput {
156    pub message: String,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub details: Option<Vec<String>>,
159}
160
161impl SuccessOutput {
162    pub fn new(message: impl Into<String>) -> Self {
163        Self {
164            message: message.into(),
165            details: None,
166        }
167    }
168
169    pub fn with_details(mut self, details: Vec<String>) -> Self {
170        self.details = Some(details);
171        self
172    }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
176pub struct KeyValueOutput {
177    pub items: Vec<KeyValueItem>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
181pub struct KeyValueItem {
182    pub key: String,
183    pub value: String,
184}
185
186impl KeyValueOutput {
187    pub const fn new() -> Self {
188        Self { items: Vec::new() }
189    }
190
191    pub fn add(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
192        self.items.push(KeyValueItem {
193            key: key.into(),
194            value: value.into(),
195        });
196        self
197    }
198}
199
200impl Default for KeyValueOutput {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
207pub struct TableOutput<T> {
208    pub rows: Vec<T>,
209}
210
211impl<T> TableOutput<T> {
212    pub const fn new(rows: Vec<T>) -> Self {
213        Self { rows }
214    }
215}
216
217impl<T> Default for TableOutput<T> {
218    fn default() -> Self {
219        Self { rows: Vec::new() }
220    }
221}
222
223use crate::cli_settings::{OutputFormat, get_global_config};
224use systemprompt_logging::CliService;
225
226pub fn render_result<T: Serialize>(result: &CommandResult<T>) {
227    if result.should_skip_render() {
228        return;
229    }
230
231    let config = get_global_config();
232
233    if matches!(result.artifact_type, ArtifactType::RawText) {
234        if let Ok(serde_json::Value::String(raw)) = serde_json::to_value(&result.data) {
235            if matches!(config.output_format(), OutputFormat::Table) {
236                if let Some(title) = &result.title {
237                    CliService::section(title);
238                }
239            }
240            CliService::output(&raw);
241            return;
242        }
243    }
244
245    match config.output_format() {
246        OutputFormat::Json => {
247            CliService::json(result);
248        },
249        OutputFormat::Yaml => {
250            CliService::yaml(result);
251        },
252        OutputFormat::Table => {
253            if let Some(title) = &result.title {
254                CliService::section(title);
255            }
256            CliService::json(&result.data);
257        },
258    }
259}