1use 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#[cfg(target_os = "ios")]
48pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
49#[cfg(not(target_os = "ios"))]
54pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
55
56#[derive(Debug, thiserror::Error)]
59pub enum RunError {
60 #[error("no block at flat index {0}")]
62 BlockNotFound(usize),
63 #[error("block is not a fenced code block")]
65 NotACodeBlock,
66 #[error("code block has no language tag (e.g. ```lisp)")]
68 MissingLanguage,
69 #[error("no runtime registered for language `{0}`")]
71 UnknownLanguage(String),
72 #[error("read {path}: {source}")]
74 Read {
75 path: String,
77 #[source]
79 source: std::io::Error,
80 },
81 #[error("write {path}: {source}")]
83 Write {
84 path: String,
86 #[source]
88 source: std::io::Error,
89 },
90 #[error("reconcile: {0}")]
92 Reconcile(#[from] outl_md::reconcile::ReconcileError),
93}
94
95#[derive(Debug)]
101pub struct RunReport {
102 pub language: String,
104 pub result: Result<ExecOutput, ExecError>,
107}
108
109pub 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 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 let block = block_at_flat_index_mut(&mut page.blocks, flat_index)
132 .ok_or(RunError::BlockNotFound(flat_index))?;
133
134 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 let runtime = registry
144 .get(&language)
145 .ok_or_else(|| RunError::UnknownLanguage(language.clone()))?;
146
147 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 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 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
184pub 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 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
264fn 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 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}