1use outl_md::parse::OutlineNode;
12use sha2::{Digest, Sha256};
13
14use crate::runtime::{ExecError, ExecOutput, ExitStatus};
15
16pub const RESULT_MARKER: &str = "> **result:**";
19
20pub const SOURCE_HASH_KEY: &str = "source-hash";
25
26pub fn source_hash(source: &str) -> String {
30 let mut h = Sha256::new();
31 h.update(source.as_bytes());
32 format!("sha256:{}", hex_encode(&h.finalize()))
33}
34
35fn hex_encode(bytes: &[u8]) -> String {
37 let mut s = String::with_capacity(bytes.len() * 2);
38 for b in bytes {
39 s.push_str(&format!("{b:02x}"));
40 }
41 s
42}
43
44pub fn render_result_body(out: Result<&ExecOutput, &ExecError>) -> String {
54 match out {
55 Err(e) => format!("{RESULT_MARKER} `error: {e}`"),
56 Ok(o) => {
57 let header = match &o.exit {
58 ExitStatus::Ok => RESULT_MARKER.to_string(),
59 ExitStatus::NonZero(code) => format!("> **result (exit {code}):**"),
60 ExitStatus::Trap(msg) => format!("> **result (trap: {msg}):**"),
61 };
62 let payload = if o.stdout.is_empty() && !o.stderr.is_empty() {
63 o.stderr.trim_end()
66 } else {
67 o.stdout.trim_end()
68 };
69
70 if payload.is_empty() {
71 format!("{header} `(no output)`")
72 } else if payload.contains('\n') {
73 format!("{header}\n```\n{payload}\n```")
74 } else {
75 format!("{header} `{payload}`")
76 }
77 }
78 }
79}
80
81pub fn upsert_result_child(parent: &mut OutlineNode, body: String) {
92 if let Some(idx) = parent.children.iter().position(is_result_block) {
93 parent.children[idx].text = body;
94 } else {
95 parent.children.push(OutlineNode {
96 text: body,
97 properties: Vec::new(),
98 children: Vec::new(),
99 });
100 }
101}
102
103pub fn upsert_result_embeds(parent: &mut OutlineNode, header: String, lines: &[&str]) {
110 let embed_children: Vec<OutlineNode> = lines
111 .iter()
112 .map(|line| OutlineNode {
113 text: line.to_string(),
114 properties: Vec::new(),
115 children: Vec::new(),
116 })
117 .collect();
118
119 if let Some(idx) = parent.children.iter().position(is_result_block) {
120 parent.children[idx].text = header;
121 parent.children[idx].children = embed_children;
122 } else {
123 parent.children.push(OutlineNode {
124 text: header,
125 properties: Vec::new(),
126 children: embed_children,
127 });
128 }
129}
130
131pub fn upsert_result_child_with_hash(parent: &mut OutlineNode, body: String, source_hash: &str) {
136 let idx = match parent.children.iter().position(is_result_block) {
137 Some(i) => {
138 parent.children[i].text = body;
139 i
140 }
141 None => {
142 parent.children.push(OutlineNode {
143 text: body,
144 properties: Vec::new(),
145 children: Vec::new(),
146 });
147 parent.children.len() - 1
148 }
149 };
150 let props = &mut parent.children[idx].properties;
151 if let Some(p) = props.iter_mut().find(|(k, _)| k == SOURCE_HASH_KEY) {
152 p.1 = source_hash.to_string();
153 } else {
154 props.push((SOURCE_HASH_KEY.to_string(), source_hash.to_string()));
155 }
156}
157
158pub fn result_source_hash(parent: &OutlineNode) -> Option<&str> {
162 let result = parent.children.iter().find(|c| is_result_block(c))?;
163 result
164 .properties
165 .iter()
166 .find(|(k, _)| k == SOURCE_HASH_KEY)
167 .map(|(_, v)| v.as_str())
168}
169
170fn is_result_block(node: &OutlineNode) -> bool {
171 node.text
172 .lines()
173 .next()
174 .map(|first| first.trim_start().starts_with(RESULT_MARKER))
175 .unwrap_or(false)
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::runtime::OutputFormat;
182 use std::time::Duration;
183
184 fn ok_output(stdout: &str) -> ExecOutput {
185 ExecOutput {
186 stdout: stdout.to_string(),
187 stderr: String::new(),
188 duration: Duration::from_millis(1),
189 exit: ExitStatus::Ok,
190 format: OutputFormat::Text,
191 }
192 }
193
194 #[test]
195 fn single_line_output_uses_inline_backticks() {
196 let body = render_result_body(Ok(&ok_output("3")));
197 assert_eq!(body, "> **result:** `3`");
198 }
199
200 #[test]
201 fn multi_line_output_uses_fenced_block() {
202 let body = render_result_body(Ok(&ok_output("a\nb\nc")));
203 assert_eq!(body, "> **result:**\n```\na\nb\nc\n```");
204 }
205
206 #[test]
207 fn empty_output_shows_placeholder() {
208 let body = render_result_body(Ok(&ok_output("")));
209 assert_eq!(body, "> **result:** `(no output)`");
210 }
211
212 #[test]
213 fn non_zero_exit_marks_header() {
214 let out = ExecOutput {
215 stdout: "boom".into(),
216 stderr: String::new(),
217 duration: Duration::from_millis(1),
218 exit: ExitStatus::NonZero(2),
219 format: OutputFormat::Text,
220 };
221 let body = render_result_body(Ok(&out));
222 assert!(body.starts_with("> **result (exit 2):**"));
223 }
224
225 #[test]
226 fn trap_marks_header_with_message() {
227 let out = ExecOutput {
228 stdout: String::new(),
229 stderr: "divide by zero".into(),
230 duration: Duration::from_millis(1),
231 exit: ExitStatus::Trap("div-by-zero".into()),
232 format: OutputFormat::Text,
233 };
234 let body = render_result_body(Ok(&out));
235 assert!(body.starts_with("> **result (trap: div-by-zero):**"));
236 assert!(body.contains("divide by zero"));
237 }
238
239 #[test]
240 fn error_path_renders_message() {
241 let err = ExecError::Timeout(Duration::from_secs(2));
242 let body = render_result_body(Err(&err));
243 assert!(body.starts_with("> **result:** `error:"));
244 assert!(body.contains("timed out"));
245 }
246
247 #[test]
248 fn upsert_creates_child_when_absent() {
249 let mut parent = OutlineNode {
250 text: "```lisp\n(+ 1 2)\n```".into(),
251 properties: Vec::new(),
252 children: Vec::new(),
253 };
254 upsert_result_child(&mut parent, "> **result:** `3`".into());
255 assert_eq!(parent.children.len(), 1);
256 assert_eq!(parent.children[0].text, "> **result:** `3`");
257 }
258
259 #[test]
260 fn upsert_replaces_existing_result_child_in_place() {
261 let mut parent = OutlineNode {
262 text: "```lisp\n(+ 1 2)\n```".into(),
263 properties: Vec::new(),
264 children: vec![OutlineNode {
265 text: "> **result:** `old`".into(),
266 properties: Vec::new(),
267 children: Vec::new(),
268 }],
269 };
270 upsert_result_child(&mut parent, "> **result:** `new`".into());
271 assert_eq!(parent.children.len(), 1, "must not create a second child");
272 assert_eq!(parent.children[0].text, "> **result:** `new`");
273 }
274
275 #[test]
276 fn upsert_ignores_non_result_children() {
277 let mut parent = OutlineNode {
278 text: "code".into(),
279 properties: Vec::new(),
280 children: vec![OutlineNode {
281 text: "some unrelated note".into(),
282 properties: Vec::new(),
283 children: Vec::new(),
284 }],
285 };
286 upsert_result_child(&mut parent, "> **result:** `42`".into());
287 assert_eq!(parent.children.len(), 2);
288 assert_eq!(parent.children[0].text, "some unrelated note");
290 assert_eq!(parent.children[1].text, "> **result:** `42`");
291 }
292
293 #[test]
294 fn source_hash_is_stable() {
295 let a = source_hash("(+ 1 2)");
296 let b = source_hash("(+ 1 2)");
297 assert_eq!(a, b);
298 assert!(a.starts_with("sha256:"));
299 }
300
301 #[test]
302 fn source_hash_differs_on_whitespace() {
303 assert_ne!(source_hash("a b"), source_hash("a b"));
305 assert_ne!(source_hash("a\nb"), source_hash("a b"));
306 }
307
308 #[test]
309 fn upsert_with_hash_stamps_property_on_existing_child() {
310 let mut parent = OutlineNode {
311 text: "code".into(),
312 properties: Vec::new(),
313 children: vec![OutlineNode {
314 text: "> **result:** `old`".into(),
315 properties: Vec::new(),
316 children: Vec::new(),
317 }],
318 };
319 upsert_result_child_with_hash(&mut parent, "> **result:** `3`".into(), "sha256:deadbeef");
320 assert_eq!(parent.children.len(), 1);
321 assert_eq!(parent.children[0].text, "> **result:** `3`");
322 assert_eq!(result_source_hash(&parent), Some("sha256:deadbeef"));
323 }
324
325 #[test]
326 fn upsert_with_hash_creates_child_when_absent() {
327 let mut parent = OutlineNode {
328 text: "code".into(),
329 properties: Vec::new(),
330 children: Vec::new(),
331 };
332 upsert_result_child_with_hash(&mut parent, "> **result:** `5`".into(), "sha256:abc");
333 assert_eq!(parent.children.len(), 1);
334 assert_eq!(result_source_hash(&parent), Some("sha256:abc"));
335 }
336
337 #[test]
338 fn result_source_hash_returns_none_when_unstamped() {
339 let parent = OutlineNode {
340 text: "code".into(),
341 properties: Vec::new(),
342 children: vec![OutlineNode {
343 text: "> **result:** `legacy`".into(),
344 properties: Vec::new(),
345 children: Vec::new(),
346 }],
347 };
348 assert_eq!(result_source_hash(&parent), None);
351 }
352
353 #[test]
354 fn upsert_embeds_creates_result_with_children_when_absent() {
355 let mut parent = OutlineNode {
356 text: "```query\nstatus: todo\n```".into(),
357 properties: Vec::new(),
358 children: Vec::new(),
359 };
360 upsert_result_embeds(
361 &mut parent,
362 "> **result:** (2 blocks)".into(),
363 &["!((blk-aaa))", "!((blk-bbb))"],
364 );
365 assert_eq!(parent.children.len(), 1);
366 assert_eq!(parent.children[0].text, "> **result:** (2 blocks)");
367 assert_eq!(parent.children[0].children.len(), 2);
368 assert_eq!(parent.children[0].children[0].text, "!((blk-aaa))");
369 assert_eq!(parent.children[0].children[1].text, "!((blk-bbb))");
370 }
371
372 #[test]
373 fn upsert_embeds_replaces_existing_result_in_place() {
374 let mut parent = OutlineNode {
375 text: "```query\nstatus: todo\n```".into(),
376 properties: Vec::new(),
377 children: vec![OutlineNode {
378 text: "> **result:** (1 blocks)".into(),
379 properties: Vec::new(),
380 children: vec![OutlineNode {
381 text: "!((blk-old))".into(),
382 properties: Vec::new(),
383 children: Vec::new(),
384 }],
385 }],
386 };
387 upsert_result_embeds(
388 &mut parent,
389 "> **result:** (3 blocks)".into(),
390 &["!((blk-a))", "!((blk-b))", "!((blk-c))"],
391 );
392 assert_eq!(parent.children.len(), 1, "must not create a second child");
393 assert_eq!(parent.children[0].text, "> **result:** (3 blocks)");
394 assert_eq!(parent.children[0].children.len(), 3);
395 }
396}