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