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 {
254        let lib = id.strip_suffix("/cookbook-lifecycle/unload")?;
255        (LifecycleAction::Unload, lib)
256    };
257    directory
258        .entry(lib)
259        .map(|entry| (action, entry.id.as_str()))
260}
261
262fn resolve_recipe<'a>(
263    store: &'a RecipeStore,
264    id: &str,
265) -> std::result::Result<&'a RecipeCard, ResolveError> {
266    if id.trim().is_empty() {
267        return Err(ResolveError::BadRequest(
268            "cookbook recipe id must not be empty".to_owned(),
269        ));
270    }
271    if let Some(card) = store.card(id) {
272        return Ok(card);
273    }
274    let suffix = format!("/{id}");
275    let candidates: Vec<&RecipeCard> = ordered_cards(store)
276        .into_iter()
277        .filter(|card| card.id.ends_with(&suffix))
278        .collect();
279    match candidates.as_slice() {
280        [card] => Ok(*card),
281        [] => Err(ResolveError::Unknown(format!("unknown recipe {id}"))),
282        many => Err(ResolveError::Ambiguous(format!(
283            "ambiguous recipe {id}: {}",
284            many.iter()
285                .map(|card| card.id.as_str())
286                .collect::<Vec<_>>()
287                .join(", ")
288        ))),
289    }
290}
291
292fn render_page(store: &RecipeStore, selected: Option<&str>) -> String {
293    let selected_card = selected
294        .and_then(|id| resolve_recipe(store, id).ok())
295        .or_else(|| ordered_cards(store).first().copied());
296    let selected_id = selected_card.map(|card| card.id.as_str());
297    let mut out = String::from(
298        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">"#,
299    );
300    out.push_str(r#"<aside class="cookbook-rail"><input type="search" aria-label="Search recipes" placeholder="Search" /><nav class="cookbook-tree">"#);
301    if store.is_empty() {
302        out.push_str(r#"<p class="empty-state">No recipes loaded.</p>"#);
303    } else {
304        render_tree_html(&mut out, store, selected_id);
305    }
306    out.push_str(r#"</nav></aside><section class="cookbook-main">"#);
307    match selected_card {
308        Some(card) => render_recipe_html(&mut out, store, card),
309        None => out.push_str(r#"<h1>Cookbook</h1><p class="empty-state">No recipes loaded.</p>"#),
310    }
311    out.push_str("</section></main></body></html>");
312    out
313}
314
315fn render_tree_html(out: &mut String, store: &RecipeStore, selected: Option<&str>) {
316    for book in view(store).books {
317        out.push_str("<section><h2>");
318        push_html(out, &book.title);
319        out.push_str("</h2>");
320        for chapter in book.chapters {
321            out.push_str("<div><h3>");
322            push_html(out, &chapter.title);
323            out.push_str("</h3><ul>");
324            for recipe in chapter.recipes {
325                let active = selected == Some(recipe.id.as_str());
326                out.push_str("<li><a href=\"/cookbook?recipe=");
327                push_attr(out, &recipe.id);
328                out.push('"');
329                if active {
330                    out.push_str(" aria-current=\"page\" class=\"selected\"");
331                }
332                out.push('>');
333                push_html(out, &recipe.title);
334                out.push_str("</a></li>");
335            }
336            out.push_str("</ul></div>");
337        }
338        out.push_str("</section>");
339    }
340}
341
342fn render_recipe_html(out: &mut String, store: &RecipeStore, card: &RecipeCard) {
343    out.push_str("<h1>");
344    push_html(out, &card.title);
345    out.push_str("</h1><div class=\"purpose\">");
346    push_purpose_html(out, &card.purpose);
347    out.push_str("</div><div class=\"recipe-actions\"><button type=\"button\">Copy</button><button type=\"button\">Run</button></div><pre><code>");
348    push_html(out, &String::from_utf8_lossy(&card.setup));
349    out.push_str("</code></pre><section class=\"results-panel\" aria-live=\"polite\">Run this recipe to see pass/fail data.</section><footer>");
350    if let Some(next) = next(store, &card.id) {
351        out.push_str("<a href=\"/cookbook?recipe=");
352        push_attr(out, &next.id);
353        out.push_str("\">Next recipe -&gt;</a>");
354    } else {
355        out.push_str("<span>No next recipe.</span>");
356    }
357    out.push_str("</footer>");
358}
359
360fn split_target(target: &str) -> (&str, Option<&str>) {
361    match target.split_once('?') {
362        Some((path, query)) => (path, Some(query)),
363        None => (target, None),
364    }
365}
366
367fn query_value(query: &str, key: &str) -> Option<String> {
368    query.split('&').find_map(|pair| {
369        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
370        (name == key).then(|| decode_component(value, true).unwrap_or_default())
371    })
372}
373
374fn decode_component(raw: &str, plus_is_space: bool) -> std::result::Result<String, ResolveError> {
375    let bytes = raw.as_bytes();
376    let mut out = Vec::with_capacity(bytes.len());
377    let mut i = 0;
378    while i < bytes.len() {
379        match bytes[i] {
380            b'%' if i + 2 < bytes.len() => {
381                let hi = hex(bytes[i + 1]);
382                let lo = hex(bytes[i + 2]);
383                match (hi, lo) {
384                    (Some(hi), Some(lo)) => out.push((hi << 4) | lo),
385                    _ => {
386                        return Err(ResolveError::BadRequest(
387                            "invalid percent escape in cookbook route".to_owned(),
388                        ));
389                    }
390                }
391                i += 3;
392            }
393            b'%' => {
394                return Err(ResolveError::BadRequest(
395                    "truncated percent escape in cookbook route".to_owned(),
396                ));
397            }
398            b'+' if plus_is_space => {
399                out.push(b' ');
400                i += 1;
401            }
402            byte => {
403                out.push(byte);
404                i += 1;
405            }
406        }
407    }
408    String::from_utf8(out).map_err(|err| ResolveError::BadRequest(err.to_string()))
409}
410
411fn hex(byte: u8) -> Option<u8> {
412    match byte {
413        b'0'..=b'9' => Some(byte - b'0'),
414        b'a'..=b'f' => Some(byte - b'a' + 10),
415        b'A'..=b'F' => Some(byte - b'A' + 10),
416        _ => None,
417    }
418}
419
420fn push_purpose_html(out: &mut String, purpose: &str) {
421    let mut wrote = false;
422    let mut open_p = false;
423    for raw in purpose.lines() {
424        let line = raw.trim();
425        if line.is_empty() {
426            if open_p {
427                out.push_str("</p>");
428                open_p = false;
429            }
430            continue;
431        }
432        if let Some(title) = line.strip_prefix("# ") {
433            if open_p {
434                out.push_str("</p>");
435                open_p = false;
436            }
437            out.push_str("<h2>");
438            push_html(out, title);
439            out.push_str("</h2>");
440        } else {
441            if !open_p {
442                out.push_str("<p>");
443                open_p = true;
444            } else {
445                out.push(' ');
446            }
447            push_html(out, line);
448        }
449        wrote = true;
450    }
451    if open_p {
452        out.push_str("</p>");
453    }
454    if !wrote {
455        out.push_str("<p>No purpose provided.</p>");
456    }
457}
458
459fn push_html(out: &mut String, text: &str) {
460    for ch in text.chars() {
461        match ch {
462            '&' => out.push_str("&amp;"),
463            '<' => out.push_str("&lt;"),
464            '>' => out.push_str("&gt;"),
465            '"' => out.push_str("&quot;"),
466            '\'' => out.push_str("&#39;"),
467            ch => out.push(ch),
468        }
469    }
470}
471
472fn push_attr(out: &mut String, text: &str) {
473    push_html(out, text);
474}