use outl_md::parse::OutlineNode;
use sha2::{Digest, Sha256};
use crate::runtime::{ExecError, ExecOutput, ExitStatus};
pub const RESULT_MARKER: &str = "> **result:**";
pub const SOURCE_HASH_KEY: &str = "source-hash";
pub fn source_hash(source: &str) -> String {
let mut h = Sha256::new();
h.update(source.as_bytes());
format!("sha256:{}", hex_encode(&h.finalize()))
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
pub fn render_result_body(out: Result<&ExecOutput, &ExecError>) -> String {
match out {
Err(e) => format!("{RESULT_MARKER} `error: {e}`"),
Ok(o) => {
let header = match &o.exit {
ExitStatus::Ok => RESULT_MARKER.to_string(),
ExitStatus::NonZero(code) => format!("> **result (exit {code}):**"),
ExitStatus::Trap(msg) => format!("> **result (trap: {msg}):**"),
};
let payload = if o.stdout.is_empty() && !o.stderr.is_empty() {
o.stderr.trim_end()
} else {
o.stdout.trim_end()
};
if payload.is_empty() {
format!("{header} `(no output)`")
} else if payload.contains('\n') {
format!("{header}\n```\n{payload}\n```")
} else {
format!("{header} `{payload}`")
}
}
}
}
pub fn upsert_result_child(parent: &mut OutlineNode, body: String) {
if let Some(idx) = parent.children.iter().position(is_result_block) {
parent.children[idx].text = body;
} else {
parent.children.push(OutlineNode {
text: body,
properties: Vec::new(),
children: Vec::new(),
});
}
}
pub fn upsert_result_embeds(parent: &mut OutlineNode, header: String, lines: &[&str]) {
let embed_children: Vec<OutlineNode> = lines
.iter()
.map(|line| OutlineNode {
text: line.to_string(),
properties: Vec::new(),
children: Vec::new(),
})
.collect();
if let Some(idx) = parent.children.iter().position(is_result_block) {
parent.children[idx].text = header;
parent.children[idx].children = embed_children;
} else {
parent.children.push(OutlineNode {
text: header,
properties: Vec::new(),
children: embed_children,
});
}
}
pub fn upsert_result_child_with_hash(parent: &mut OutlineNode, body: String, source_hash: &str) {
let idx = match parent.children.iter().position(is_result_block) {
Some(i) => {
parent.children[i].text = body;
i
}
None => {
parent.children.push(OutlineNode {
text: body,
properties: Vec::new(),
children: Vec::new(),
});
parent.children.len() - 1
}
};
let props = &mut parent.children[idx].properties;
if let Some(p) = props.iter_mut().find(|(k, _)| k == SOURCE_HASH_KEY) {
p.1 = source_hash.to_string();
} else {
props.push((SOURCE_HASH_KEY.to_string(), source_hash.to_string()));
}
}
pub fn result_source_hash(parent: &OutlineNode) -> Option<&str> {
let result = parent.children.iter().find(|c| is_result_block(c))?;
result
.properties
.iter()
.find(|(k, _)| k == SOURCE_HASH_KEY)
.map(|(_, v)| v.as_str())
}
fn is_result_block(node: &OutlineNode) -> bool {
node.text
.lines()
.next()
.map(|first| first.trim_start().starts_with(RESULT_MARKER))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::OutputFormat;
use std::time::Duration;
fn ok_output(stdout: &str) -> ExecOutput {
ExecOutput {
stdout: stdout.to_string(),
stderr: String::new(),
duration: Duration::from_millis(1),
exit: ExitStatus::Ok,
format: OutputFormat::Text,
}
}
#[test]
fn single_line_output_uses_inline_backticks() {
let body = render_result_body(Ok(&ok_output("3")));
assert_eq!(body, "> **result:** `3`");
}
#[test]
fn multi_line_output_uses_fenced_block() {
let body = render_result_body(Ok(&ok_output("a\nb\nc")));
assert_eq!(body, "> **result:**\n```\na\nb\nc\n```");
}
#[test]
fn empty_output_shows_placeholder() {
let body = render_result_body(Ok(&ok_output("")));
assert_eq!(body, "> **result:** `(no output)`");
}
#[test]
fn non_zero_exit_marks_header() {
let out = ExecOutput {
stdout: "boom".into(),
stderr: String::new(),
duration: Duration::from_millis(1),
exit: ExitStatus::NonZero(2),
format: OutputFormat::Text,
};
let body = render_result_body(Ok(&out));
assert!(body.starts_with("> **result (exit 2):**"));
}
#[test]
fn trap_marks_header_with_message() {
let out = ExecOutput {
stdout: String::new(),
stderr: "divide by zero".into(),
duration: Duration::from_millis(1),
exit: ExitStatus::Trap("div-by-zero".into()),
format: OutputFormat::Text,
};
let body = render_result_body(Ok(&out));
assert!(body.starts_with("> **result (trap: div-by-zero):**"));
assert!(body.contains("divide by zero"));
}
#[test]
fn error_path_renders_message() {
let err = ExecError::Timeout(Duration::from_secs(2));
let body = render_result_body(Err(&err));
assert!(body.starts_with("> **result:** `error:"));
assert!(body.contains("timed out"));
}
#[test]
fn upsert_creates_child_when_absent() {
let mut parent = OutlineNode {
text: "```lisp\n(+ 1 2)\n```".into(),
properties: Vec::new(),
children: Vec::new(),
};
upsert_result_child(&mut parent, "> **result:** `3`".into());
assert_eq!(parent.children.len(), 1);
assert_eq!(parent.children[0].text, "> **result:** `3`");
}
#[test]
fn upsert_replaces_existing_result_child_in_place() {
let mut parent = OutlineNode {
text: "```lisp\n(+ 1 2)\n```".into(),
properties: Vec::new(),
children: vec![OutlineNode {
text: "> **result:** `old`".into(),
properties: Vec::new(),
children: Vec::new(),
}],
};
upsert_result_child(&mut parent, "> **result:** `new`".into());
assert_eq!(parent.children.len(), 1, "must not create a second child");
assert_eq!(parent.children[0].text, "> **result:** `new`");
}
#[test]
fn upsert_ignores_non_result_children() {
let mut parent = OutlineNode {
text: "code".into(),
properties: Vec::new(),
children: vec![OutlineNode {
text: "some unrelated note".into(),
properties: Vec::new(),
children: Vec::new(),
}],
};
upsert_result_child(&mut parent, "> **result:** `42`".into());
assert_eq!(parent.children.len(), 2);
assert_eq!(parent.children[0].text, "some unrelated note");
assert_eq!(parent.children[1].text, "> **result:** `42`");
}
#[test]
fn source_hash_is_stable() {
let a = source_hash("(+ 1 2)");
let b = source_hash("(+ 1 2)");
assert_eq!(a, b);
assert!(a.starts_with("sha256:"));
}
#[test]
fn source_hash_differs_on_whitespace() {
assert_ne!(source_hash("a b"), source_hash("a b"));
assert_ne!(source_hash("a\nb"), source_hash("a b"));
}
#[test]
fn upsert_with_hash_stamps_property_on_existing_child() {
let mut parent = OutlineNode {
text: "code".into(),
properties: Vec::new(),
children: vec![OutlineNode {
text: "> **result:** `old`".into(),
properties: Vec::new(),
children: Vec::new(),
}],
};
upsert_result_child_with_hash(&mut parent, "> **result:** `3`".into(), "sha256:deadbeef");
assert_eq!(parent.children.len(), 1);
assert_eq!(parent.children[0].text, "> **result:** `3`");
assert_eq!(result_source_hash(&parent), Some("sha256:deadbeef"));
}
#[test]
fn upsert_with_hash_creates_child_when_absent() {
let mut parent = OutlineNode {
text: "code".into(),
properties: Vec::new(),
children: Vec::new(),
};
upsert_result_child_with_hash(&mut parent, "> **result:** `5`".into(), "sha256:abc");
assert_eq!(parent.children.len(), 1);
assert_eq!(result_source_hash(&parent), Some("sha256:abc"));
}
#[test]
fn result_source_hash_returns_none_when_unstamped() {
let parent = OutlineNode {
text: "code".into(),
properties: Vec::new(),
children: vec![OutlineNode {
text: "> **result:** `legacy`".into(),
properties: Vec::new(),
children: Vec::new(),
}],
};
assert_eq!(result_source_hash(&parent), None);
}
#[test]
fn upsert_embeds_creates_result_with_children_when_absent() {
let mut parent = OutlineNode {
text: "```query\nstatus: todo\n```".into(),
properties: Vec::new(),
children: Vec::new(),
};
upsert_result_embeds(
&mut parent,
"> **result:** (2 blocks)".into(),
&["!((blk-aaa))", "!((blk-bbb))"],
);
assert_eq!(parent.children.len(), 1);
assert_eq!(parent.children[0].text, "> **result:** (2 blocks)");
assert_eq!(parent.children[0].children.len(), 2);
assert_eq!(parent.children[0].children[0].text, "!((blk-aaa))");
assert_eq!(parent.children[0].children[1].text, "!((blk-bbb))");
}
#[test]
fn upsert_embeds_replaces_existing_result_in_place() {
let mut parent = OutlineNode {
text: "```query\nstatus: todo\n```".into(),
properties: Vec::new(),
children: vec![OutlineNode {
text: "> **result:** (1 blocks)".into(),
properties: Vec::new(),
children: vec![OutlineNode {
text: "!((blk-old))".into(),
properties: Vec::new(),
children: Vec::new(),
}],
}],
};
upsert_result_embeds(
&mut parent,
"> **result:** (3 blocks)".into(),
&["!((blk-a))", "!((blk-b))", "!((blk-c))"],
);
assert_eq!(parent.children.len(), 1, "must not create a second child");
assert_eq!(parent.children[0].text, "> **result:** (3 blocks)");
assert_eq!(parent.children[0].children.len(), 3);
}
}