Skip to main content

sim_lib_server/
cookbook_web.rs

1use sim_cookbook::{EmbeddedDir, RecipeCard, RecipeStore, next, ordered_cards, view};
2use sim_kernel::{Cx, Result};
3use sim_lib_cookbook::{
4    LifecycleAction, LoadableLibList, SeededLibCatalog, projected_recipe_store,
5    run_lifecycle_action, run_recipe_with_loadable_libs,
6};
7
8use crate::cookbook_web_json::{
9    render_error_json, render_index_json, render_recipe_json, render_run_json, render_search_json,
10};
11
12const JSON: &str = "application/json; charset=utf-8";
13const HTML: &str = "text/html; charset=utf-8";
14
15/// Cookbook state exposed by the WebUI route and JSON API.
16pub struct CookbookWebState {
17    directory: LoadableLibList,
18    diagnostics: Vec<String>,
19    extra_books: Vec<EmbeddedDir>,
20}
21
22impl CookbookWebState {
23    /// Build a WebUI state from the crate-shipped loadable-lib directory.
24    pub fn seeded() -> Result<Self> {
25        Ok(Self::from_loadable_libs(
26            SeededLibCatalog::loadable_libs(),
27            Vec::new(),
28        ))
29    }
30
31    /// Build a WebUI state from the seeded directory plus host-injected books.
32    ///
33    /// Host-injected books cover facade-level descriptor recipes that are above
34    /// `sim-lib-cookbook` in the dependency graph. They are projected on each
35    /// request after the dynamic loadable-lib directory reflects the live `Cx`.
36    pub fn seeded_with_books(extra: &[(&str, EmbeddedDir)]) -> Result<Self> {
37        let mut state = Self::seeded()?;
38        state.extra_books = extra.iter().map(|(_, recipes)| *recipes).collect();
39        Ok(state)
40    }
41
42    /// Build a WebUI state from an effective loadable-lib directory.
43    pub fn from_loadable_libs(directory: LoadableLibList, diagnostics: Vec<String>) -> Self {
44        Self {
45            directory,
46            diagnostics,
47            extra_books: Vec::new(),
48        }
49    }
50
51    /// Build an empty WebUI state for tests and no-recipes deployments.
52    pub fn empty() -> Self {
53        Self::from_loadable_libs(LoadableLibList::new(Vec::new()), Vec::new())
54    }
55
56    /// Project a fresh cookbook store from the current runtime context.
57    pub fn store(&self, cx: &Cx) -> Result<RecipeStore> {
58        let mut store = projected_recipe_store(cx, &self.directory)?;
59        for recipes in &self.extra_books {
60            let _ = store.register_book(recipes);
61        }
62        Ok(store)
63    }
64
65    /// Route one cookbook WebUI request.
66    ///
67    /// `target` is an HTTP request target such as `/api/cookbook?q=x`. The run
68    /// endpoint requires a runtime context because it executes the same
69    /// `sim-lib-cookbook` recipe runner used by the runtime ops and CLI.
70    pub fn handle_request(
71        &self,
72        method: &str,
73        target: &str,
74        cx: Option<&mut Cx>,
75    ) -> CookbookWebResponse {
76        let (path, query) = split_target(target);
77        match (method, path) {
78            ("GET", "/cookbook") => {
79                let Some(cx) = cx else {
80                    return CookbookWebResponse::server_error(
81                        "cookbook page requires a runtime context",
82                    );
83                };
84                let store = match self.store(cx) {
85                    Ok(store) => store,
86                    Err(err) => return CookbookWebResponse::server_error(err.to_string()),
87                };
88                let selected = query.and_then(|q| query_value(q, "recipe"));
89                CookbookWebResponse::html(render_page(&store, selected.as_deref()))
90            }
91            ("GET", "/api/cookbook") => {
92                let Some(cx) = cx else {
93                    return CookbookWebResponse::server_error(
94                        "cookbook index requires a runtime context",
95                    );
96                };
97                match self.store(cx) {
98                    Ok(store) => {
99                        CookbookWebResponse::json(render_index_json(cx, &store, &self.diagnostics))
100                    }
101                    Err(err) => CookbookWebResponse::server_error(err.to_string()),
102                }
103            }
104            ("GET", "/api/cookbook/search") => {
105                let Some(cx) = cx else {
106                    return CookbookWebResponse::server_error(
107                        "cookbook search requires a runtime context",
108                    );
109                };
110                let q = query
111                    .and_then(|query| query_value(query, "q"))
112                    .unwrap_or_default();
113                match self.store(cx) {
114                    Ok(store) => CookbookWebResponse::json(render_search_json(cx, &store, &q)),
115                    Err(err) => CookbookWebResponse::server_error(err.to_string()),
116                }
117            }
118            ("POST", path)
119                if path.starts_with("/api/cookbook/recipe/") && path.ends_with("/run") =>
120            {
121                let start = "/api/cookbook/recipe/".len();
122                let end = path.len() - "/run".len();
123                let raw_id = &path[start..end];
124                let Some(cx) = cx else {
125                    return CookbookWebResponse::server_error(
126                        "cookbook run endpoint requires a runtime context",
127                    );
128                };
129                let store = match self.store(cx) {
130                    Ok(store) => store,
131                    Err(err) => return CookbookWebResponse::server_error(err.to_string()),
132                };
133                let id = match decode_component(raw_id, false) {
134                    Ok(id) => id,
135                    Err(err) => return response_for_resolve_error(err),
136                };
137                let card = match resolve_recipe(&store, &id) {
138                    Ok(card) => card.clone(),
139                    Err(err) => {
140                        if let Some((action, lib)) = stale_lifecycle_id(&self.directory, &id) {
141                            let run = run_lifecycle_action(cx, &self.directory, action, lib, &id);
142                            return CookbookWebResponse::json(render_run_json(&run));
143                        }
144                        return response_for_resolve_error(err);
145                    }
146                };
147                match run_recipe_with_loadable_libs(cx, &self.directory, &card) {
148                    Ok(run) => CookbookWebResponse::json(render_run_json(&run)),
149                    Err(err) => CookbookWebResponse::server_error(err.to_string()),
150                }
151            }
152            (_, path) if path.starts_with("/api/cookbook/recipe/") && path.ends_with("/run") => {
153                CookbookWebResponse::method_not_allowed()
154            }
155            ("GET", path) if path.starts_with("/api/cookbook/recipe/") => {
156                let Some(cx) = cx else {
157                    return CookbookWebResponse::server_error(
158                        "cookbook detail requires a runtime context",
159                    );
160                };
161                let store = match self.store(cx) {
162                    Ok(store) => store,
163                    Err(err) => return CookbookWebResponse::server_error(err.to_string()),
164                };
165                let raw_id = &path["/api/cookbook/recipe/".len()..];
166                match decode_component(raw_id, false).and_then(|id| resolve_recipe(&store, &id)) {
167                    Ok(card) => CookbookWebResponse::json(render_recipe_json(cx, &store, card)),
168                    Err(err) => response_for_resolve_error(err),
169                }
170            }
171            (_, "/api/cookbook") | (_, "/api/cookbook/search") | (_, "/cookbook") => {
172                CookbookWebResponse::method_not_allowed()
173            }
174            (_, path) if path.starts_with("/api/cookbook/recipe/") => {
175                CookbookWebResponse::method_not_allowed()
176            }
177            _ => CookbookWebResponse::not_found("unknown cookbook route"),
178        }
179    }
180}
181
182/// Minimal HTTP response model for cookbook route adapters.
183#[derive(Clone, Debug, PartialEq, Eq)]
184pub struct CookbookWebResponse {
185    /// HTTP status code.
186    pub status: u16,
187    /// Response `Content-Type`.
188    pub content_type: &'static str,
189    /// UTF-8 response body.
190    pub body: String,
191}
192
193impl CookbookWebResponse {
194    fn html(body: String) -> Self {
195        Self {
196            status: 200,
197            content_type: HTML,
198            body,
199        }
200    }
201
202    fn json(body: String) -> Self {
203        Self {
204            status: 200,
205            content_type: JSON,
206            body,
207        }
208    }
209
210    fn not_found(message: impl AsRef<str>) -> Self {
211        Self::error(404, message)
212    }
213
214    fn method_not_allowed() -> Self {
215        Self::error(405, "method not allowed")
216    }
217
218    fn server_error(message: impl AsRef<str>) -> Self {
219        Self::error(500, message)
220    }
221
222    fn error(status: u16, message: impl AsRef<str>) -> Self {
223        Self {
224            status,
225            content_type: JSON,
226            body: render_error_json(message.as_ref()),
227        }
228    }
229}
230
231#[derive(Debug, PartialEq, Eq)]
232enum ResolveError {
233    Unknown(String),
234    Ambiguous(String),
235    BadRequest(String),
236}
237
238fn response_for_resolve_error(err: ResolveError) -> CookbookWebResponse {
239    match err {
240        ResolveError::Unknown(message) => CookbookWebResponse::not_found(message),
241        ResolveError::Ambiguous(message) | ResolveError::BadRequest(message) => {
242            CookbookWebResponse::error(400, message)
243        }
244    }
245}
246
247fn stale_lifecycle_id<'a>(
248    directory: &'a LoadableLibList,
249    id: &str,
250) -> Option<(LifecycleAction, &'a str)> {
251    let (action, lib) = if let Some(lib) = id.strip_prefix("cookbook/load/") {
252        (LifecycleAction::Load, lib)
253    } else if let Some(lib) = id.strip_suffix("/cookbook-lifecycle/unload") {
254        (LifecycleAction::Unload, lib)
255    } else {
256        return None;
257    };
258    directory
259        .entry(lib)
260        .map(|entry| (action, entry.id.as_str()))
261}
262
263fn resolve_recipe<'a>(
264    store: &'a RecipeStore,
265    id: &str,
266) -> std::result::Result<&'a RecipeCard, ResolveError> {
267    if id.trim().is_empty() {
268        return Err(ResolveError::BadRequest(
269            "cookbook recipe id must not be empty".to_owned(),
270        ));
271    }
272    if let Some(card) = store.card(id) {
273        return Ok(card);
274    }
275    let suffix = format!("/{id}");
276    let candidates: Vec<&RecipeCard> = ordered_cards(store)
277        .into_iter()
278        .filter(|card| card.id.ends_with(&suffix))
279        .collect();
280    match candidates.as_slice() {
281        [card] => Ok(*card),
282        [] => Err(ResolveError::Unknown(format!("unknown recipe {id}"))),
283        many => Err(ResolveError::Ambiguous(format!(
284            "ambiguous recipe {id}: {}",
285            many.iter()
286                .map(|card| card.id.as_str())
287                .collect::<Vec<_>>()
288                .join(", ")
289        ))),
290    }
291}
292
293fn render_page(store: &RecipeStore, selected: Option<&str>) -> String {
294    let selected_card = selected
295        .and_then(|id| resolve_recipe(store, id).ok())
296        .or_else(|| ordered_cards(store).first().copied());
297    let selected_id = selected_card.map(|card| card.id.as_str());
298    let mut out = String::from(
299        r#"<!DOCTYPE html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>SIM Cookbook</title><link rel="stylesheet" href="/styles/theme.css" /></head><body><nav class="top-nav"><a href="/">Shell</a><a href="/cookbook" aria-current="page">Cookbook</a></nav><main class="cookbook-shell">"#,
300    );
301    out.push_str(r#"<aside class="cookbook-rail"><input type="search" aria-label="Search recipes" placeholder="Search" /><nav class="cookbook-tree">"#);
302    if store.is_empty() {
303        out.push_str(r#"<p class="empty-state">No recipes loaded.</p>"#);
304    } else {
305        render_tree_html(&mut out, store, selected_id);
306    }
307    out.push_str(r#"</nav></aside><section class="cookbook-main">"#);
308    match selected_card {
309        Some(card) => render_recipe_html(&mut out, store, card),
310        None => out.push_str(r#"<h1>Cookbook</h1><p class="empty-state">No recipes loaded.</p>"#),
311    }
312    out.push_str("</section></main></body></html>");
313    out
314}
315
316fn render_tree_html(out: &mut String, store: &RecipeStore, selected: Option<&str>) {
317    for book in view(store).books {
318        out.push_str("<section><h2>");
319        push_html(out, &book.title);
320        out.push_str("</h2>");
321        for chapter in book.chapters {
322            out.push_str("<div><h3>");
323            push_html(out, &chapter.title);
324            out.push_str("</h3><ul>");
325            for recipe in chapter.recipes {
326                let active = selected == Some(recipe.id.as_str());
327                out.push_str("<li><a href=\"/cookbook?recipe=");
328                push_attr(out, &recipe.id);
329                out.push('"');
330                if active {
331                    out.push_str(" aria-current=\"page\" class=\"selected\"");
332                }
333                out.push('>');
334                push_html(out, &recipe.title);
335                out.push_str("</a></li>");
336            }
337            out.push_str("</ul></div>");
338        }
339        out.push_str("</section>");
340    }
341}
342
343fn render_recipe_html(out: &mut String, store: &RecipeStore, card: &RecipeCard) {
344    out.push_str("<h1>");
345    push_html(out, &card.title);
346    out.push_str("</h1><div class=\"purpose\">");
347    push_purpose_html(out, &card.purpose);
348    out.push_str("</div><div class=\"recipe-actions\"><button type=\"button\">Copy</button><button type=\"button\">Run</button></div><pre><code>");
349    push_html(out, &String::from_utf8_lossy(&card.setup));
350    out.push_str("</code></pre><section class=\"results-panel\" aria-live=\"polite\">Run this recipe to see pass/fail data.</section><footer>");
351    if let Some(next) = next(store, &card.id) {
352        out.push_str("<a href=\"/cookbook?recipe=");
353        push_attr(out, &next.id);
354        out.push_str("\">Next recipe -&gt;</a>");
355    } else {
356        out.push_str("<span>No next recipe.</span>");
357    }
358    out.push_str("</footer>");
359}
360
361fn split_target(target: &str) -> (&str, Option<&str>) {
362    match target.split_once('?') {
363        Some((path, query)) => (path, Some(query)),
364        None => (target, None),
365    }
366}
367
368fn query_value(query: &str, key: &str) -> Option<String> {
369    query.split('&').find_map(|pair| {
370        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
371        (name == key).then(|| decode_component(value, true).unwrap_or_default())
372    })
373}
374
375fn decode_component(raw: &str, plus_is_space: bool) -> std::result::Result<String, ResolveError> {
376    let bytes = raw.as_bytes();
377    let mut out = Vec::with_capacity(bytes.len());
378    let mut i = 0;
379    while i < bytes.len() {
380        match bytes[i] {
381            b'%' if i + 2 < bytes.len() => {
382                let hi = hex(bytes[i + 1]);
383                let lo = hex(bytes[i + 2]);
384                match (hi, lo) {
385                    (Some(hi), Some(lo)) => out.push((hi << 4) | lo),
386                    _ => {
387                        return Err(ResolveError::BadRequest(
388                            "invalid percent escape in cookbook route".to_owned(),
389                        ));
390                    }
391                }
392                i += 3;
393            }
394            b'%' => {
395                return Err(ResolveError::BadRequest(
396                    "truncated percent escape in cookbook route".to_owned(),
397                ));
398            }
399            b'+' if plus_is_space => {
400                out.push(b' ');
401                i += 1;
402            }
403            byte => {
404                out.push(byte);
405                i += 1;
406            }
407        }
408    }
409    String::from_utf8(out).map_err(|err| ResolveError::BadRequest(err.to_string()))
410}
411
412fn hex(byte: u8) -> Option<u8> {
413    match byte {
414        b'0'..=b'9' => Some(byte - b'0'),
415        b'a'..=b'f' => Some(byte - b'a' + 10),
416        b'A'..=b'F' => Some(byte - b'A' + 10),
417        _ => None,
418    }
419}
420
421fn push_purpose_html(out: &mut String, purpose: &str) {
422    let mut wrote = false;
423    let mut open_p = false;
424    for raw in purpose.lines() {
425        let line = raw.trim();
426        if line.is_empty() {
427            if open_p {
428                out.push_str("</p>");
429                open_p = false;
430            }
431            continue;
432        }
433        if let Some(title) = line.strip_prefix("# ") {
434            if open_p {
435                out.push_str("</p>");
436                open_p = false;
437            }
438            out.push_str("<h2>");
439            push_html(out, title);
440            out.push_str("</h2>");
441        } else {
442            if !open_p {
443                out.push_str("<p>");
444                open_p = true;
445            } else {
446                out.push(' ');
447            }
448            push_html(out, line);
449        }
450        wrote = true;
451    }
452    if open_p {
453        out.push_str("</p>");
454    }
455    if !wrote {
456        out.push_str("<p>No purpose provided.</p>");
457    }
458}
459
460fn push_html(out: &mut String, text: &str) {
461    for ch in text.chars() {
462        match ch {
463            '&' => out.push_str("&amp;"),
464            '<' => out.push_str("&lt;"),
465            '>' => out.push_str("&gt;"),
466            '"' => out.push_str("&quot;"),
467            '\'' => out.push_str("&#39;"),
468            ch => out.push(ch),
469        }
470    }
471}
472
473fn push_attr(out: &mut String, text: &str) {
474    push_html(out, text);
475}