Skip to main content

outl_exec/
result_block.rs

1//! Build and place the `> **result:**` subblock under a code block.
2//!
3//! Pure functions on [`OutlineNode`] — no parsing, no I/O. Persisting
4//! the mutated AST is the caller's job (see [`crate::orchestrate`]).
5//!
6//! The result block is a *child* of the code block, identified by a
7//! literal `> **result:**` marker on its first line. Single-line
8//! outputs become an inline code span; multi-line outputs use a fenced
9//! code block so newlines render cleanly in any markdown viewer.
10
11use outl_md::parse::OutlineNode;
12use sha2::{Digest, Sha256};
13
14use crate::runtime::{ExecError, ExecOutput, ExitStatus};
15
16/// Marker that identifies a result block. The very first non-whitespace
17/// text on the line must equal this.
18pub const RESULT_MARKER: &str = "> **result:**";
19
20/// Property key written into the result subblock so we can detect
21/// whether the parent's source has changed since the last run.
22/// Used by [`crate::orchestrate::run_block_at_index_if_source_changed`]
23/// and by the TUI's auto-run loop.
24pub const SOURCE_HASH_KEY: &str = "source-hash";
25
26/// SHA-256 of `source`, formatted `sha256:<hex>`. Same shape as
27/// [`outl_md::sidecar::content_hash`] but without whitespace
28/// normalisation — for code, whitespace IS the semantics.
29pub 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
35/// Tiny hex encoder so we don't pull in `hex` for one call site.
36fn 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
44/// Render an [`ExecOutput`] (or a top-level [`ExecError`]) as the
45/// markdown body of a result subblock.
46///
47/// Format:
48///
49/// - Single-line stdout → `> **result:** \`output\``
50/// - Multi-line stdout  → `> **result:**\n```\noutput\n````
51/// - Non-zero / trap    → `> **result (exit N):** ...` or `> **result (trap):** ...`
52/// - Infrastructure err → `> **result (error):** \`message\``
53pub 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                // Nothing on stdout but the script wrote to stderr —
64                // show that instead so the user sees the message.
65                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
81/// Insert or replace the result child under `parent`.
82///
83/// If `parent` already has a child whose text starts with
84/// [`RESULT_MARKER`], its text is overwritten in place (preserving
85/// position, properties, descendant children). Otherwise a fresh child
86/// is appended at the end of `parent.children`.
87///
88/// The "in place" replace is what makes re-runs idempotent: the
89/// reconcile pass downstream sees one block edit, not a delete +
90/// create, so the block keeps the same ID in the sidecar.
91pub 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
103/// Insert or replace the result child under `parent` with embed children.
104///
105/// Used by runtimes that return [`crate::runtime::OutputFormat::Embeds`]: each line of
106/// stdout becomes a child bullet under the result header. Lines are
107/// expected to be embed references (`!((blk-…))`) so the displayed
108/// result is a **live view** of the original blocks, not a copy.
109pub 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
131/// Same as [`upsert_result_child`] but also stamps the result subblock
132/// with a `source-hash::` property. The auto-run loop reads that
133/// property to short-circuit re-runs when the parent source hasn't
134/// changed.
135pub 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
158/// Read the `source-hash::` recorded in the result subblock under
159/// `parent`, if any. Returns `None` when no result subblock exists or
160/// when it was written without the hash (pre-auto-run runs).
161pub 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        // Unrelated note untouched, new result appended at end.
289        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        // Code is whitespace-sensitive (Python!) — don't collapse.
304        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        // Legacy result subblock (written before the hash logic
349        // shipped) should look like "never ran" so it gets refreshed.
350        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}