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(String),
27 Stored {
29 file: StoredFile,
31 preview: String,
34 truncated: bool,
36 },
37 Image {
39 media_type: String,
41 file: StoredFile,
43 },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49#[cfg_attr(feature = "serde", serde(tag = "status", rename_all = "snake_case"))]
50pub enum CommandOutcome {
51 Exited {
53 code: i32,
55 },
56 Running {
58 task_id: String,
60 output: StoredFile,
62 },
63 Killed {
65 task_id: String,
67 notice: String,
69 },
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct CommandResult {
76 pub stdout: CommandStream,
78 pub stderr: Option<CommandStream>,
80 pub outcome: CommandOutcome,
82}
83
84#[derive(Template)]
85#[template(path = "command_stream.txt", escape = "none")]
86struct CommandStreamTemplate<'a> {
87 stream: &'a CommandStream,
88}
89
90#[derive(Template)]
91#[template(path = "command_result.txt", escape = "none")]
92struct CommandResultTemplate<'a> {
93 stdout: &'a str,
94 stderr: Option<&'a str>,
95 outcome: &'a CommandOutcome,
96}
97
98impl CommandStream {
99 #[must_use]
101 pub const fn is_empty(&self) -> bool {
102 matches!(self, Self::Inline(text) if text.is_empty())
103 }
104
105 #[must_use]
113 pub fn render_for_model(&self) -> String {
114 if self.is_empty() {
115 return String::new();
116 }
117 let mut block = CommandStreamTemplate { stream: self }
118 .render()
119 .expect("command stream template must render");
120 if !block.ends_with('\n') {
121 block.push('\n');
122 }
123 block
124 }
125}
126
127impl CommandResult {
128 #[must_use]
135 pub fn render_for_model(&self) -> String {
136 let stdout = self.stdout.render_for_model();
137 let stderr = self
138 .stderr
139 .as_ref()
140 .map(CommandStream::render_for_model)
141 .filter(|block| !block.is_empty());
142 CommandResultTemplate {
143 stdout: &stdout,
144 stderr: stderr.as_deref(),
145 outcome: &self.outcome,
146 }
147 .render()
148 .expect("command result template must render")
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use alloc::string::ToString;
155
156 use super::*;
157
158 fn file() -> StoredFile {
159 StoredFile {
160 url: "outputs/x.txt".to_string(),
161 path: "/sandbox/outputs/x.txt".to_string(),
162 }
163 }
164
165 fn result(
166 stdout: CommandStream,
167 stderr: Option<CommandStream>,
168 outcome: CommandOutcome,
169 ) -> String {
170 CommandResult {
171 stdout,
172 stderr,
173 outcome,
174 }
175 .render_for_model()
176 }
177
178 #[test]
179 fn placeholder_only_when_both_streams_are_empty() {
180 assert_eq!(
181 result(
182 CommandStream::Inline(String::new()),
183 None,
184 CommandOutcome::Exited { code: 0 }
185 ),
186 "(no output)\n"
187 );
188 assert_eq!(
189 result(
190 CommandStream::Inline(String::new()),
191 Some(CommandStream::Inline("oops".to_string())),
192 CommandOutcome::Exited { code: 2 },
193 ),
194 "stderr:\noops\nExit code: 2.\n"
195 );
196 }
197
198 #[test]
199 fn stored_streams_state_url_and_absolute_path() {
200 let stored = CommandStream::Stored {
201 file: file(),
202 preview: "head".to_string(),
203 truncated: true,
204 };
205 assert_eq!(
206 result(stored, None, CommandOutcome::Exited { code: 0 }),
207 "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"
208 );
209 let image = CommandStream::Image {
210 media_type: "image/png".to_string(),
211 file: file(),
212 };
213 assert_eq!(
214 result(image, None, CommandOutcome::Exited { code: 0 }),
215 "[Image: image/png, saved to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).]\n"
216 );
217 }
218
219 #[test]
220 fn running_and_killed_outcomes_render_their_facts() {
221 assert_eq!(
222 result(
223 CommandStream::Inline(String::new()),
224 None,
225 CommandOutcome::Running {
226 task_id: "t1".to_string(),
227 output: file(),
228 },
229 ),
230 "Started in background as task 't1'; output is being written to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).\n"
231 );
232 assert_eq!(
233 result(
234 CommandStream::Inline("partial\n".to_string()),
235 None,
236 CommandOutcome::Killed {
237 task_id: "t2".to_string(),
238 notice: "Killed at the time bound.".to_string(),
239 },
240 ),
241 "partial\nKilled at the time bound.\nTask ID: 't2'.\n"
242 );
243 }
244}