Skip to main content

outl_exec/
orchestrate.rs

1//! End-to-end "run this block": single function every UI calls.
2//!
3//! Flow:
4//!
5//! 1. Parse the page (`.md` on disk → AST).
6//! 2. Walk the AST to find the block at `flat_index` (DFS preorder).
7//! 3. Extract `(language, body)` from its fence text.
8//! 4. Look up the runtime, call `execute`.
9//! 5. Render the output as a `> **result:**` subblock.
10//! 6. Upsert that subblock under the code block.
11//! 7. Re-render the AST back to `.md`, atomic-write, reconcile into
12//!    the op log.
13//!
14//! The function is sync today — runtimes are sync, the I/O is sync,
15//! the TUI calls it from its event loop. When we add long-running
16//! runtimes (compile-then-run Rust, streaming output) the boundary
17//! becomes an async wrapper; the orchestration stays the same.
18
19use std::path::Path;
20use std::time::Duration;
21
22use outl_core::hlc::HlcGenerator;
23use outl_core::workspace::Workspace;
24use outl_md::parse::{parse, OutlineNode};
25use outl_md::reconcile::reconcile_md;
26use outl_md::render::render;
27
28use crate::language::extract_fence;
29use crate::registry::RuntimeRegistry;
30use crate::result_block::{
31    render_result_body, result_source_hash, source_hash, upsert_result_child,
32    upsert_result_child_with_hash, upsert_result_embeds, RESULT_MARKER,
33};
34use crate::runtime::{ExecContext, ExecError, ExecOutput, OutputFormat};
35
36/// Default per-run timeout. UIs can override by building an
37/// [`ExecContext`] manually and going around this helper.
38/// Wall-clock budget for a single fence execution.
39///
40/// iOS gets a tighter 2-second budget: the UI is fully blocked during
41/// execution (sync call from the Tauri command) and a longer wait
42/// makes the app feel hung on a touch device. The narrative also
43/// helps with App Review — a bounded, sub-second-typical timeout is
44/// easier to defend under Guideline 2.5.2 than "user can queue
45/// arbitrary workloads". Desktop / TUI keep 5s where the user has
46/// keyboard interrupt and a real terminal mental model.
47#[cfg(target_os = "ios")]
48pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
49/// Wall-clock budget for a single fence execution (non-iOS targets).
50///
51/// See the iOS-specific entry above for the rationale on the
52/// per-platform split.
53#[cfg(not(target_os = "ios"))]
54pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
55
56/// Errors that can happen *around* execution — before we ever reach
57/// the runtime, or while persisting its output.
58#[derive(Debug, thiserror::Error)]
59pub enum RunError {
60    /// `flat_index` doesn't point at any block in the page.
61    #[error("no block at flat index {0}")]
62    BlockNotFound(usize),
63    /// The block exists but its text isn't a fenced code block.
64    #[error("block is not a fenced code block")]
65    NotACodeBlock,
66    /// Fence has no language tag (` ``` ` with nothing after).
67    #[error("code block has no language tag (e.g. ```lisp)")]
68    MissingLanguage,
69    /// No runtime registered for the requested language.
70    #[error("no runtime registered for language `{0}`")]
71    UnknownLanguage(String),
72    /// Failed reading the `.md` from disk.
73    #[error("read {path}: {source}")]
74    Read {
75        /// Path we tried to read.
76        path: String,
77        /// Underlying I/O error.
78        #[source]
79        source: std::io::Error,
80    },
81    /// Failed writing the `.md` back.
82    #[error("write {path}: {source}")]
83    Write {
84        /// Path we tried to write.
85        path: String,
86        /// Underlying I/O error.
87        #[source]
88        source: std::io::Error,
89    },
90    /// Reconciling the new AST into the op log failed.
91    #[error("reconcile: {0}")]
92    Reconcile(#[from] outl_md::reconcile::ReconcileError),
93}
94
95/// What a successful run hands back to the caller (the UI). Enough to
96/// show a status-line message without re-reading the page.
97///
98/// Not `Clone` — `ExecError::Io` wraps `std::io::Error` which itself is
99/// not `Clone`. UIs consume the report once.
100#[derive(Debug)]
101pub struct RunReport {
102    /// Language tag that was executed.
103    pub language: String,
104    /// Outcome of the run — `Ok` when the runtime returned (even with
105    /// non-zero exit), `Err` for infrastructure failures.
106    pub result: Result<ExecOutput, ExecError>,
107}
108
109/// Run the code block at `flat_index` inside `md_path` through the
110/// registry's matching runtime.
111///
112/// `flat_index` is the DFS-preorder position of the block in the page.
113/// That's what TUI selection already tracks (`App.selected`) and what
114/// `path_for_index` returns — same coordinate system.
115pub fn run_block_at_index(
116    workspace: &mut Workspace,
117    hlc: &HlcGenerator,
118    md_path: &Path,
119    flat_index: usize,
120    registry: &RuntimeRegistry,
121    orphans_log: Option<&Path>,
122) -> Result<RunReport, RunError> {
123    // 1. Load and parse.
124    let text = std::fs::read_to_string(md_path).map_err(|source| RunError::Read {
125        path: md_path.display().to_string(),
126        source,
127    })?;
128    let mut page = parse(&text);
129
130    // 2. Find the block.
131    let block = block_at_flat_index_mut(&mut page.blocks, flat_index)
132        .ok_or(RunError::BlockNotFound(flat_index))?;
133
134    // 3. Pull (language, body) out.
135    let parts = extract_fence(&block.text).ok_or(RunError::NotACodeBlock)?;
136    if parts.language.is_empty() {
137        return Err(RunError::MissingLanguage);
138    }
139    let language = parts.language.clone();
140    let body = parts.body;
141
142    // 4. Look up the runtime.
143    let runtime = registry
144        .get(&language)
145        .ok_or_else(|| RunError::UnknownLanguage(language.clone()))?;
146
147    // 5. Execute.
148    let ctx = ExecContext {
149        workspace_root: workspace
150            .root
151            .clone()
152            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()),
153        stdin: None,
154        timeout: DEFAULT_TIMEOUT,
155        mem_limit: None,
156    };
157    let result = runtime.execute(&body, &ctx);
158
159    // 6. Render result, upsert (without source hash — manual `gx` is
160    // always meant to refresh).
161    match result.as_ref() {
162        Ok(o) if o.format == OutputFormat::Embeds => {
163            let embeds: Vec<&str> = o.stdout.lines().filter(|l| !l.is_empty()).collect();
164            let header = format!("{RESULT_MARKER} ({} blocks)", embeds.len());
165            upsert_result_embeds(block, header, &embeds);
166        }
167        _ => {
168            let body = render_result_body(result.as_ref());
169            upsert_result_child(block, body);
170        }
171    }
172
173    // 7. Persist + reconcile.
174    let rendered = render(&page);
175    outl_md::write_atomic(md_path, rendered.as_bytes()).map_err(|source| RunError::Write {
176        path: md_path.display().to_string(),
177        source,
178    })?;
179    reconcile_md(workspace, hlc, md_path, orphans_log)?;
180
181    Ok(RunReport { language, result })
182}
183
184/// Cache-aware variant of [`run_block_at_index`].
185///
186/// Used by the TUI's auto-run loop: a block with `auto-run::` set
187/// runs **only when its source has changed** since the last execution.
188/// "Changed" is decided by comparing SHA-256 of the fence body against
189/// the `source-hash::` property stamped on the result subblock.
190///
191/// Returns:
192/// - `Ok(Some(report))` — the block ran. Caller can update status.
193/// - `Ok(None)` — cache hit, nothing happened.
194/// - `Err(_)` — orchestration failure (same surface as `run_block_at_index`).
195pub fn run_block_at_index_if_source_changed(
196    workspace: &mut Workspace,
197    hlc: &HlcGenerator,
198    md_path: &Path,
199    flat_index: usize,
200    registry: &RuntimeRegistry,
201    orphans_log: Option<&Path>,
202) -> Result<Option<RunReport>, RunError> {
203    let text = std::fs::read_to_string(md_path).map_err(|source| RunError::Read {
204        path: md_path.display().to_string(),
205        source,
206    })?;
207    let mut page = parse(&text);
208
209    let block = block_at_flat_index_mut(&mut page.blocks, flat_index)
210        .ok_or(RunError::BlockNotFound(flat_index))?;
211    let parts = extract_fence(&block.text).ok_or(RunError::NotACodeBlock)?;
212    if parts.language.is_empty() {
213        return Err(RunError::MissingLanguage);
214    }
215    let language = parts.language.clone();
216    let body = parts.body;
217    let want_hash = source_hash(&body);
218
219    // Cache check: if the result subblock already records this exact
220    // source hash, there's nothing to do.
221    if result_source_hash(block)
222        .map(|s| s == want_hash)
223        .unwrap_or(false)
224    {
225        return Ok(None);
226    }
227
228    let runtime = registry
229        .get(&language)
230        .ok_or_else(|| RunError::UnknownLanguage(language.clone()))?;
231    let ctx = ExecContext {
232        workspace_root: workspace
233            .root
234            .clone()
235            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()),
236        stdin: None,
237        timeout: DEFAULT_TIMEOUT,
238        mem_limit: None,
239    };
240    let result = runtime.execute(&body, &ctx);
241
242    match result.as_ref() {
243        Ok(o) if o.format == OutputFormat::Embeds => {
244            let embeds: Vec<&str> = o.stdout.lines().filter(|l| !l.is_empty()).collect();
245            let header = format!("{RESULT_MARKER} ({} blocks)", embeds.len());
246            upsert_result_embeds(block, header, &embeds);
247        }
248        _ => {
249            let body_md = render_result_body(result.as_ref());
250            upsert_result_child_with_hash(block, body_md, &want_hash);
251        }
252    }
253
254    let rendered = render(&page);
255    outl_md::write_atomic(md_path, rendered.as_bytes()).map_err(|source| RunError::Write {
256        path: md_path.display().to_string(),
257        source,
258    })?;
259    reconcile_md(workspace, hlc, md_path, orphans_log)?;
260
261    Ok(Some(RunReport { language, result }))
262}
263
264/// DFS-preorder traversal returning the block at `target` flat index.
265///
266/// Lives here (small and private) so the crate doesn't need to depend
267/// on `outl-tui::outline_ops` — keeps the dep graph one-way.
268fn block_at_flat_index_mut(blocks: &mut [OutlineNode], target: usize) -> Option<&mut OutlineNode> {
269    fn walk<'a>(
270        nodes: &'a mut [OutlineNode],
271        target: usize,
272        counter: &mut usize,
273    ) -> Option<&'a mut OutlineNode> {
274        for node in nodes {
275            if *counter == target {
276                return Some(node);
277            }
278            *counter += 1;
279            if let Some(hit) = walk(&mut node.children, target, counter) {
280                return Some(hit);
281            }
282        }
283        None
284    }
285    walk(blocks, target, &mut 0)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use outl_md::parse::ParsedPage;
292
293    fn page_with_blocks(blocks: Vec<OutlineNode>) -> ParsedPage {
294        ParsedPage {
295            properties: Vec::new(),
296            blocks,
297            warnings: Vec::new(),
298        }
299    }
300
301    fn leaf(text: &str) -> OutlineNode {
302        OutlineNode {
303            text: text.into(),
304            properties: Vec::new(),
305            children: Vec::new(),
306        }
307    }
308
309    #[test]
310    fn flat_index_zero_returns_first_block() {
311        let mut p = page_with_blocks(vec![leaf("a"), leaf("b")]);
312        let n = block_at_flat_index_mut(&mut p.blocks, 0).unwrap();
313        assert_eq!(n.text, "a");
314    }
315
316    #[test]
317    fn flat_index_descends_into_children() {
318        // Tree:
319        //   a (0)
320        //     a1 (1)
321        //     a2 (2)
322        //   b (3)
323        let mut p = page_with_blocks(vec![
324            OutlineNode {
325                text: "a".into(),
326                properties: vec![],
327                children: vec![leaf("a1"), leaf("a2")],
328            },
329            leaf("b"),
330        ]);
331        assert_eq!(
332            block_at_flat_index_mut(&mut p.blocks, 1).unwrap().text,
333            "a1"
334        );
335        assert_eq!(
336            block_at_flat_index_mut(&mut p.blocks, 2).unwrap().text,
337            "a2"
338        );
339        assert_eq!(block_at_flat_index_mut(&mut p.blocks, 3).unwrap().text, "b");
340    }
341
342    #[test]
343    fn flat_index_past_end_returns_none() {
344        let mut p = page_with_blocks(vec![leaf("a")]);
345        assert!(block_at_flat_index_mut(&mut p.blocks, 99).is_none());
346    }
347}