use alloc::string::String;
use askama::Template;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StoredFile {
pub url: String,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
pub enum CommandStream {
Inline(String),
Stored {
file: StoredFile,
preview: String,
truncated: bool,
},
Image {
media_type: String,
file: StoredFile,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "status", rename_all = "snake_case"))]
pub enum CommandOutcome {
Exited {
code: i32,
},
Running {
task_id: String,
output: StoredFile,
},
Killed {
task_id: String,
notice: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommandResult {
pub stdout: CommandStream,
pub stderr: Option<CommandStream>,
pub outcome: CommandOutcome,
}
#[derive(Template)]
#[template(path = "command_stream.txt", escape = "none")]
struct CommandStreamTemplate<'a> {
stream: &'a CommandStream,
}
#[derive(Template)]
#[template(path = "command_result.txt", escape = "none")]
struct CommandResultTemplate<'a> {
stdout: &'a str,
stderr: Option<&'a str>,
outcome: &'a CommandOutcome,
}
impl CommandStream {
#[must_use]
pub const fn is_empty(&self) -> bool {
matches!(self, Self::Inline(text) if text.is_empty())
}
#[must_use]
pub fn render_for_model(&self) -> String {
if self.is_empty() {
return String::new();
}
let mut block = CommandStreamTemplate { stream: self }
.render()
.expect("command stream template must render");
if !block.ends_with('\n') {
block.push('\n');
}
block
}
}
impl CommandResult {
#[must_use]
pub fn render_for_model(&self) -> String {
let stdout = self.stdout.render_for_model();
let stderr = self
.stderr
.as_ref()
.map(CommandStream::render_for_model)
.filter(|block| !block.is_empty());
CommandResultTemplate {
stdout: &stdout,
stderr: stderr.as_deref(),
outcome: &self.outcome,
}
.render()
.expect("command result template must render")
}
}
#[cfg(test)]
mod tests {
use alloc::string::ToString;
use super::*;
fn file() -> StoredFile {
StoredFile {
url: "outputs/x.txt".to_string(),
path: "/sandbox/outputs/x.txt".to_string(),
}
}
fn result(
stdout: CommandStream,
stderr: Option<CommandStream>,
outcome: CommandOutcome,
) -> String {
CommandResult {
stdout,
stderr,
outcome,
}
.render_for_model()
}
#[test]
fn placeholder_only_when_both_streams_are_empty() {
assert_eq!(
result(
CommandStream::Inline(String::new()),
None,
CommandOutcome::Exited { code: 0 }
),
"(no output)\n"
);
assert_eq!(
result(
CommandStream::Inline(String::new()),
Some(CommandStream::Inline("oops".to_string())),
CommandOutcome::Exited { code: 2 },
),
"stderr:\noops\nExit code: 2.\n"
);
}
#[test]
fn stored_streams_state_url_and_absolute_path() {
let stored = CommandStream::Stored {
file: file(),
preview: "head".to_string(),
truncated: true,
};
assert_eq!(
result(stored, None, CommandOutcome::Exited { code: 0 }),
"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"
);
let image = CommandStream::Image {
media_type: "image/png".to_string(),
file: file(),
};
assert_eq!(
result(image, None, CommandOutcome::Exited { code: 0 }),
"[Image: image/png, saved to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).]\n"
);
}
#[test]
fn running_and_killed_outcomes_render_their_facts() {
assert_eq!(
result(
CommandStream::Inline(String::new()),
None,
CommandOutcome::Running {
task_id: "t1".to_string(),
output: file(),
},
),
"Started in background as task 't1'; output is being written to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).\n"
);
assert_eq!(
result(
CommandStream::Inline("partial\n".to_string()),
None,
CommandOutcome::Killed {
task_id: "t2".to_string(),
notice: "Killed at the time bound.".to_string(),
},
),
"partial\nKilled at the time bound.\nTask ID: 't2'.\n"
);
}
}