1use alloc::string::String;
7use alloc::vec::Vec;
8
9use askama::Template;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct StoredFile {
15 pub url: String,
17 pub path: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
25pub enum CommandStream {
26 Inline {
28 text: String,
30 },
31 Stored {
33 file: StoredFile,
35 preview: String,
38 truncated: bool,
40 },
41 Image {
43 media_type: String,
45 file: StoredFile,
47 },
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
53#[cfg_attr(feature = "serde", serde(tag = "status", rename_all = "snake_case"))]
54pub enum CommandOutcome {
55 Exited {
57 code: i32,
59 },
60 Running {
62 task_id: String,
64 output: StoredFile,
66 },
67 Killed {
69 task_id: String,
71 notice: String,
73 },
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
80pub enum InterruptionCause {
81 Asleep,
83 Offline,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub struct Interruption {
94 pub cause: InterruptionCause,
96 pub from: String,
98 pub to: String,
100}
101
102#[must_use]
110pub fn render_interruptions(interruptions: &[Interruption]) -> String {
111 InterruptionsTemplate { interruptions }
112 .render()
113 .expect("interruptions template must render")
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub struct CommandResult {
120 pub stdout: CommandStream,
122 pub stderr: Option<CommandStream>,
124 pub outcome: CommandOutcome,
126 #[cfg_attr(feature = "serde", serde(default))]
128 pub interruptions: Vec<Interruption>,
129}
130
131#[derive(Template)]
132#[template(path = "command_interruptions.txt", escape = "none")]
133struct InterruptionsTemplate<'a> {
134 interruptions: &'a [Interruption],
135}
136
137#[derive(Template)]
138#[template(path = "command_stream.txt", escape = "none")]
139struct CommandStreamTemplate<'a> {
140 stream: &'a CommandStream,
141}
142
143#[derive(Template)]
144#[template(path = "command_result.txt", escape = "none")]
145struct CommandResultTemplate<'a> {
146 stdout: &'a str,
147 stderr: Option<&'a str>,
148 outcome: &'a CommandOutcome,
149 interruptions: &'a str,
150}
151
152impl CommandStream {
153 #[must_use]
155 pub const fn is_empty(&self) -> bool {
156 matches!(self, Self::Inline { text } if text.is_empty())
157 }
158
159 #[must_use]
167 pub fn render_for_model(&self) -> String {
168 if self.is_empty() {
169 return String::new();
170 }
171 let mut block = CommandStreamTemplate { stream: self }
172 .render()
173 .expect("command stream template must render");
174 if !block.ends_with('\n') {
175 block.push('\n');
176 }
177 block
178 }
179}
180
181impl CommandResult {
182 #[must_use]
189 pub fn render_for_model(&self) -> String {
190 let stdout = self.stdout.render_for_model();
191 let stderr = self
192 .stderr
193 .as_ref()
194 .map(CommandStream::render_for_model)
195 .filter(|block| !block.is_empty());
196 CommandResultTemplate {
197 stdout: &stdout,
198 stderr: stderr.as_deref(),
199 outcome: &self.outcome,
200 interruptions: &render_interruptions(&self.interruptions),
201 }
202 .render()
203 .expect("command result template must render")
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use alloc::string::ToString;
210
211 use super::*;
212
213 fn file() -> StoredFile {
214 StoredFile {
215 url: "outputs/x.txt".to_string(),
216 path: "/sandbox/outputs/x.txt".to_string(),
217 }
218 }
219
220 fn result(
221 stdout: CommandStream,
222 stderr: Option<CommandStream>,
223 outcome: CommandOutcome,
224 ) -> String {
225 CommandResult {
226 stdout,
227 stderr,
228 outcome,
229 interruptions: Vec::new(),
230 }
231 .render_for_model()
232 }
233
234 #[test]
235 fn placeholder_only_when_both_streams_are_empty() {
236 assert_eq!(
237 result(
238 CommandStream::Inline {
239 text: String::new()
240 },
241 None,
242 CommandOutcome::Exited { code: 0 }
243 ),
244 "(no output)\n"
245 );
246 assert_eq!(
247 result(
248 CommandStream::Inline {
249 text: String::new()
250 },
251 Some(CommandStream::Inline {
252 text: "oops".to_string()
253 }),
254 CommandOutcome::Exited { code: 2 },
255 ),
256 "stderr:\noops\nExit code: 2.\n"
257 );
258 }
259
260 #[test]
261 fn stored_streams_state_url_and_absolute_path() {
262 let stored = CommandStream::Stored {
263 file: file(),
264 preview: "head".to_string(),
265 truncated: true,
266 };
267 assert_eq!(
268 result(stored, None, CommandOutcome::Exited { code: 0 }),
269 "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"
270 );
271 let image = CommandStream::Image {
272 media_type: "image/png".to_string(),
273 file: file(),
274 };
275 assert_eq!(
276 result(image, None, CommandOutcome::Exited { code: 0 }),
277 "[Image: image/png, saved to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).]\n"
278 );
279 }
280
281 #[test]
282 fn running_and_killed_outcomes_render_their_facts() {
283 assert_eq!(
284 result(
285 CommandStream::Inline {
286 text: String::new()
287 },
288 None,
289 CommandOutcome::Running {
290 task_id: "t1".to_string(),
291 output: file(),
292 },
293 ),
294 "Started in background as task 't1'; output is being written to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).\n"
295 );
296 assert_eq!(
297 result(
298 CommandStream::Inline {
299 text: "partial\n".to_string()
300 },
301 None,
302 CommandOutcome::Killed {
303 task_id: "t2".to_string(),
304 notice: "Killed at the time bound.".to_string(),
305 },
306 ),
307 "partial\nKilled at the time bound.\nTask ID: 't2'.\n"
308 );
309 }
310
311 #[test]
312 fn interruptions_render_one_line_each_after_the_outcome() {
313 let command = CommandResult {
314 stdout: CommandStream::Inline {
315 text: "out\n".to_string(),
316 },
317 stderr: None,
318 outcome: CommandOutcome::Exited { code: 1 },
319 interruptions: alloc::vec![
320 Interruption {
321 cause: InterruptionCause::Asleep,
322 from: "14:02:11".to_string(),
323 to: "14:31:40".to_string(),
324 },
325 Interruption {
326 cause: InterruptionCause::Offline,
327 from: "14:31:40".to_string(),
328 to: "14:32:05".to_string(),
329 },
330 ],
331 };
332 assert_eq!(
333 command.render_for_model(),
334 "out\nExit code: 1.\nThe machine was asleep from 14:02:11 to 14:31:40 while this ran.\nThe network was offline from 14:31:40 to 14:32:05 while this ran.\n"
335 );
336 }
337}