Skip to main content

sim_lib_server/
cookbook_web.rs

1use sim_cookbook::{RecipeCard, RecipeStore, next, ordered_cards, view};
2use sim_kernel::{Cx, Result};
3use sim_lib_cookbook::{SeededLibCatalog, run_recipe_with_catalog, seeded_recipe_store};
4
5use crate::cookbook_web_json::{
6    render_error_json, render_index_json, render_recipe_json, render_run_json, render_search_json,
7};
8
9const JSON: &str = "application/json; charset=utf-8";
10const HTML: &str = "text/html; charset=utf-8";
11
12/// Cookbook state exposed by the WebUI route and JSON API.
13#[derive(Clone, Debug)]
14pub struct CookbookWebState {
15    store: RecipeStore,
16}
17
18impl CookbookWebState {
19    /// Build a WebUI state from the crate-shipped seeded recipe books.
20    pub fn seeded() -> Result<Self> {
21        Ok(Self {
22            store: seeded_recipe_store()?,
23        })
24    }
25
26    /// Build a WebUI state from the seeded books PLUS `extra` books injected by the
27    /// host (COOK8.02 facade injection). The seeded set covers the lower-graph
28    /// domains `sim-lib-cookbook` can depend on directly; a host above the cookbook
29    /// (sim-web-shell, the `sim` facade) passes its own higher-graph recipe books
30    /// here -- which cannot be seed-deps of the engine without a cycle -- so the
31    /// browsable catalog grows toward full coverage. A book that fails to register
32    /// is skipped rather than blanking the whole catalog; it simply does not appear.
33    pub fn seeded_with_books(extra: &[(&str, sim_cookbook::EmbeddedDir)]) -> Result<Self> {
34        let mut store = seeded_recipe_store()?;
35        for (_book, recipes) in extra {
36            let _ = store.register_book(recipes);
37        }
38        Ok(Self { store })
39    }
40
41    /// Build a WebUI state from an existing recipe store.
42    pub fn from_store(store: RecipeStore) -> Self {
43        Self { store }
44    }
45
46    /// Build an empty WebUI state for tests and no-recipes deployments.
47    pub fn empty() -> Self {
48        Self::from_store(RecipeStore::new())
49    }
50
51    /// Access the backing cookbook store.
52    pub fn store(&self) -> &RecipeStore {
53        &self.store
54    }
55
56    /// Route one cookbook WebUI request.
57    ///
58    /// `target` is an HTTP request target such as `/api/cookbook?q=x`. The run
59    /// endpoint requires a runtime context because it executes the same
60    /// `sim-lib-cookbook` recipe runner used by the runtime ops and CLI.
61    pub fn handle_request(
62        &self,
63        method: &str,
64        target: &str,
65        cx: Option<&mut Cx>,
66    ) -> CookbookWebResponse {
67        let (path, query) = split_target(target);
68        match (method, path) {
69            ("GET", "/cookbook") => {
70                let selected = query.and_then(|q| query_value(q, "recipe"));
71                CookbookWebResponse::html(render_page(&self.store, selected.as_deref()))
72            }
73            ("GET", "/api/cookbook") => CookbookWebResponse::json(render_index_json(&self.store)),
74            ("GET", "/api/cookbook/search") => {
75                let q = query
76                    .and_then(|query| query_value(query, "q"))
77                    .unwrap_or_default();
78                CookbookWebResponse::json(render_search_json(&self.store, &q))
79            }
80            ("POST", path)
81                if path.starts_with("/api/cookbook/recipe/") && path.ends_with("/run") =>
82            {
83                let start = "/api/cookbook/recipe/".len();
84                let end = path.len() - "/run".len();
85                let raw_id = &path[start..end];
86                let Some(cx) = cx else {
87                    return CookbookWebResponse::server_error(
88                        "cookbook run endpoint requires a runtime context",
89                    );
90                };
91                let card = match decode_component(raw_id, false)
92                    .and_then(|id| resolve_recipe(&self.store, &id))
93                {
94                    Ok(card) => card.clone(),
95                    Err(err) => return response_for_resolve_error(err),
96                };
97                // COOKBOOK_7: the run loads the recipe's `requires` from the
98                // catalog before eval, so a domain absent from the boot Cx is
99                // loaded on demand rather than failing as "not loaded".
100                let catalog = SeededLibCatalog::standard();
101                match run_recipe_with_catalog(cx, &catalog, &card) {
102                    Ok(run) => CookbookWebResponse::json(render_run_json(&run)),
103                    Err(err) => CookbookWebResponse::server_error(err.to_string()),
104                }
105            }
106            (_, path) if path.starts_with("/api/cookbook/recipe/") && path.ends_with("/run") => {
107                CookbookWebResponse::method_not_allowed()
108            }
109            ("GET", path) if path.starts_with("/api/cookbook/recipe/") => {
110                let raw_id = &path["/api/cookbook/recipe/".len()..];
111                match decode_component(raw_id, false)
112                    .and_then(|id| resolve_recipe(&self.store, &id))
113                {
114                    Ok(card) => CookbookWebResponse::json(render_recipe_json(&self.store, card)),
115                    Err(err) => response_for_resolve_error(err),
116                }
117            }
118            (_, "/api/cookbook") | (_, "/api/cookbook/search") | (_, "/cookbook") => {
119                CookbookWebResponse::method_not_allowed()
120            }
121            (_, path) if path.starts_with("/api/cookbook/recipe/") => {
122                CookbookWebResponse::method_not_allowed()
123            }
124            _ => CookbookWebResponse::not_found("unknown cookbook route"),
125        }
126    }
127}
128
129/// Minimal HTTP response model for cookbook route adapters.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct CookbookWebResponse {
132    /// HTTP status code.
133    pub status: u16,
134    /// Response `Content-Type`.
135    pub content_type: &'static str,
136    /// UTF-8 response body.
137    pub body: String,
138}
139
140impl CookbookWebResponse {
141    fn html(body: String) -> Self {
142        Self {
143            status: 200,
144            content_type: HTML,
145            body,
146        }
147    }
148
149    fn json(body: String) -> Self {
150        Self {
151            status: 200,
152            content_type: JSON,
153            body,
154        }
155    }
156
157    fn not_found(message: impl AsRef<str>) -> Self {
158        Self::error(404, message)
159    }
160
161    fn method_not_allowed() -> Self {
162        Self::error(405, "method not allowed")
163    }
164
165    fn server_error(message: impl AsRef<str>) -> Self {
166        Self::error(500, message)
167    }
168
169    fn error(status: u16, message: impl AsRef<str>) -> Self {
170        Self {
171            status,
172            content_type: JSON,
173            body: render_error_json(message.as_ref()),
174        }
175    }
176}
177
178#[derive(Debug, PartialEq, Eq)]
179enum ResolveError {
180    Unknown(String),
181    Ambiguous(String),
182    BadRequest(String),
183}
184
185fn response_for_resolve_error(err: ResolveError) -> CookbookWebResponse {
186    match err {
187        ResolveError::Unknown(message) => CookbookWebResponse::not_found(message),
188        ResolveError::Ambiguous(message) | ResolveError::BadRequest(message) => {
189            CookbookWebResponse::error(400, message)
190        }
191    }
192}
193
194fn resolve_recipe<'a>(
195    store: &'a RecipeStore,
196    id: &str,
197) -> std::result::Result<&'a RecipeCard, ResolveError> {
198    if id.trim().is_empty() {
199        return Err(ResolveError::BadRequest(
200            "cookbook recipe id must not be empty".to_owned(),
201        ));
202    }
203    if let Some(card) = store.card(id) {
204        return Ok(card);
205    }
206    let suffix = format!("/{id}");
207    let candidates: Vec<&RecipeCard> = ordered_cards(store)
208        .into_iter()
209        .filter(|card| card.id.ends_with(&suffix))
210        .collect();
211    match candidates.as_slice() {
212        [card] => Ok(*card),
213        [] => Err(ResolveError::Unknown(format!("unknown recipe {id}"))),
214        many => Err(ResolveError::Ambiguous(format!(
215            "ambiguous recipe {id}: {}",
216            many.iter()
217                .map(|card| card.id.as_str())
218                .collect::<Vec<_>>()
219                .join(", ")
220        ))),
221    }
222}
223
224fn render_page(store: &RecipeStore, selected: Option<&str>) -> String {
225    let selected_card = selected
226        .and_then(|id| resolve_recipe(store, id).ok())
227        .or_else(|| ordered_cards(store).first().copied());
228    let selected_id = selected_card.map(|card| card.id.as_str());
229    let mut out = String::from(
230        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">"#,
231    );
232    out.push_str(r#"<aside class="cookbook-rail"><input type="search" aria-label="Search recipes" placeholder="Search" /><nav class="cookbook-tree">"#);
233    if store.is_empty() {
234        out.push_str(r#"<p class="empty-state">No recipes loaded.</p>"#);
235    } else {
236        render_tree_html(&mut out, store, selected_id);
237    }
238    out.push_str(r#"</nav></aside><section class="cookbook-main">"#);
239    match selected_card {
240        Some(card) => render_recipe_html(&mut out, store, card),
241        None => out.push_str(r#"<h1>Cookbook</h1><p class="empty-state">No recipes loaded.</p>"#),
242    }
243    out.push_str("</section></main></body></html>");
244    out
245}
246
247fn render_tree_html(out: &mut String, store: &RecipeStore, selected: Option<&str>) {
248    for book in view(store).books {
249        out.push_str("<section><h2>");
250        push_html(out, &book.title);
251        out.push_str("</h2>");
252        for chapter in book.chapters {
253            out.push_str("<div><h3>");
254            push_html(out, &chapter.title);
255            out.push_str("</h3><ul>");
256            for recipe in chapter.recipes {
257                let active = selected == Some(recipe.id.as_str());
258                out.push_str("<li><a href=\"/cookbook?recipe=");
259                push_attr(out, &recipe.id);
260                out.push('"');
261                if active {
262                    out.push_str(" aria-current=\"page\" class=\"selected\"");
263                }
264                out.push('>');
265                push_html(out, &recipe.title);
266                out.push_str("</a></li>");
267            }
268            out.push_str("</ul></div>");
269        }
270        out.push_str("</section>");
271    }
272}
273
274fn render_recipe_html(out: &mut String, store: &RecipeStore, card: &RecipeCard) {
275    out.push_str("<h1>");
276    push_html(out, &card.title);
277    out.push_str("</h1><div class=\"purpose\">");
278    push_purpose_html(out, &card.purpose);
279    out.push_str("</div><div class=\"recipe-actions\"><button type=\"button\">Copy</button><button type=\"button\">Run</button></div><pre><code>");
280    push_html(out, &String::from_utf8_lossy(&card.setup));
281    out.push_str("</code></pre><section class=\"results-panel\" aria-live=\"polite\">Run this recipe to see pass/fail data.</section><footer>");
282    if let Some(next) = next(store, &card.id) {
283        out.push_str("<a href=\"/cookbook?recipe=");
284        push_attr(out, &next.id);
285        out.push_str("\">Next recipe -&gt;</a>");
286    } else {
287        out.push_str("<span>No next recipe.</span>");
288    }
289    out.push_str("</footer>");
290}
291
292fn split_target(target: &str) -> (&str, Option<&str>) {
293    match target.split_once('?') {
294        Some((path, query)) => (path, Some(query)),
295        None => (target, None),
296    }
297}
298
299fn query_value(query: &str, key: &str) -> Option<String> {
300    query.split('&').find_map(|pair| {
301        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
302        (name == key).then(|| decode_component(value, true).unwrap_or_default())
303    })
304}
305
306fn decode_component(raw: &str, plus_is_space: bool) -> std::result::Result<String, ResolveError> {
307    let bytes = raw.as_bytes();
308    let mut out = Vec::with_capacity(bytes.len());
309    let mut i = 0;
310    while i < bytes.len() {
311        match bytes[i] {
312            b'%' if i + 2 < bytes.len() => {
313                let hi = hex(bytes[i + 1]);
314                let lo = hex(bytes[i + 2]);
315                match (hi, lo) {
316                    (Some(hi), Some(lo)) => out.push((hi << 4) | lo),
317                    _ => {
318                        return Err(ResolveError::BadRequest(
319                            "invalid percent escape in cookbook route".to_owned(),
320                        ));
321                    }
322                }
323                i += 3;
324            }
325            b'%' => {
326                return Err(ResolveError::BadRequest(
327                    "truncated percent escape in cookbook route".to_owned(),
328                ));
329            }
330            b'+' if plus_is_space => {
331                out.push(b' ');
332                i += 1;
333            }
334            byte => {
335                out.push(byte);
336                i += 1;
337            }
338        }
339    }
340    String::from_utf8(out).map_err(|err| ResolveError::BadRequest(err.to_string()))
341}
342
343fn hex(byte: u8) -> Option<u8> {
344    match byte {
345        b'0'..=b'9' => Some(byte - b'0'),
346        b'a'..=b'f' => Some(byte - b'a' + 10),
347        b'A'..=b'F' => Some(byte - b'A' + 10),
348        _ => None,
349    }
350}
351
352fn push_purpose_html(out: &mut String, purpose: &str) {
353    let mut wrote = false;
354    let mut open_p = false;
355    for raw in purpose.lines() {
356        let line = raw.trim();
357        if line.is_empty() {
358            if open_p {
359                out.push_str("</p>");
360                open_p = false;
361            }
362            continue;
363        }
364        if let Some(title) = line.strip_prefix("# ") {
365            if open_p {
366                out.push_str("</p>");
367                open_p = false;
368            }
369            out.push_str("<h2>");
370            push_html(out, title);
371            out.push_str("</h2>");
372        } else {
373            if !open_p {
374                out.push_str("<p>");
375                open_p = true;
376            } else {
377                out.push(' ');
378            }
379            push_html(out, line);
380        }
381        wrote = true;
382    }
383    if open_p {
384        out.push_str("</p>");
385    }
386    if !wrote {
387        out.push_str("<p>No purpose provided.</p>");
388    }
389}
390
391fn push_html(out: &mut String, text: &str) {
392    for ch in text.chars() {
393        match ch {
394            '&' => out.push_str("&amp;"),
395            '<' => out.push_str("&lt;"),
396            '>' => out.push_str("&gt;"),
397            '"' => out.push_str("&quot;"),
398            '\'' => out.push_str("&#39;"),
399            ch => out.push(ch),
400        }
401    }
402}
403
404fn push_attr(out: &mut String, text: &str) {
405    push_html(out, text);
406}