Skip to main content

systemprompt_models/artifacts/
cli.rs

1//! CLI artifact wrapper for MCP tool responses.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value as JsonValue;
6use std::collections::HashMap;
7use thiserror::Error;
8
9use super::list::ListItem;
10use super::table::Column;
11use super::types::ColumnType;
12use super::{CopyPasteTextArtifact, DashboardArtifact, ListArtifact, TableArtifact, TextArtifact};
13use crate::execution::context::RequestContext;
14
15#[derive(Debug, Error)]
16pub enum ConversionError {
17    #[error("Missing columns hint for table artifact")]
18    MissingColumns,
19
20    #[error("No array found in data for table/list conversion")]
21    NoArrayFound,
22
23    #[error("JSON serialization error: {0}")]
24    Json(#[from] serde_json::Error),
25
26    #[error("Unsupported artifact type: {0}")]
27    UnsupportedType(String),
28}
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
31#[serde(rename_all = "snake_case")]
32pub enum CliArtifactType {
33    Table,
34    List,
35    PresentationCard,
36    Text,
37    CopyPasteText,
38    Chart,
39    Form,
40    Dashboard,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
44pub struct RenderingHints {
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub columns: Option<Vec<String>>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub chart_type: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub theme: Option<String>,
51    #[serde(flatten)]
52    pub extra: HashMap<String, JsonValue>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct CommandResultRaw {
57    pub data: JsonValue,
58    pub artifact_type: CliArtifactType,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub title: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub hints: Option<RenderingHints>,
63}
64
65impl CommandResultRaw {
66    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
67        serde_json::from_str(json)
68    }
69
70    pub fn from_value(value: JsonValue) -> Result<Self, serde_json::Error> {
71        serde_json::from_value(value)
72    }
73
74    pub fn to_cli_artifact(&self, ctx: &RequestContext) -> Result<CliArtifact, ConversionError> {
75        match self.artifact_type {
76            CliArtifactType::Table => self.convert_table(ctx),
77            CliArtifactType::List => self.convert_list(ctx),
78            CliArtifactType::CopyPasteText => Ok(self.convert_copy_paste_text(ctx)),
79            CliArtifactType::Text
80            | CliArtifactType::PresentationCard
81            | CliArtifactType::Dashboard
82            | CliArtifactType::Chart
83            | CliArtifactType::Form => Ok(self.convert_text(ctx)),
84        }
85    }
86
87    fn convert_table(&self, ctx: &RequestContext) -> Result<CliArtifact, ConversionError> {
88        let column_names = self
89            .hints
90            .as_ref()
91            .and_then(|h| h.columns.as_ref())
92            .ok_or(ConversionError::MissingColumns)?;
93
94        let items = extract_array_from_value(&self.data)?;
95
96        let columns: Vec<Column> = column_names
97            .iter()
98            .map(|name| Column::new(name, ColumnType::String))
99            .collect();
100
101        let artifact = TableArtifact::new(columns, ctx).with_rows(items);
102
103        Ok(CliArtifact::Table { artifact })
104    }
105
106    fn convert_list(&self, ctx: &RequestContext) -> Result<CliArtifact, ConversionError> {
107        let items = extract_array_from_value(&self.data)?;
108
109        let list_items: Vec<ListItem> = items
110            .iter()
111            .filter_map(|item| {
112                let title = item
113                    .get("title")
114                    .or_else(|| item.get("name"))
115                    .and_then(|v| v.as_str())?;
116
117                let summary = item
118                    .get("summary")
119                    .or_else(|| item.get("description"))
120                    .and_then(|v| v.as_str())
121                    .unwrap_or("");
122
123                let link = item
124                    .get("link")
125                    .or_else(|| item.get("url"))
126                    .or_else(|| item.get("id"))
127                    .and_then(|v| v.as_str())
128                    .unwrap_or("");
129
130                Some(ListItem::new(title, summary, link))
131            })
132            .collect();
133
134        let artifact = ListArtifact::new(ctx).with_items(list_items);
135
136        Ok(CliArtifact::List { artifact })
137    }
138
139    fn convert_text(&self, ctx: &RequestContext) -> CliArtifact {
140        let content = self
141            .data
142            .get("message")
143            .and_then(|v| v.as_str())
144            .map_or_else(
145                || {
146                    serde_json::to_string_pretty(&self.data)
147                        .unwrap_or_else(|_| self.data.to_string())
148                },
149                String::from,
150            );
151
152        let mut artifact = TextArtifact::new(&content, ctx);
153
154        if let Some(title) = &self.title {
155            artifact = artifact.with_title(title);
156        }
157
158        CliArtifact::Text { artifact }
159    }
160
161    fn convert_copy_paste_text(&self, ctx: &RequestContext) -> CliArtifact {
162        let content = self
163            .data
164            .get("content")
165            .or_else(|| self.data.get("message"))
166            .and_then(|v| v.as_str())
167            .map_or_else(
168                || {
169                    serde_json::to_string_pretty(&self.data)
170                        .unwrap_or_else(|_| self.data.to_string())
171                },
172                String::from,
173            );
174
175        let mut artifact = CopyPasteTextArtifact::new(&content, ctx);
176
177        if let Some(title) = &self.title {
178            artifact = artifact.with_title(title);
179        }
180
181        CliArtifact::CopyPasteText { artifact }
182    }
183}
184
185fn extract_array_from_value(value: &JsonValue) -> Result<Vec<JsonValue>, ConversionError> {
186    if let Some(arr) = value.as_array() {
187        return Ok(arr.clone());
188    }
189
190    if let Some(obj) = value.as_object() {
191        for v in obj.values() {
192            if let Some(arr) = v.as_array() {
193                return Ok(arr.clone());
194            }
195        }
196    }
197
198    Err(ConversionError::NoArrayFound)
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
202#[serde(tag = "artifact_type", rename_all = "snake_case")]
203pub enum CliArtifact {
204    Table {
205        #[serde(flatten)]
206        artifact: TableArtifact,
207    },
208    List {
209        #[serde(flatten)]
210        artifact: ListArtifact,
211    },
212    Text {
213        #[serde(flatten)]
214        artifact: TextArtifact,
215    },
216    #[serde(rename = "copy_paste_text")]
217    CopyPasteText {
218        #[serde(flatten)]
219        artifact: CopyPasteTextArtifact,
220    },
221    Dashboard {
222        #[serde(flatten)]
223        artifact: DashboardArtifact,
224    },
225}
226
227impl CliArtifact {
228    #[must_use]
229    pub const fn artifact_type_str(&self) -> &'static str {
230        match self {
231            Self::Table { .. } => "table",
232            Self::List { .. } => "list",
233            Self::Text { .. } => "text",
234            Self::CopyPasteText { .. } => "copy_paste_text",
235            Self::Dashboard { .. } => "dashboard",
236        }
237    }
238
239    #[must_use]
240    pub const fn table(artifact: TableArtifact) -> Self {
241        Self::Table { artifact }
242    }
243
244    #[must_use]
245    pub const fn list(artifact: ListArtifact) -> Self {
246        Self::List { artifact }
247    }
248
249    #[must_use]
250    pub const fn text(artifact: TextArtifact) -> Self {
251        Self::Text { artifact }
252    }
253
254    #[must_use]
255    pub const fn copy_paste_text(artifact: CopyPasteTextArtifact) -> Self {
256        Self::CopyPasteText { artifact }
257    }
258
259    #[must_use]
260    pub const fn dashboard(artifact: DashboardArtifact) -> Self {
261        Self::Dashboard { artifact }
262    }
263}