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#[derive(Clone, Debug)]
14pub struct CookbookWebState {
15 store: RecipeStore,
16}
17
18impl CookbookWebState {
19 pub fn seeded() -> Result<Self> {
21 Ok(Self {
22 store: seeded_recipe_store()?,
23 })
24 }
25
26 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 pub fn from_store(store: RecipeStore) -> Self {
43 Self { store }
44 }
45
46 pub fn empty() -> Self {
48 Self::from_store(RecipeStore::new())
49 }
50
51 pub fn store(&self) -> &RecipeStore {
53 &self.store
54 }
55
56 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 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#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct CookbookWebResponse {
132 pub status: u16,
134 pub content_type: &'static str,
136 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 -></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("&"),
395 '<' => out.push_str("<"),
396 '>' => out.push_str(">"),
397 '"' => out.push_str("""),
398 '\'' => out.push_str("'"),
399 ch => out.push(ch),
400 }
401 }
402}
403
404fn push_attr(out: &mut String, text: &str) {
405 push_html(out, text);
406}