1use alloc::string::String;
7
8use askama::Template;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct StoredFile {
14 pub url: String,
16 pub path: String,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
24pub enum CommandStream {
25 Inline {
27 text: String,
29 },
30 Stored {
32 file: StoredFile,
34 preview: String,
37 truncated: bool,
39 },
40 Image {
42 media_type: String,
44 file: StoredFile,
46 },
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52#[cfg_attr(feature = "serde", serde(tag = "status", rename_all = "snake_case"))]
53pub enum CommandOutcome {
54 Exited {
56 code: i32,
58 },
59 Running {
61 task_id: String,
63 output: StoredFile,
65 },
66 Killed {
68 task_id: String,
70 notice: String,
72 },
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct CommandResult {
79 pub stdout: CommandStream,
81 pub stderr: Option<CommandStream>,
83 pub outcome: CommandOutcome,
85}
86
87#[derive(Template)]
88#[template(path = "command_stream.txt", escape = "none")]
89struct CommandStreamTemplate<'a> {
90 stream: &'a CommandStream,
91}
92
93#[derive(Template)]
94#[template(path = "command_result.txt", escape = "none")]
95struct CommandResultTemplate<'a> {
96 stdout: &'a str,
97 stderr: Option<&'a str>,
98 outcome: &'a CommandOutcome,
99}
100
101impl CommandStream {
102 #[must_use]
104 pub const fn is_empty(&self) -> bool {
105 matches!(self, Self::Inline { text } if text.is_empty())
106 }
107
108 #[must_use]
116 pub fn render_for_model(&self) -> String {
117 if self.is_empty() {
118 return String::new();
119 }
120 let mut block = CommandStreamTemplate { stream: self }
121 .render()
122 .expect("command stream template must render");
123 if !block.ends_with('\n') {
124 block.push('\n');
125 }
126 block
127 }
128}
129
130impl CommandResult {
131 #[must_use]
138 pub fn render_for_model(&self) -> String {
139 let stdout = self.stdout.render_for_model();
140 let stderr = self
141 .stderr
142 .as_ref()
143 .map(CommandStream::render_for_model)
144 .filter(|block| !block.is_empty());
145 CommandResultTemplate {
146 stdout: &stdout,
147 stderr: stderr.as_deref(),
148 outcome: &self.outcome,
149 }
150 .render()
151 .expect("command result template must render")
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use alloc::string::ToString;
158
159 use super::*;
160
161 fn file() -> StoredFile {
162 StoredFile {
163 url: "outputs/x.txt".to_string(),
164 path: "/sandbox/outputs/x.txt".to_string(),
165 }
166 }
167
168 fn result(
169 stdout: CommandStream,
170 stderr: Option<CommandStream>,
171 outcome: CommandOutcome,
172 ) -> String {
173 CommandResult {
174 stdout,
175 stderr,
176 outcome,
177 }
178 .render_for_model()
179 }
180
181 #[test]
182 fn placeholder_only_when_both_streams_are_empty() {
183 assert_eq!(
184 result(
185 CommandStream::Inline {
186 text: String::new()
187 },
188 None,
189 CommandOutcome::Exited { code: 0 }
190 ),
191 "(no output)\n"
192 );
193 assert_eq!(
194 result(
195 CommandStream::Inline {
196 text: String::new()
197 },
198 Some(CommandStream::Inline {
199 text: "oops".to_string()
200 }),
201 CommandOutcome::Exited { code: 2 },
202 ),
203 "stderr:\noops\nExit code: 2.\n"
204 );
205 }
206
207 #[test]
208 fn stored_streams_state_url_and_absolute_path() {
209 let stored = CommandStream::Stored {
210 file: file(),
211 preview: "head".to_string(),
212 truncated: true,
213 };
214 assert_eq!(
215 result(stored, None, CommandOutcome::Exited { code: 0 }),
216 "head\n[Partial view: the full output is saved to outputs/x.txt (absolute path: /sandbox/outputs/x.txt). Use head/tail/grep/sed on that file to read the rest.]\n"
217 );
218 let image = CommandStream::Image {
219 media_type: "image/png".to_string(),
220 file: file(),
221 };
222 assert_eq!(
223 result(image, None, CommandOutcome::Exited { code: 0 }),
224 "[Image: image/png, saved to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).]\n"
225 );
226 }
227
228 #[test]
229 fn running_and_killed_outcomes_render_their_facts() {
230 assert_eq!(
231 result(
232 CommandStream::Inline {
233 text: String::new()
234 },
235 None,
236 CommandOutcome::Running {
237 task_id: "t1".to_string(),
238 output: file(),
239 },
240 ),
241 "Started in background as task 't1'; output is being written to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).\n"
242 );
243 assert_eq!(
244 result(
245 CommandStream::Inline {
246 text: "partial\n".to_string()
247 },
248 None,
249 CommandOutcome::Killed {
250 task_id: "t2".to_string(),
251 notice: "Killed at the time bound.".to_string(),
252 },
253 ),
254 "partial\nKilled at the time bound.\nTask ID: 't2'.\n"
255 );
256 }
257}